feat: complete campaign flow and optimize battle assets
This commit is contained in:
281
scripts/verify-loader-lifecycle.mjs
Normal file
281
scripts/verify-loader-lifecycle.mjs
Normal file
@@ -0,0 +1,281 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user