282 lines
9.9 KiB
JavaScript
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;
|
|
}
|
|
}
|