perf: stream story assets by page
This commit is contained in:
@@ -4,9 +4,14 @@ import { join } from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
|
||||
|
||||
const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/');
|
||||
const measureRenderer = readRenderer(process.env.MEASURE_RENDERER ?? 'webgl');
|
||||
const targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/heros_web/');
|
||||
const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json';
|
||||
const baselineViewport = desktopBrowserViewport;
|
||||
const chromiumLaunchOptions = Object.freeze({
|
||||
headless: true,
|
||||
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
||||
});
|
||||
const legacyUiScale = 1.5;
|
||||
const budgets = {
|
||||
titleReadyMs: readBudget('PERF_BUDGET_TITLE_READY_MS', 30000),
|
||||
@@ -14,8 +19,10 @@ const budgets = {
|
||||
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),
|
||||
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 15000),
|
||||
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 110000),
|
||||
storyBattleTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_BATTLE_TRANSITION_KB', 125000),
|
||||
storyActionSheetRequests: 0,
|
||||
largestJsKB: readBudget('PERF_BUDGET_LARGEST_JS_KB', 2500)
|
||||
};
|
||||
|
||||
@@ -50,14 +57,35 @@ let browser;
|
||||
|
||||
try {
|
||||
serverProcess = await ensurePreviewServer(targetUrl);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
browser = await chromium.launch(chromiumLaunchOptions);
|
||||
const context = await browser.newContext(desktopBrowserContextOptions);
|
||||
await context.addInitScript(() => window.localStorage.clear());
|
||||
await context.addInitScript(() => {
|
||||
window.localStorage.clear();
|
||||
window.__HEROS_WEBGL_QA_EVENTS__ = [];
|
||||
window.addEventListener(
|
||||
'webglcontextlost',
|
||||
(event) => {
|
||||
window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() });
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener(
|
||||
'webglcontextrestored',
|
||||
(event) => {
|
||||
window.__HEROS_WEBGL_QA_EVENTS__.push({ type: event.type, at: performance.now() });
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
const titleStartedAt = Date.now();
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
const rendering = await readRenderingProbe(page);
|
||||
assertRenderingProbe(rendering, measureRenderer);
|
||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||
const titleMs = Date.now() - titleStartedAt;
|
||||
const titleEntries = await resourceEntries(page);
|
||||
@@ -88,11 +116,14 @@ try {
|
||||
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 storyTransition = segmentStats(storyOnly);
|
||||
const battleTransition = segmentStats(battleOnly);
|
||||
const report = {
|
||||
targetUrl,
|
||||
measuredAt: new Date().toISOString(),
|
||||
viewport: { ...baselineViewport },
|
||||
deviceScaleFactor: desktopBrowserContextOptions.deviceScaleFactor,
|
||||
rendering,
|
||||
timingsMs: {
|
||||
titleReady: titleMs,
|
||||
storyReadyFromNewGame: storyMs,
|
||||
@@ -101,8 +132,12 @@ try {
|
||||
},
|
||||
build: buildStats(),
|
||||
firstLoad: segmentStats(titleEntries),
|
||||
storyTransition: segmentStats(storyOnly),
|
||||
battleTransition: segmentStats(battleOnly)
|
||||
storyTransition,
|
||||
battleTransition,
|
||||
assetPolicy: {
|
||||
storyActionSheetRequests: actionSheetFiles(storyOnly),
|
||||
battleActionSheetRequests: actionSheetFiles(battleOnly)
|
||||
}
|
||||
};
|
||||
report.budget = evaluateBudgets(report, budgets);
|
||||
|
||||
@@ -119,6 +154,9 @@ try {
|
||||
if (report.budget.failures.length > 0) {
|
||||
throw new Error(`Performance budget failed: ${report.budget.failures.map((failure) => `${failure.metric}=${failure.actual} > ${failure.budget}`).join(', ')}`);
|
||||
}
|
||||
if (pageErrors.length > 0) {
|
||||
throw new Error(`Performance run reported page errors: ${JSON.stringify(pageErrors, null, 2)}`);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
@@ -129,17 +167,110 @@ try {
|
||||
function withDebugParam(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('debug', '1');
|
||||
parsed.searchParams.set('renderer', 'canvas');
|
||||
parsed.searchParams.set('renderer', measureRenderer);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function readRenderer(value) {
|
||||
if (value !== 'webgl' && value !== 'canvas') {
|
||||
throw new Error(`MEASURE_RENDERER must be "webgl" or "canvas", received "${value}".`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readRenderingProbe(page) {
|
||||
return page.evaluate(() => {
|
||||
const game = window.__HEROS_GAME__;
|
||||
const canvas = document.querySelector('canvas');
|
||||
const bounds = canvas?.getBoundingClientRect();
|
||||
const gl = game?.renderer?.gl ?? null;
|
||||
const webgl2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
|
||||
const webgl1 = typeof WebGLRenderingContext !== 'undefined' && gl instanceof WebGLRenderingContext;
|
||||
|
||||
return {
|
||||
requestedRenderer: new URLSearchParams(window.location.search).get('renderer'),
|
||||
renderer: {
|
||||
type: game?.renderer?.type ?? null,
|
||||
name: game?.renderer?.constructor?.name ?? null
|
||||
},
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
deviceScaleFactor: window.devicePixelRatio
|
||||
},
|
||||
canvas: canvas
|
||||
? {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
clientWidth: canvas.clientWidth,
|
||||
clientHeight: canvas.clientHeight,
|
||||
bounds: bounds
|
||||
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
||||
: null
|
||||
}
|
||||
: null,
|
||||
webgl: gl
|
||||
? {
|
||||
contextType: webgl2 ? 'webgl2' : webgl1 ? 'webgl' : 'unknown',
|
||||
drawingBufferWidth: gl.drawingBufferWidth,
|
||||
drawingBufferHeight: gl.drawingBufferHeight,
|
||||
contextLost: gl.isContextLost(),
|
||||
errorCode: gl.getError(),
|
||||
noErrorCode: gl.NO_ERROR
|
||||
}
|
||||
: null,
|
||||
webglEvents: window.__HEROS_WEBGL_QA_EVENTS__ ?? []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function assertRenderingProbe(probe, expectedRenderer) {
|
||||
const expectedRendererType = expectedRenderer === 'webgl' ? 2 : 1;
|
||||
assert(probe.requestedRenderer === expectedRenderer, `Expected ${expectedRenderer} query parameter: ${JSON.stringify(probe)}`);
|
||||
assert(
|
||||
probe.viewport?.width === baselineViewport.width &&
|
||||
probe.viewport?.height === baselineViewport.height &&
|
||||
probe.viewport?.deviceScaleFactor === desktopBrowserContextOptions.deviceScaleFactor,
|
||||
`Expected ${baselineViewport.width}x${baselineViewport.height} DPR ${desktopBrowserContextOptions.deviceScaleFactor} viewport: ${JSON.stringify(probe)}`
|
||||
);
|
||||
assert(
|
||||
probe.canvas?.width === baselineViewport.width &&
|
||||
probe.canvas?.height === baselineViewport.height &&
|
||||
probe.canvas?.clientWidth === baselineViewport.width &&
|
||||
probe.canvas?.clientHeight === baselineViewport.height &&
|
||||
probe.canvas?.bounds?.width === baselineViewport.width &&
|
||||
probe.canvas?.bounds?.height === baselineViewport.height,
|
||||
`Expected an exact FHD game canvas: ${JSON.stringify(probe)}`
|
||||
);
|
||||
assert(probe.renderer?.type === expectedRendererType, `Expected Phaser ${expectedRenderer} renderer type ${expectedRendererType}: ${JSON.stringify(probe)}`);
|
||||
if (expectedRenderer === 'webgl') {
|
||||
assert(probe.webgl?.contextType === 'webgl' || probe.webgl?.contextType === 'webgl2', `Expected an active WebGL context: ${JSON.stringify(probe)}`);
|
||||
assert(
|
||||
probe.webgl.drawingBufferWidth === baselineViewport.width && probe.webgl.drawingBufferHeight === baselineViewport.height,
|
||||
`Expected an FHD WebGL drawing buffer: ${JSON.stringify(probe)}`
|
||||
);
|
||||
assert(probe.webgl.contextLost === false, `Expected the WebGL context to remain active: ${JSON.stringify(probe)}`);
|
||||
assert(probe.webgl.errorCode === probe.webgl.noErrorCode, `Expected no WebGL error flag: ${JSON.stringify(probe)}`);
|
||||
assert(probe.webglEvents.length === 0, `Expected no WebGL context loss/restoration events: ${JSON.stringify(probe)}`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
const webglEvents = window.__HEROS_WEBGL_QA_EVENTS__ ?? [];
|
||||
return activeScenes.includes('TitleScene') || webglEvents.some((event) => event.type === 'webglcontextlost');
|
||||
}, undefined, { timeout: 90000 });
|
||||
const startupState = await page.evaluate(() => ({
|
||||
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
||||
webglEvents: window.__HEROS_WEBGL_QA_EVENTS__ ?? []
|
||||
}));
|
||||
assert(
|
||||
startupState.activeScenes.includes('TitleScene'),
|
||||
`Renderer failed before TitleScene became active: ${JSON.stringify(startupState)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForStoryReady(page) {
|
||||
@@ -153,8 +284,12 @@ async function waitForStoryReady(page) {
|
||||
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;
|
||||
try {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return state?.scene === 'BattleScene' && state?.battleId === expectedBattleId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, battleId);
|
||||
if (ready) {
|
||||
return;
|
||||
@@ -167,30 +302,38 @@ async function advanceStoryUntilBattle(page, battleId) {
|
||||
|
||||
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;
|
||||
try {
|
||||
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;
|
||||
} catch {
|
||||
return 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
|
||||
);
|
||||
try {
|
||||
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
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, battleId, { timeout: 30000 });
|
||||
}
|
||||
|
||||
@@ -260,6 +403,12 @@ function extensionOf(name) {
|
||||
return path.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() ?? 'other';
|
||||
}
|
||||
|
||||
function actionSheetFiles(entries) {
|
||||
return entries
|
||||
.map((entry) => decodeURIComponent(entry.name.split('/').pop() ?? entry.name))
|
||||
.filter((file) => /^unit-.*-actions(?:-[^.]+)?\.png(?:[?#]|$)/i.test(file));
|
||||
}
|
||||
|
||||
function bytesToKb(bytes) {
|
||||
return Math.round((bytes / 1024) * 10) / 10;
|
||||
}
|
||||
@@ -271,6 +420,9 @@ function readBudget(name, fallback) {
|
||||
|
||||
function evaluateBudgets(report, budgetValues) {
|
||||
const largestJs = report.build.javascript[0]?.sizeKB ?? 0;
|
||||
const storyBattleTransitionEncodedKB = bytesToKb(
|
||||
(report.storyTransition.summary.encodedKB + report.battleTransition.summary.encodedKB) * 1024
|
||||
);
|
||||
const checks = [
|
||||
['titleReadyMs', report.timingsMs.titleReady, budgetValues.titleReadyMs],
|
||||
['storyReadyFromNewGameMs', report.timingsMs.storyReadyFromNewGame, budgetValues.storyReadyFromNewGameMs],
|
||||
@@ -279,6 +431,8 @@ function evaluateBudgets(report, budgetValues) {
|
||||
['firstLoadEncodedKB', report.firstLoad.summary.encodedKB, budgetValues.firstLoadEncodedKB],
|
||||
['storyTransitionEncodedKB', report.storyTransition.summary.encodedKB, budgetValues.storyTransitionEncodedKB],
|
||||
['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB],
|
||||
['storyBattleTransitionEncodedKB', storyBattleTransitionEncodedKB, budgetValues.storyBattleTransitionEncodedKB],
|
||||
['storyActionSheetRequests', report.assetPolicy.storyActionSheetRequests.length, budgetValues.storyActionSheetRequests],
|
||||
['largestJsKB', largestJs, budgetValues.largestJsKB]
|
||||
];
|
||||
const failures = checks
|
||||
@@ -340,3 +494,9 @@ async function canReach(url) {
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user