Files
heros_web/scripts/verify-loader-lifecycle.mjs

282 lines
9.9 KiB
JavaScript

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