Add performance gate to release verification
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"generate:sfx": "node scripts/generate-sfx.mjs",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"measure:performance": "node scripts/measure-performance.mjs",
|
||||
"verify:performance": "node scripts/measure-performance.mjs",
|
||||
"verify:battle-data": "node scripts/verify-battle-scenario-data.mjs",
|
||||
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
||||
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
|
||||
@@ -29,7 +30,7 @@
|
||||
"verify:flow": "node scripts/verify-flow.mjs",
|
||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||
"verify:local-release": "pnpm run verify:static-data && tsc --noEmit && pnpm run build && pnpm run verify:release",
|
||||
"verify:local-release": "pnpm run verify:static-data && tsc --noEmit && pnpm run build && pnpm run verify:release && pnpm run verify:performance",
|
||||
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
|
||||
"qa:representative": "node scripts/qa-representative-battles.mjs",
|
||||
"qa:smoke": "node scripts/qa-representative-battles.mjs --set=smoke",
|
||||
|
||||
@@ -5,6 +5,16 @@ import { chromium } from 'playwright';
|
||||
|
||||
const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/');
|
||||
const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json';
|
||||
const budgets = {
|
||||
titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000),
|
||||
storyReadyFromNewGameMs: readBudget('PERF_BUDGET_STORY_READY_MS', 30000),
|
||||
firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 60000),
|
||||
firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 70000),
|
||||
firstLoadEncodedKB: readBudget('PERF_BUDGET_FIRST_LOAD_KB', 10000),
|
||||
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 130000),
|
||||
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 50000),
|
||||
largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500)
|
||||
};
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
@@ -31,12 +41,20 @@ try {
|
||||
const storyEntries = await resourceEntries(page);
|
||||
|
||||
const battleStartedAt = Date.now();
|
||||
await advanceStoryUntilBattle(page);
|
||||
await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery');
|
||||
const battleReadyState = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const battleMs = Date.now() - battleStartedAt;
|
||||
const battleEntries = await resourceEntries(page);
|
||||
|
||||
let firstBattlePlayableMs = battleMs;
|
||||
if (battleReadyState.phase === 'deployment') {
|
||||
const playableStartedAt = Date.now();
|
||||
await page.mouse.click(1085, 637);
|
||||
await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
|
||||
firstBattlePlayableMs += Date.now() - playableStartedAt;
|
||||
}
|
||||
|
||||
const titleNames = new Set(titleEntries.map((entry) => entry.name));
|
||||
const storyNames = new Set(storyEntries.map((entry) => entry.name));
|
||||
const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name));
|
||||
@@ -47,13 +65,15 @@ try {
|
||||
timingsMs: {
|
||||
titleReady: titleMs,
|
||||
storyReadyFromNewGame: storyMs,
|
||||
firstBattleReadyFromStory: battleMs
|
||||
firstBattleReadyFromStory: battleMs,
|
||||
firstBattlePlayableFromStory: firstBattlePlayableMs
|
||||
},
|
||||
build: buildStats(),
|
||||
firstLoad: segmentStats(titleEntries),
|
||||
storyTransition: segmentStats(storyOnly),
|
||||
battleTransition: segmentStats(battleOnly)
|
||||
};
|
||||
report.budget = evaluateBudgets(report, budgets);
|
||||
|
||||
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`Performance report written to ${outputPath}`);
|
||||
@@ -62,8 +82,12 @@ try {
|
||||
firstLoad: report.firstLoad.summary,
|
||||
storyTransition: report.storyTransition.summary,
|
||||
battleTransition: report.battleTransition.summary,
|
||||
largestJs: report.build.javascript[0] ?? null
|
||||
largestJs: report.build.javascript[0] ?? null,
|
||||
budget: report.budget.summary
|
||||
}, null, 2));
|
||||
if (report.budget.failures.length > 0) {
|
||||
throw new Error(`Performance budget failed: ${report.budget.failures.map((failure) => `${failure.metric}=${failure.actual} > ${failure.budget}`).join(', ')}`);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
@@ -95,19 +119,37 @@ async function waitForStoryReady(page) {
|
||||
}, undefined, { timeout: 120000 });
|
||||
}
|
||||
|
||||
async function advanceStoryUntilBattle(page) {
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
||||
if (activeScenes.includes('BattleScene')) {
|
||||
async function advanceStoryUntilBattle(page, battleId) {
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const ready = await page.evaluate((expectedBattleId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId;
|
||||
}, battleId);
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(160);
|
||||
await page.waitForTimeout(220);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBattleReady(page, battleId) {
|
||||
return page.waitForFunction((expectedBattleId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
state?.scene === 'BattleScene' &&
|
||||
state?.battleId === expectedBattleId &&
|
||||
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
||||
state?.mapBackgroundReady === true &&
|
||||
state?.resultVisible === false
|
||||
)
|
||||
? { phase: state.phase }
|
||||
: false;
|
||||
}, battleId, { timeout: 120000 }).then((handle) => handle.jsonValue());
|
||||
}
|
||||
|
||||
async function waitForBattlePlayable(page, battleId) {
|
||||
await page.waitForFunction((expectedBattleId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
@@ -115,9 +157,10 @@ async function waitForBattleReady(page, battleId) {
|
||||
state?.battleId === expectedBattleId &&
|
||||
state?.phase === 'idle' &&
|
||||
state?.mapBackgroundReady === true &&
|
||||
state?.triggeredBattleEvents?.includes('opening') &&
|
||||
state?.resultVisible === false
|
||||
);
|
||||
}, battleId, { timeout: 120000 });
|
||||
}, battleId, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function resourceEntries(page) {
|
||||
@@ -190,6 +233,38 @@ function bytesToKb(bytes) {
|
||||
return Math.round((bytes / 1024) * 10) / 10;
|
||||
}
|
||||
|
||||
function readBudget(name, fallback) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function evaluateBudgets(report, budgetValues) {
|
||||
const largestJs = report.build.javascript[0]?.sizeKB ?? 0;
|
||||
const checks = [
|
||||
['titleReadyMs', report.timingsMs.titleReady, budgetValues.titleReadyMs],
|
||||
['storyReadyFromNewGameMs', report.timingsMs.storyReadyFromNewGame, budgetValues.storyReadyFromNewGameMs],
|
||||
['firstBattleReadyFromStoryMs', report.timingsMs.firstBattleReadyFromStory, budgetValues.firstBattleReadyFromStoryMs],
|
||||
['firstBattlePlayableFromStoryMs', report.timingsMs.firstBattlePlayableFromStory, budgetValues.firstBattlePlayableFromStoryMs],
|
||||
['firstLoadEncodedKB', report.firstLoad.summary.encodedKB, budgetValues.firstLoadEncodedKB],
|
||||
['storyTransitionEncodedKB', report.storyTransition.summary.encodedKB, budgetValues.storyTransitionEncodedKB],
|
||||
['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB],
|
||||
['largestJsKB', largestJs, budgetValues.largestJsKB]
|
||||
];
|
||||
const failures = checks
|
||||
.filter(([, actual, budget]) => actual > budget)
|
||||
.map(([metric, actual, budget]) => ({ metric, actual, budget }));
|
||||
|
||||
return {
|
||||
summary: {
|
||||
status: failures.length === 0 ? 'pass' : 'fail',
|
||||
checked: checks.length,
|
||||
failures: failures.length
|
||||
},
|
||||
budgets: budgetValues,
|
||||
failures
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePreviewServer(url) {
|
||||
if (await canReach(url)) {
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user