703 lines
28 KiB
JavaScript
703 lines
28 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';
|
|
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
|
|
|
|
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),
|
|
storyReadyFromNewGameMs: readBudget('PERF_BUDGET_STORY_READY_MS', 30000),
|
|
firstBattleReadyFromStoryMs: readBudget('PERF_BUDGET_BATTLE_READY_MS', 6000),
|
|
firstBattlePlayableFromStoryMs: readBudget('PERF_BUDGET_BATTLE_PLAYABLE_MS', 25000),
|
|
firstLoadEncodedKB: readBudget('PERF_BUDGET_FIRST_LOAD_KB', 10000),
|
|
storyTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_TRANSITION_KB', 15000),
|
|
battleEntryTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_ENTRY_KB', 45000),
|
|
battleTransitionEncodedKB: readBudget('PERF_BUDGET_BATTLE_TRANSITION_KB', 110000),
|
|
storyBattleTransitionEncodedKB: readBudget('PERF_BUDGET_STORY_BATTLE_TRANSITION_KB', 125000),
|
|
storyActionSheetRequests: 0,
|
|
battleEntryActionSheetRequests: 0,
|
|
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(chromiumLaunchOptions);
|
|
const context = await browser.newContext(desktopBrowserContextOptions);
|
|
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);
|
|
|
|
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 battleTransitionStart = await advanceStoryUntilBattle(page, 'first-battle-zhuo-commandery');
|
|
const battleStartedAt = battleTransitionStart.startedAt;
|
|
const battleReadyState = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
|
const battleReadyMs = Date.now() - battleStartedAt;
|
|
const battleReadyEntries = await resourceEntries(page);
|
|
|
|
let firstBattlePlayableMs = battleReadyMs;
|
|
let battlePlayableState = battleReadyState;
|
|
if (battleReadyState.phase === 'deployment') {
|
|
await clickBattleDeploymentStart(page);
|
|
battlePlayableState = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
|
|
firstBattlePlayableMs = Date.now() - battleStartedAt;
|
|
}
|
|
assert(
|
|
battleReadyState.combatAssets?.baseReady === true && battleReadyState.combatAssets?.actionReady === false,
|
|
`Expected deployment to open from base sheets before action sheets: ${JSON.stringify(battleReadyState)}`
|
|
);
|
|
assert(
|
|
battlePlayableState.combatAssets?.status === 'ready' || battlePlayableState.combatAssets?.status === 'degraded',
|
|
`Expected combat assets to settle before battle play: ${JSON.stringify(battlePlayableState)}`
|
|
);
|
|
if (battlePlayableState.combatAssets?.status === 'ready') {
|
|
assert(
|
|
battlePlayableState.combatAssets.actionReady === true && battlePlayableState.combatAssets.portraitReady === true,
|
|
`Expected all deferred combat assets before battle play: ${JSON.stringify(battlePlayableState)}`
|
|
);
|
|
}
|
|
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
|
const battleEntries = await resourceEntries(page);
|
|
const combatAssetStallFallback = measureRenderer === 'webgl'
|
|
? {
|
|
action: await verifyCombatAssetStallFallback(browser, targetUrl, 'action'),
|
|
portrait: await verifyCombatAssetStallFallback(browser, targetUrl, 'portrait')
|
|
}
|
|
: null;
|
|
|
|
const titleNames = new Set(titleEntries.map((entry) => entry.name));
|
|
const preBattleNames = new Set(battleTransitionStart.resourceEntries.map((entry) => entry.name));
|
|
const battleReadyNames = new Set(battleReadyEntries.map((entry) => entry.name));
|
|
const storyOnly = storyEntries.filter((entry) => !titleNames.has(entry.name));
|
|
const battleEntryOnly = battleReadyEntries.filter((entry) => !preBattleNames.has(entry.name));
|
|
const battleOnly = battleEntries.filter((entry) => !preBattleNames.has(entry.name));
|
|
const battleDeferredOnly = battleOnly.filter((entry) => !battleReadyNames.has(entry.name));
|
|
const storyTransition = segmentStats(storyOnly);
|
|
const battleEntryTransition = segmentStats(battleEntryOnly);
|
|
const battleDeferredTransition = segmentStats(battleDeferredOnly);
|
|
const battleTransition = segmentStats(battleOnly);
|
|
const report = {
|
|
targetUrl,
|
|
measuredAt: new Date().toISOString(),
|
|
viewport: { ...baselineViewport },
|
|
deviceScaleFactor: desktopBrowserContextOptions.deviceScaleFactor,
|
|
rendering,
|
|
timingsMs: {
|
|
titleReady: titleMs,
|
|
storyReadyFromNewGame: storyMs,
|
|
firstBattleReadyFromStory: battleReadyMs,
|
|
firstBattlePlayableFromStory: firstBattlePlayableMs
|
|
},
|
|
build: buildStats(),
|
|
firstLoad: segmentStats(titleEntries),
|
|
storyTransition,
|
|
battleEntryTransition,
|
|
battleDeferredTransition,
|
|
battleTransition,
|
|
assetPolicy: {
|
|
storyActionSheetRequests: actionSheetFiles(storyOnly),
|
|
battleEntryActionSheetRequests: actionSheetFiles(battleEntryOnly),
|
|
battleDeferredActionSheetRequests: actionSheetFiles(battleDeferredOnly),
|
|
battleActionSheetRequests: actionSheetFiles(battleOnly)
|
|
},
|
|
battleAssetStreaming: {
|
|
entry: battleReadyState.combatAssets,
|
|
playable: battlePlayableState.combatAssets
|
|
},
|
|
combatAssetStallFallback
|
|
};
|
|
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,
|
|
battleEntryTransition: report.battleEntryTransition.summary,
|
|
battleDeferredTransition: report.battleDeferredTransition.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(', ')}`);
|
|
}
|
|
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) {
|
|
serverProcess.kill();
|
|
}
|
|
}
|
|
|
|
function withDebugParam(url) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
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() ?? [];
|
|
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) {
|
|
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 state = await page.evaluate((expectedBattleId) => {
|
|
try {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
|
|
return {
|
|
battleReady: battle?.scene === 'BattleScene' && battle?.battleId === expectedBattleId,
|
|
storyReady: story?.ready === true,
|
|
storyLastPage: story?.isLastPage === true,
|
|
storyTargetsBattle: story?.nextScene === 'BattleScene',
|
|
currentPageReady: story?.assetStreaming?.currentPageReady === true,
|
|
villageReady: window.__HEROS_DEBUG__?.village?.()?.ready === true,
|
|
militiaCampReady: window.__HEROS_DEBUG__?.militiaCamp?.()?.ready === true
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}, battleId);
|
|
if (state.battleReady) {
|
|
throw new Error('Battle became ready before the transition start could be measured.');
|
|
}
|
|
if (state.storyReady && state.storyLastPage && state.storyTargetsBattle && state.currentPageReady) {
|
|
await page.waitForTimeout(600);
|
|
const entries = await resourceEntries(page);
|
|
const startedAt = Date.now();
|
|
await page.keyboard.press('Space');
|
|
return { startedAt, resourceEntries: entries };
|
|
}
|
|
|
|
if (state.villageReady) {
|
|
const advanced = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene');
|
|
return scene?.debugCompleteVillage?.() ?? false;
|
|
});
|
|
if (!advanced) {
|
|
throw new Error('Playable prologue village was ready but could not advance to the brotherhood story.');
|
|
}
|
|
await page.waitForTimeout(400);
|
|
continue;
|
|
}
|
|
|
|
if (state.militiaCampReady) {
|
|
const advanced = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene');
|
|
return scene?.debugCompleteCamp?.() ?? false;
|
|
});
|
|
if (!advanced) {
|
|
throw new Error('Playable prologue militia camp was ready but could not advance to the departure story.');
|
|
}
|
|
await page.waitForTimeout(400);
|
|
continue;
|
|
}
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(220);
|
|
}
|
|
throw new Error(`Story did not reach the battle transition for ${battleId}.`);
|
|
}
|
|
|
|
async function waitForBattleReady(page, battleId) {
|
|
return page.waitForFunction((expectedBattleId) => {
|
|
try {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
|
state?.mapBackgroundReady === true &&
|
|
state?.combatAssets?.baseReady === true &&
|
|
state?.resultVisible === false
|
|
)
|
|
? { phase: state.phase, combatAssets: state.combatAssets }
|
|
: false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, battleId, { timeout: 120000 }).then((handle) => handle.jsonValue());
|
|
}
|
|
|
|
async function waitForBattlePlayable(page, battleId) {
|
|
return page.waitForFunction((expectedBattleId) => {
|
|
try {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
state?.phase === 'idle' &&
|
|
state?.mapBackgroundReady === true &&
|
|
(state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded') &&
|
|
state?.triggeredBattleEvents?.includes('opening') &&
|
|
state?.resultVisible === false
|
|
)
|
|
? { phase: state.phase, combatAssets: state.combatAssets }
|
|
: false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, battleId, { timeout: 30000 }).then((handle) => handle.jsonValue());
|
|
}
|
|
|
|
async function verifyCombatAssetStallFallback(browserInstance, baseUrl, kind) {
|
|
const context = await browserInstance.newContext(desktopBrowserContextOptions);
|
|
const pageErrors = [];
|
|
let stalledUrl = null;
|
|
const stallReleaseMs = 1800;
|
|
const watchdogMs = 450;
|
|
|
|
try {
|
|
await context.addInitScript(() => window.localStorage.clear());
|
|
const page = await context.newPage();
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
await page.route('**/*', async (route) => {
|
|
const url = route.request().url();
|
|
const fileName = decodeURIComponent(url.split('/').pop()?.split('?')[0] ?? '');
|
|
const shouldStall = kind === 'action'
|
|
? /^unit-.*-actions-[^.]+\.(?:png|webp)$/i.test(fileName)
|
|
: /^(?:liu-bei|guan-yu|zhang-fei)-[^.]+\.webp$/i.test(fileName);
|
|
const isSelectedStall = shouldStall && (!stalledUrl || stalledUrl === url);
|
|
if (isSelectedStall) {
|
|
stalledUrl ??= url;
|
|
await delay(stallReleaseMs);
|
|
await route.abort('timedout');
|
|
return;
|
|
}
|
|
await route.continue();
|
|
});
|
|
|
|
const battleUrl = new URL(baseUrl);
|
|
battleUrl.searchParams.set('debug', '1');
|
|
battleUrl.searchParams.set('renderer', 'webgl');
|
|
battleUrl.searchParams.set('debugBattle', 'first-battle-zhuo-commandery');
|
|
battleUrl.searchParams.set('debugCombatAssetWatchdogMs', String(watchdogMs));
|
|
battleUrl.searchParams.set('assetStallQa', kind);
|
|
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
|
|
const entry = await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
|
assert(
|
|
entry.phase === 'deployment' && entry.combatAssets?.baseReady === true,
|
|
`Expected ${kind} stall QA to reach deployment from base sheets: ${JSON.stringify(entry)}`
|
|
);
|
|
|
|
await clickBattleDeploymentStart(page);
|
|
const playable = await waitForBattlePlayable(page, 'first-battle-zhuo-commandery');
|
|
const settled = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.combatAssets ?? null);
|
|
assert(stalledUrl, `Expected ${kind} stall QA to intercept one deferred asset request.`);
|
|
assert(
|
|
playable.combatAssets?.status === 'degraded' &&
|
|
settled?.pendingDeploymentConfirmation === false &&
|
|
settled?.settlementCount === 1,
|
|
`Expected ${kind} stall watchdog to settle once and resume pending combat: ${JSON.stringify({ playable, settled, stalledUrl })}`
|
|
);
|
|
|
|
const fallback = await page.evaluate((assetKind) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
if (!battle) {
|
|
return null;
|
|
}
|
|
if (assetKind === 'action') {
|
|
const missing = new Set(battle.combatAssets?.missingActionKeys ?? []);
|
|
const unitTextures = (battle.units ?? []).map((unit) => ({
|
|
id: unit.id,
|
|
textureBase: unit.textureBase,
|
|
actionTexture: unit.actionTexture
|
|
}));
|
|
return {
|
|
missingKeys: [...missing],
|
|
fallbackUnitIds: unitTextures
|
|
.filter((unit) => missing.has(unit.textureBase) && unit.actionTexture === unit.textureBase)
|
|
.map((unit) => unit.id),
|
|
unitTextures
|
|
};
|
|
}
|
|
return {
|
|
missingKeys: [...(battle.combatAssets?.missingPortraitKeys ?? [])],
|
|
fallbackUnitIds: (battle.units ?? [])
|
|
.filter((unit) => unit.combatPortraitKey && !unit.combatPortraitReady)
|
|
.map((unit) => unit.id)
|
|
};
|
|
}, kind);
|
|
assert(
|
|
fallback?.missingKeys?.length > 0 && fallback?.fallbackUnitIds?.length > 0,
|
|
`Expected ${kind} stall QA to expose a concrete fallback: ${JSON.stringify(fallback)}`
|
|
);
|
|
|
|
await page.waitForTimeout(stallReleaseMs + 350);
|
|
const afterLateFailure = await page.evaluate(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
phase: battle?.phase ?? null,
|
|
status: battle?.combatAssets?.status ?? null,
|
|
pending: battle?.combatAssets?.pendingDeploymentConfirmation ?? null,
|
|
settlementCount: battle?.combatAssets?.settlementCount ?? null
|
|
};
|
|
});
|
|
assert(
|
|
afterLateFailure.phase === 'idle' &&
|
|
afterLateFailure.status === 'degraded' &&
|
|
afterLateFailure.pending === false &&
|
|
afterLateFailure.settlementCount === 1,
|
|
`Expected late ${kind} completion/error callbacks to remain inert: ${JSON.stringify(afterLateFailure)}`
|
|
);
|
|
assert(pageErrors.length === 0, `Expected no page errors during ${kind} stall fallback: ${JSON.stringify(pageErrors)}`);
|
|
|
|
return {
|
|
status: afterLateFailure.status,
|
|
settlementCount: afterLateFailure.settlementCount,
|
|
missingKeyCount: fallback.missingKeys.length,
|
|
fallbackUnitIds: fallback.fallbackUnitIds,
|
|
stalledFile: decodeURIComponent(stalledUrl.split('/').pop()?.split('?')[0] ?? '')
|
|
};
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
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 actionSheetFiles(entries) {
|
|
return entries
|
|
.map((entry) => decodeURIComponent(entry.name.split('/').pop() ?? entry.name))
|
|
.filter((file) => /^unit-.*-actions(?:-[^.]+)?\.(?:png|webp)(?:[?#]|$)/i.test(file));
|
|
}
|
|
|
|
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 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],
|
|
['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],
|
|
['battleEntryTransitionEncodedKB', report.battleEntryTransition.summary.encodedKB, budgetValues.battleEntryTransitionEncodedKB],
|
|
['battleTransitionEncodedKB', report.battleTransition.summary.encodedKB, budgetValues.battleTransitionEncodedKB],
|
|
['storyBattleTransitionEncodedKB', storyBattleTransitionEncodedKB, budgetValues.storyBattleTransitionEncodedKB],
|
|
['storyActionSheetRequests', report.assetPolicy.storyActionSheetRequests.length, budgetValues.storyActionSheetRequests],
|
|
['battleEntryActionSheetRequests', report.assetPolicy.battleEntryActionSheetRequests.length, budgetValues.battleEntryActionSheetRequests],
|
|
['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));
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|