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