Files
heros_web/scripts/measure-performance.mjs

340 lines
12 KiB
JavaScript

import { spawn } from 'node:child_process';
import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
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 baselineViewport = { width: 1920, height: 1080 };
const legacyUiScale = 1.5;
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)
};
async function clickLegacyUi(page, x, y, options) {
await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options);
}
async function clickBattleDeploymentStart(page) {
const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const layout = scene?.layout;
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!scene || !layout || !canvas || !bounds) {
return null;
}
const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2;
const logicalY = layout.panelY + layout.panelHeight - 76 + 17;
return {
x: bounds.left + logicalX * bounds.width / scene.scale.width,
y: bounds.top + logicalY * bounds.height / scene.scale.height
};
});
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
throw new Error(`Expected a runtime deployment start point: ${JSON.stringify(point)}`);
}
await page.mouse.click(point.x, point.y);
}
let serverProcess;
let browser;
try {
serverProcess = await ensurePreviewServer(targetUrl);
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: baselineViewport });
await context.addInitScript(() => window.localStorage.clear());
const page = await context.newPage();
const titleStartedAt = Date.now();
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.waitForLoadState('networkidle', { timeout: 120000 });
const titleMs = Date.now() - titleStartedAt;
const titleEntries = await resourceEntries(page);
const storyStartedAt = Date.now();
await clickLegacyUi(page, 962, 240);
await waitForStoryReady(page);
await page.waitForLoadState('networkidle', { timeout: 120000 });
const storyMs = Date.now() - storyStartedAt;
const storyEntries = await resourceEntries(page);
const battleStartedAt = Date.now();
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 clickBattleDeploymentStart(page);
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));
const battleOnly = battleEntries.filter((entry) => !storyNames.has(entry.name));
const report = {
targetUrl,
measuredAt: new Date().toISOString(),
timingsMs: {
titleReady: titleMs,
storyReadyFromNewGame: storyMs,
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}`);
console.log(JSON.stringify({
timingsMs: report.timingsMs,
firstLoad: report.firstLoad.summary,
storyTransition: report.storyTransition.summary,
battleTransition: report.battleTransition.summary,
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) {
serverProcess.kill();
}
}
function withDebugParam(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
return parsed.toString();
}
async function waitForTitle(page) {
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('TitleScene');
}, undefined, { timeout: 90000 });
}
async function waitForStoryReady(page) {
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
return activeScenes.includes('StoryScene') && story?.ready === true;
}, undefined, { timeout: 120000 });
}
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(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 (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
state?.phase === 'idle' &&
state?.mapBackgroundReady === true &&
state?.triggeredBattleEvents?.includes('opening') &&
state?.resultVisible === false
);
}, battleId, { timeout: 30000 });
}
async function resourceEntries(page) {
return page.evaluate(() =>
performance.getEntriesByType('resource').map((entry) => ({
name: entry.name,
initiatorType: entry.initiatorType,
startTime: Math.round(entry.startTime),
duration: Math.round(entry.duration),
transferSize: entry.transferSize || 0,
encodedBodySize: entry.encodedBodySize || 0
}))
);
}
function segmentStats(entries) {
const encodedBytes = entries.reduce((sum, entry) => sum + entry.encodedBodySize, 0);
const transferBytes = entries.reduce((sum, entry) => sum + entry.transferSize, 0);
const byExt = {};
entries.forEach((entry) => {
const ext = extensionOf(entry.name);
byExt[ext] ??= { count: 0, encodedKB: 0, transferKB: 0 };
byExt[ext].count += 1;
byExt[ext].encodedKB += bytesToKb(entry.encodedBodySize);
byExt[ext].transferKB += bytesToKb(entry.transferSize);
});
return {
summary: {
requests: entries.length,
encodedKB: bytesToKb(encodedBytes),
transferKB: bytesToKb(transferBytes)
},
byExt,
largest: [...entries]
.sort((left, right) => right.encodedBodySize - left.encodedBodySize)
.slice(0, 12)
.map((entry) => ({
file: entry.name.split('/').pop(),
encodedKB: bytesToKb(entry.encodedBodySize),
startMs: entry.startTime,
durationMs: entry.duration
}))
};
}
function buildStats() {
const assetsDir = join(process.cwd(), 'dist', 'assets');
if (!existsSync(assetsDir)) {
return { javascript: [], sourcemaps: [] };
}
const assets = readdirSync(assetsDir)
.map((file) => ({ file, sizeKB: bytesToKb(statSync(join(assetsDir, file)).size) }))
.sort((left, right) => right.sizeKB - left.sizeKB);
return {
javascript: assets.filter((asset) => asset.file.endsWith('.js')),
sourcemaps: assets.filter((asset) => asset.file.endsWith('.map'))
};
}
function extensionOf(name) {
const path = name.split('?')[0];
return path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() ?? 'other';
}
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;
}
const parsed = new URL(url);
const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
if (!isLocal) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], {
cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe']
});
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
child.stdout.on('data', () => {});
for (let i = 0; i < 120; i += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(`Vite preview did not start at ${url}\n${stderr.join('')}`);
}
async function canReach(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}