Add performance gate to release verification

This commit is contained in:
2026-07-09 10:12:13 +09:00
parent 54e34d39ce
commit fd1e1ce495
2 changed files with 87 additions and 11 deletions

View File

@@ -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;