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 targetUrl = withDebugParam(process.env.MEASURE_URL ?? 'http://127.0.0.1:4173/'); const outputPath = process.env.MEASURE_OUT ?? 'dist/performance-report.json'; const baselineViewport = desktopBrowserViewport; 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(desktopBrowserContextOptions); 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(), viewport: { ...baselineViewport }, deviceScaleFactor: desktopBrowserContextOptions.deviceScaleFactor, 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)); }