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); } }