Files
heros_web/scripts/verify-battle-keyboard-command-flow-browser.mjs

552 lines
17 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const battleId = 'second-battle-yellow-turban-pursuit';
const renderer = process.env.VERIFY_BATTLE_KEYBOARD_RENDERER;
if (!renderer) {
for (const requestedRenderer of ['canvas', 'webgl']) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_BATTLE_KEYBOARD_RENDERER: requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
renderer === 'canvas' || renderer === 'webgl',
`Unsupported renderer: ${renderer}`
);
const baseUrl =
process.env.VERIFY_BATTLE_KEYBOARD_URL ??
'http://127.0.0.1:41796/heros_web/';
const targetUrl = withDebugOptions(baseUrl, renderer);
let serverProcess;
let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless: process.env.VERIFY_BATTLE_KEYBOARD_HEADLESS !== '0'
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await page.evaluate(() => window.localStorage.clear());
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertDesktopViewport(page);
await assertRendererAndBasePath(page, renderer);
await page.evaluate((requestedBattleId) => {
window.__HEROS_DEBUG__?.goToBattle(requestedBattleId);
}, battleId);
await page.waitForFunction(
(requestedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === requestedBattleId &&
['deployment', 'idle'].includes(battle.phase) &&
battle.mapBackgroundReady === true &&
(battle.phase === 'idle' ||
['ready', 'degraded'].includes(battle.combatAssets?.status))
);
},
battleId,
{ timeout: 90000 }
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (scene?.phase === 'deployment') {
scene.confirmPreBattleDeployment();
}
});
await page.waitForFunction(
(requestedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === requestedBattleId && battle.phase === 'idle';
},
battleId,
{ timeout: 30000 }
);
const fixture = await page.evaluate(async () => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const unit = units.find(
(candidate) =>
candidate.id === 'liu-bei' &&
candidate.faction === 'ally' &&
candidate.hp > 0
);
if (!scene || !unit) {
return null;
}
scene.hideBattleEventBanner();
scene.hideTurnEndPrompt();
scene.hideSaveSlotPanel();
scene.hideMapMenu();
scene.hideCommandMenu();
scene.clearMarkers();
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.actedUnitIds.clear();
units
.filter((candidate) => candidate.faction === 'ally' && candidate.hp > 0)
.forEach((candidate) => {
candidate.hp = candidate.maxHp;
});
scene.centerCameraOnTile(unit.x, unit.y);
scene.selectUnit(unit);
await scene.moveSelectedUnit(unit.x, unit.y);
const recordPointerInput = () => {
window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1;
};
window.__HEROS_KEYBOARD_COMMAND_QA__ = {
pointerInputCount: 0,
recordPointerInput
};
window.addEventListener('mousedown', recordPointerInput, true);
window.addEventListener('pointerdown', recordPointerInput, true);
const state = window.__HEROS_DEBUG__?.battle();
return {
unitId: unit.id,
unitTile: { x: unit.x, y: unit.y },
pendingMove: state?.pendingMove ?? null,
commandMenu: state?.commandMenuKeyboard ?? null
};
});
assert(fixture, 'Expected the keyboard command fixture to select Liu Bei.');
assert(
fixture.pendingMove?.unitId === fixture.unitId,
`Expected a preserved pending stay-in-place move: ${JSON.stringify(fixture)}`
);
assertCommandMenuFocus(fixture.commandMenu, 'command');
assert(
fixture.commandMenu.items.some(
({ id, available }) => id === 'attack' && available === false
),
`Expected the distant attack command to be unavailable for skip QA: ${JSON.stringify(fixture.commandMenu)}`
);
assert(
fixture.commandMenu.items.some(
({ id, available }) => id === 'strategy' && available
),
`Expected Liu Bei to have an available strategy: ${JSON.stringify(fixture.commandMenu)}`
);
assert(
fixture.commandMenu.focusedItem.id === 'strategy',
`Default focus must skip unavailable attack and land on strategy: ${JSON.stringify(fixture.commandMenu)}`
);
await page.screenshot({
path: `dist/verification-battle-keyboard-command-focus-${renderer}.png`,
fullPage: true
});
const initialCommandFocus = fixture.commandMenu.focusedItem.id;
const availableCommandIds = fixture.commandMenu.items
.filter(({ available }) => available)
.map(({ id }) => id);
const previousCommandId =
availableCommandIds[
(availableCommandIds.indexOf(initialCommandFocus) -
1 +
availableCommandIds.length) %
availableCommandIds.length
];
await page.keyboard.press('Shift+Tab');
let state = await readBattleState(page);
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
assert(
state.commandMenuKeyboard.focusedItem.id === previousCommandId,
`Shift+Tab must wrap to the previous available command: ${JSON.stringify(state.commandMenuKeyboard)}`
);
await page.keyboard.press('Tab');
state = await readBattleState(page);
assert(
state.commandMenuKeyboard.focusedItem.id === initialCommandFocus,
`Tab must wrap back to the initial command without visiting a disabled row: ${JSON.stringify(state.commandMenuKeyboard)}`
);
const visitedCommands = [];
for (let index = 0; index < availableCommandIds.length; index += 1) {
await page.keyboard.press('Tab');
state = await readBattleState(page);
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
visitedCommands.push(state.commandMenuKeyboard.focusedItem.id);
}
assert(
visitedCommands.at(-1) === initialCommandFocus &&
visitedCommands.every((id) => availableCommandIds.includes(id)),
`Command focus must wrap and skip every unavailable row: ${JSON.stringify({
availableCommandIds,
visitedCommands
})}`
);
await focusMenuItem(page, 'strategy');
const beforeUsable = await readBattleState(page);
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard;
return keyboard?.visible === true && keyboard.mode === 'usable';
});
state = await readBattleState(page);
assertCommandMenuFocus(state.commandMenuKeyboard, 'usable');
assert(
state.selectedUnitId === fixture.unitId &&
state.pendingMove?.unitId === fixture.unitId &&
state.commandMenuKeyboard.parentCommand === 'strategy',
`Entering the usable menu must preserve unit and pending move: ${JSON.stringify(state)}`
);
assert(
state.commandMenuKeyboard.items.some(({ available }) => !available) &&
state.commandMenuKeyboard.focusedItem.available === true,
`Usable default focus must skip an unavailable full-HP heal: ${JSON.stringify(state.commandMenuKeyboard)}`
);
assert(
!state.commandMenuKeyboard.items.find(
({ id }) => id === state.commandMenuKeyboard.focusedItem.id
)?.available === false,
`Usable focus landed on an unavailable item: ${JSON.stringify(state.commandMenuKeyboard)}`
);
await page.keyboard.press('Shift+Tab');
state = await readBattleState(page);
assertCommandMenuFocus(state.commandMenuKeyboard, 'usable');
assert(
state.commandMenuKeyboard.focusedItem.available === true,
`Usable Shift+Tab must skip unavailable rows: ${JSON.stringify(state.commandMenuKeyboard)}`
);
await page.screenshot({
path: `dist/verification-battle-keyboard-usable-focus-${renderer}.png`,
fullPage: true
});
await page.keyboard.press('Escape');
await page.waitForFunction(() => {
const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard;
return (
keyboard?.visible === true &&
keyboard.mode === 'command' &&
keyboard.focusedItem?.id === 'strategy'
);
});
state = await readBattleState(page);
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
assert(
state.selectedUnitId === fixture.unitId &&
state.pendingMove?.unitId === fixture.unitId &&
state.pendingMove.toX === beforeUsable.pendingMove.toX &&
state.pendingMove.toY === beforeUsable.pendingMove.toY,
`Esc must return one level without clearing selection or pending move: ${JSON.stringify(state)}`
);
await focusMenuItem(page, 'wait');
await page.keyboard.press('Enter');
await page.waitForFunction(
(unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.commandMenuKeyboard?.visible === false &&
battle?.actedUnitIds?.includes(unitId)
);
},
fixture.unitId
);
const completed = await page.evaluate((unitId) => {
const state = window.__HEROS_DEBUG__?.battle();
const qa = window.__HEROS_KEYBOARD_COMMAND_QA__;
if (qa?.recordPointerInput) {
window.removeEventListener('mousedown', qa.recordPointerInput, true);
window.removeEventListener('pointerdown', qa.recordPointerInput, true);
}
return {
selectedUnitId: state?.selectedUnitId ?? null,
pendingMove: state?.pendingMove ?? null,
acted: state?.actedUnitIds?.includes(unitId) ?? false,
commandMenu: state?.commandMenuKeyboard ?? null,
pointerInputCount: qa?.pointerInputCount ?? -1
};
}, fixture.unitId);
assert(
completed.acted &&
completed.pendingMove === null &&
completed.commandMenu?.visible === false,
`Keyboard Enter on wait must complete exactly one unit action: ${JSON.stringify(completed)}`
);
assert(
completed.pointerInputCount === 0,
`The command action cycle emitted mouse/pointer input after setup: ${JSON.stringify(completed)}`
);
assert(
pageErrors.length === 0,
`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`
);
assert(
consoleErrors.length === 0,
`Unexpected console errors: ${JSON.stringify(consoleErrors.slice(-5))}`
);
console.log(
`Verified ${renderer} keyboard-only battle command flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` +
'default focus skips disabled commands, Tab and Shift+Tab wrap available rows, Enter opens strategy, ' +
'usable focus skips unavailable choices, Esc preserves the selected unit and pending move, and Enter completes wait with zero mouse input.'
);
} finally {
await browser?.close();
await stopServerProcess(serverProcess);
}
function withDebugOptions(url, requestedRenderer) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', requestedRenderer);
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
return parsed.toString();
}
async function focusMenuItem(page, expectedId) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const keyboard = await page.evaluate(
() => window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard
);
assertCommandMenuFocus(keyboard, keyboard?.mode);
if (keyboard.focusedItem.id === expectedId) {
return;
}
const target = keyboard.items.find(({ id }) => id === expectedId);
assert(
target?.available,
`Expected ${expectedId} to be keyboard-selectable: ${JSON.stringify(keyboard)}`
);
await page.keyboard.press('Tab');
}
throw new Error(`Could not focus command menu item ${expectedId}.`);
}
function assertCommandMenuFocus(keyboard, expectedMode) {
assert(
keyboard?.visible === true &&
keyboard.mode === expectedMode &&
keyboard.focusedItem?.available === true &&
keyboard.focusedItem.bounds &&
keyboard.focusedItem.lineWidth >= 3 &&
keyboard.focusMarker?.visible === true &&
keyboard.focusMarker.text === '▶' &&
keyboard.focusMarker.bounds &&
boundsInside(keyboard.focusMarker.bounds, keyboard.focusedItem.bounds) &&
keyboard.guidance?.text.includes('↑↓/Tab') &&
keyboard.guidance.text.includes('Enter') &&
keyboard.guidance.text.includes('Esc') &&
boundsInsideViewport(keyboard.guidance.bounds),
`Expected one visible non-color keyboard focus and on-screen controls guidance: ${JSON.stringify(keyboard)}`
);
assert(
keyboard.items.filter(({ focused }) => focused).length === 1,
`Expected exactly one focused menu item: ${JSON.stringify(keyboard)}`
);
}
async function readBattleState(page) {
return page.evaluate(() => window.__HEROS_DEBUG__?.battle());
}
function boundsInside(inner, outer) {
return (
inner.x >= outer.x - 1 &&
inner.y >= outer.y - 1 &&
inner.x + inner.width <= outer.x + outer.width + 1 &&
inner.y + inner.height <= outer.y + outer.height + 1
);
}
function boundsInsideViewport(bounds) {
return (
bounds &&
bounds.x >= 0 &&
bounds.y >= 0 &&
bounds.x + bounds.width <= desktopBrowserViewport.width &&
bounds.y + bounds.height <= desktopBrowserViewport.height
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
Boolean(
window.__HEROS_DEBUG__?.activeScenes &&
window.__HEROS_DEBUG__?.goToBattle
),
undefined,
{ timeout: 90000 }
);
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
return {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
zoom: window.visualViewport?.scale ?? 1,
canvas: canvas
? { width: canvas.width, height: canvas.height }
: null
};
});
assert(
viewport.width === desktopBrowserViewport.width &&
viewport.height === desktopBrowserViewport.height &&
viewport.dpr === 1 &&
viewport.zoom === 1 &&
viewport.canvas?.width === desktopBrowserViewport.width &&
viewport.canvas?.height === desktopBrowserViewport.height,
`Expected explicit 1920x1080 CSS viewport, 100% zoom, and DPR1: ${JSON.stringify(viewport)}`
);
}
async function assertRendererAndBasePath(page, requestedRenderer) {
const runtime = await page.evaluate(() => ({
pathname: window.location.pathname,
rendererType: window.__HEROS_GAME__?.renderer?.type ?? null,
rendererName:
window.__HEROS_GAME__?.renderer?.constructor?.name ?? null
}));
const expectedRendererType = requestedRenderer === 'canvas' ? 1 : 2;
assert(
runtime.pathname === '/heros_web/' &&
runtime.rendererType === expectedRendererType,
`Expected ${requestedRenderer} under /heros_web/: ${JSON.stringify(runtime)}`
);
}
async function ensureLocalServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(
process.execPath,
[
'node_modules/vite/bin/vite.js',
'--host',
'127.0.0.1',
'--port',
parsed.port || '41796',
'--strictPort'
],
{
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 attempt = 0; attempt < 120; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
await stopServerProcess(child);
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;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function stopServerProcess(child) {
if (!child || child.exitCode !== null) {
return;
}
const exited = new Promise((resolve) => {
child.once('exit', resolve);
});
child.kill();
await Promise.race([exited, delay(5000)]);
if (child.exitCode === null) {
child.kill('SIGKILL');
await Promise.race([exited, delay(2000)]);
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}