From fd1e1ce49570cc57152c1f54bca547ea3ede81d1 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 9 Jul 2026 10:12:13 +0900 Subject: [PATCH] Add performance gate to release verification --- package.json | 3 +- scripts/measure-performance.mjs | 95 +++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 1d510bc..78b38b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/measure-performance.mjs b/scripts/measure-performance.mjs index 9990f18..b6e90a5 100644 --- a/scripts/measure-performance.mjs +++ b/scripts/measure-performance.mjs @@ -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;