import { spawn } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions } from './desktop-browser-viewport.mjs'; const targetUrl = withStressParams( process.env.LOADER_LIFECYCLE_URL ?? 'http://127.0.0.1:4178/heros_web/' ); const reportPath = process.env.LOADER_LIFECYCLE_REPORT ?? 'dist/qa-loader-lifecycle.json'; const battleIds = [ 'first-battle-zhuo-commandery', 'ninth-battle-xuzhou-gate-night-raid', 'eighteenth-battle-bowang-ambush', 'twenty-sixth-battle-changsha-veteran', 'thirty-sixth-battle-dingjun-vanguard', 'thirty-seventh-battle-hanzhong-decisive', 'fortieth-battle-han-river-flood', 'forty-first-battle-fan-castle-siege', 'fifty-fifth-battle-northern-qishan-road', 'sixty-sixth-battle-wuzhang-final' ]; let serverProcess; let browser; try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); const page = await browser.newPage(desktopBrowserContextOptions); page.setDefaultTimeout(90000); const pageErrors = []; const unexpectedRequestFailures = []; let delayedActionRequests = 0; let abortedActionRequests = 0; let activeActionRequests = 0; let peakActiveActionRequests = 0; page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); page.on('request', (request) => { if (!isActionImageRequest(request)) { return; } activeActionRequests += 1; peakActiveActionRequests = Math.max(peakActiveActionRequests, activeActionRequests); }); page.on('requestfinished', (request) => { if (isActionImageRequest(request)) { activeActionRequests = Math.max(0, activeActionRequests - 1); } }); page.on('requestfailed', (request) => { if (isActionImageRequest(request)) { activeActionRequests = Math.max(0, activeActionRequests - 1); abortedActionRequests += 1; return; } unexpectedRequestFailures.push({ url: request.url(), type: request.resourceType(), failure: request.failure()?.errorText ?? 'unknown' }); }); await page.route('**/*', async (route, request) => { if (!isActionImageRequest(request)) { await route.continue(); return; } delayedActionRequests += 1; await delay(900); try { await route.continue(); } catch { // The expected watchdog cancellation can dispose the route before the delay ends. } }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction( () => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); const results = []; for (const battleId of battleIds) { await page.evaluate((requestedBattleId) => window.__HEROS_DEBUG__.goToBattle(requestedBattleId), battleId); await page.waitForFunction( (requestedBattleId) => { const state = window.__HEROS_DEBUG__?.battle(); return ( state?.battleId === requestedBattleId && ['deployment', 'idle'].includes(state.phase) && state.mapTextureReady === true && state.mapBackgroundReady === true && state.combatAssets?.baseReady === true ); }, battleId, { timeout: 30000 } ).catch(async (error) => { const readiness = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const textureKeys = window.__HEROS_GAME__?.textures.getTextureKeys?.() ?? []; return { activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], battle: window.__HEROS_DEBUG__?.battle?.() ?? null, loader: scene ? { state: scene.load.state, loading: scene.load.isLoading(), pending: scene.load.list.size, inflight: scene.load.inflight.size, processing: scene.load.queue.size } : null, textureKeys: textureKeys.filter( (key) => key.startsWith('battle-map-') || key.startsWith('unit-') ) }; }); throw new Error( `Battle ${battleId} did not become ready: ${JSON.stringify({ readiness, activeActionRequests, pageErrors, unexpectedRequestFailures })}`, { cause: error } ); }); await page.waitForFunction( (requestedBattleId) => { const state = window.__HEROS_DEBUG__?.battle(); const loader = state?.combatAssets?.loader; return ( state?.battleId === requestedBattleId && (state.combatAssets?.status === 'ready' || state.combatAssets?.status === 'degraded') && loader?.loading === false && loader?.pending === 0 && loader?.inflight === 0 && loader?.processing === 0 ); }, battleId, { timeout: 15000 } ); await page.waitForTimeout(950); const snapshot = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); const textureKeys = window.__HEROS_GAME__?.textures.getTextureKeys?.() ?? []; return { battleId: state?.battleId, phase: state?.phase, mapTextureReady: state?.mapTextureReady, mapBackgroundReady: state?.mapBackgroundReady, combatAssets: state?.combatAssets, battleMapTextureKeys: textureKeys.filter((key) => key.startsWith('battle-map-')), unitBaseTextureCount: textureKeys.filter( (key) => key.startsWith('unit-') && !key.endsWith('-actions') ).length, unitActionTextureCount: textureKeys.filter((key) => key.endsWith('-actions')).length, viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio } }; }); assert(snapshot.battleId === battleId, `Unexpected active battle: ${JSON.stringify(snapshot)}`); assert(snapshot.mapTextureReady && snapshot.mapBackgroundReady, `Map was not ready: ${JSON.stringify(snapshot)}`); assert(snapshot.combatAssets?.baseReady === true, `Base unit sheets were not ready: ${JSON.stringify(snapshot)}`); assert( snapshot.unitBaseTextureCount === snapshot.combatAssets.unitTextureKeys.length, `Unit base textures accumulated or went missing: ${JSON.stringify(snapshot)}` ); assert( snapshot.unitActionTextureCount === 0, `Timed-out action textures should be fully released: ${JSON.stringify(snapshot)}` ); assert(snapshot.combatAssets?.loader?.loading === false, `Loader remained active: ${JSON.stringify(snapshot)}`); assert(snapshot.battleMapTextureKeys.length <= 1, `Battle map textures accumulated: ${JSON.stringify(snapshot)}`); assert( snapshot.viewport.width === 1920 && snapshot.viewport.height === 1080 && snapshot.viewport.dpr === 1, `Expected an exact 1920x1080 DPR1 viewport: ${JSON.stringify(snapshot.viewport)}` ); assert(activeActionRequests === 0, `Action requests remained active after ${battleId}: ${activeActionRequests}`); assert(pageErrors.length === 0, `Runtime errors after ${battleId}: ${JSON.stringify(pageErrors)}`); results.push(snapshot); console.log( `Loader lifecycle ${battleId}: status=${snapshot.combatAssets.status} ` + `maps=${snapshot.battleMapTextureKeys.length} base=${snapshot.unitBaseTextureCount} ` + `actions=${snapshot.unitActionTextureCount}` ); } assert(delayedActionRequests > 0, 'Expected the stress route to delay action-sheet XHR requests.'); assert(abortedActionRequests > 0, 'Expected the watchdog to abort delayed action-sheet XHR requests.'); assert(unexpectedRequestFailures.length === 0, `Unexpected request failures: ${JSON.stringify(unexpectedRequestFailures)}`); const report = { viewport: { width: 1920, height: 1080, dpr: 1 }, watchdogMs: 250, delayedActionRequests, abortedActionRequests, peakActiveActionRequests, pageErrors, unexpectedRequestFailures, battles: results }; mkdirSync('dist', { recursive: true }); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); console.log( `Loader lifecycle QA passed: battles=${results.length} delayed=${delayedActionRequests} ` + `aborted=${abortedActionRequests} peak=${peakActiveActionRequests}` ); console.log(`Wrote loader lifecycle report ${reportPath}`); } finally { await browser?.close(); serverProcess?.kill(); } function withStressParams(url) { const parsed = new URL(url); parsed.searchParams.set('renderer', 'canvas'); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('debugCombatAssetWatchdogMs', '250'); return parsed.toString(); } function isActionImageRequest(request) { return ( (request.resourceType() === 'xhr' || request.resourceType() === 'image') && request.url().includes('-actions.webp') ); } function assert(condition, message) { if (!condition) { throw new Error(message); } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function ensureLocalServer(url) { if (await canReach(url)) { return undefined; } const parsed = new URL(url); const child = spawn( process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '4178', '--strictPort'], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'ignore', 'pipe'] } ); const stderr = []; child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); for (let attempt = 0; attempt < 80; attempt += 1) { if (await canReach(url)) { return child; } await delay(250); } child.kill(); throw new Error(`Vite server 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; } }