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 = 'first-battle-zhuo-commandery'; 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(); const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? []; return ( battle?.battleId === requestedBattleId && battle.phase === 'deployment' && battle.mapBackgroundReady === true && ['ready', 'degraded'].includes(battle.combatAssets?.status) && activeScenes.length === 1 && activeScenes[0] === 'BattleScene' ); }, battleId, { timeout: 90000 } ); await installPointerInputRecorder(page); let state = await readBattleState(page); assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions); assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); assertDeploymentKeyboardOrder(state.deploymentKeyboard); await page.keyboard.press('Tab'); await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id); state = await readBattleState(page); assertDeploymentKeyboardFocus( state.deploymentKeyboard, state.deploymentKeyboard.items[0].id ); await page.keyboard.press('Shift+Tab'); await waitForDeploymentFocus(page, 'start'); state = await readBattleState(page); assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); await page.keyboard.press('ArrowRight'); await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id); state = await readBattleState(page); assertDeploymentKeyboardFocus( state.deploymentKeyboard, state.deploymentKeyboard.items[0].id ); await page.keyboard.press('ArrowLeft'); await waitForDeploymentFocus(page, 'start'); state = await readBattleState(page); assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start'); const firstDeploymentItemId = state.deploymentKeyboard.items[0].id; const secondDeploymentItemId = state.deploymentKeyboard.items[1].id; await page.keyboard.down('ArrowRight'); await waitForDeploymentFocus(page, firstDeploymentItemId); await page.keyboard.down('ArrowRight'); await waitForDeploymentFocus(page, secondDeploymentItemId); await page.keyboard.up('ArrowRight'); await page.keyboard.press('ArrowLeft'); await waitForDeploymentFocus(page, firstDeploymentItemId); await page.keyboard.press('ArrowLeft'); await waitForDeploymentFocus(page, 'start'); await dispatchKeyDownWithoutKeyUp(page, 'Tab', 'Tab'); await waitForDeploymentFocus(page, firstDeploymentItemId); await page.evaluate(() => { window.dispatchEvent(new Event('blur')); }); await page.keyboard.press('Tab'); await waitForDeploymentFocus(page, secondDeploymentItemId); await page.keyboard.press('Shift+Tab'); await waitForDeploymentFocus(page, firstDeploymentItemId); await page.keyboard.press('Shift+Tab'); await waitForDeploymentFocus(page, 'start'); await page.screenshot({ path: `dist/verification-battle-keyboard-deployment-focus-${renderer}.png`, fullPage: true }); await page.keyboard.press('Enter'); await page.waitForFunction( (requestedBattleId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === requestedBattleId && battle.phase === 'idle'; }, battleId, { timeout: 30000 } ); await page.waitForFunction( () => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'idle' && battle.activeBattleEvent === null && battle.firstBattleTutorial?.active === true && battle.firstBattleTutorial.step === 'select-unit' ); }, undefined, { timeout: 30000 } ); state = await readBattleState(page); const tutorialPath = state.firstBattleTutorial?.path; assert( tutorialPath?.unitId && tutorialPath.from && tutorialPath.moveTile && tutorialPath.targetId && tutorialPath.targetTile, `Expected a complete first-battle tutorial path: ${JSON.stringify(state.firstBattleTutorial)}` ); state = await focusBattleCursorAt(page, tutorialPath.from, { expectedMode: 'unit', expectedUnitId: tutorialPath.unitId }); assertIdleUnitKeyboardFocus(state, tutorialPath.unitId, tutorialPath.from); await page.screenshot({ path: `dist/verification-battle-keyboard-idle-focus-${renderer}.png`, fullPage: true }); await page.keyboard.press('Enter'); await page.waitForFunction( (expectedUnitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'moving' && battle.selectedUnitId === expectedUnitId && battle.firstBattleTutorial?.step === 'move' ); }, tutorialPath.unitId ); state = await focusBattleCursorAt(page, tutorialPath.moveTile, { expectedMode: 'move' }); assertBattleCursor(state.keyboardBattleCursor, tutorialPath.moveTile, 'move'); await page.keyboard.press('Enter'); await page.waitForFunction( (expectedUnitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'command' && battle.selectedUnitId === expectedUnitId && battle.pendingMove?.unitId === expectedUnitId && battle.firstBattleTutorial?.step === 'attack-command' && battle.commandMenuKeyboard?.visible === true ); }, tutorialPath.unitId, { timeout: 30000 } ); await focusMenuItem(page, 'attack'); state = await readBattleState(page); assertCommandMenuFocus(state.commandMenuKeyboard, 'command'); assert( state.commandMenuKeyboard.focusedItem.id === 'attack', `Expected attack to receive keyboard focus: ${JSON.stringify(state.commandMenuKeyboard)}` ); await page.keyboard.press('Enter'); await page.waitForFunction( (expectedUnitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'targeting' && battle.selectedUnitId === expectedUnitId && battle.firstBattleTutorial?.step === 'target-preview' ); }, tutorialPath.unitId ); state = await focusBattleCursorAt(page, tutorialPath.targetTile, { expectedMode: 'damage', expectedUnitId: tutorialPath.targetId }); assertBattleCursor( state.keyboardBattleCursor, tutorialPath.targetTile, 'damage', tutorialPath.targetId ); await page.keyboard.press('Enter'); await page.waitForFunction( (expectedTargetId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.firstBattleTutorial?.targetPreviewLocked === true && battle.combatExchangePreview?.defenderId === expectedTargetId ); }, tutorialPath.targetId ); state = await readBattleState(page); assert( state.combatExchangePreview?.attackerId === tutorialPath.unitId && state.combatExchangePreview.defenderId === tutorialPath.targetId && Number.isFinite(state.combatExchangePreview.attackDamage) && Number.isFinite(state.combatExchangePreview.attackHitRate), `Expected a keyboard-selected attack forecast before confirmation: ${JSON.stringify(state.combatExchangePreview)}` ); await page.screenshot({ path: `dist/verification-battle-keyboard-attack-preview-${renderer}.png`, fullPage: true }); await page.keyboard.press('Enter'); await page.waitForFunction( (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.actedUnitIds?.includes(unitId) && battle.commandMenuKeyboard?.visible === false && battle.firstBattleTutorial?.active === false ); }, tutorialPath.unitId, { timeout: 30000 } ); const completed = await page.evaluate((unitId) => { const state = window.__HEROS_DEBUG__?.battle(); const qa = window.__HEROS_KEYBOARD_COMMAND_QA__; return { selectedUnitId: state?.selectedUnitId ?? null, pendingMove: state?.pendingMove ?? null, acted: state?.actedUnitIds?.includes(unitId) ?? false, commandMenu: state?.commandMenuKeyboard ?? null, phase: state?.phase ?? null, tutorialActive: state?.firstBattleTutorial?.active ?? null, pointerInputCount: qa?.pointerInputCount ?? -1 }; }, tutorialPath.unitId); assert( completed.acted && completed.commandMenu?.visible === false && completed.tutorialActive === false, `Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}` ); assert( completed.pointerInputCount === 0, `Deployment-to-attack keyboard flow emitted mouse/pointer input: ${JSON.stringify(completed)}` ); state = await waitForAutoFocusedUnitWithKeyboard(page); const handoffUnit = state.units?.find( (unit) => unit.id === state.selectedUnitId ); assert( handoffUnit?.id === 'zhang-fei' && handoffUnit.faction === 'ally' && handoffUnit.hp > 0 && handoffUnit.acted === false, `Expected the first completed action to hand keyboard focus to Zhang Fei: ${JSON.stringify({ selectedUnitId: state.selectedUnitId, units: state.units })}` ); await focusBattleCursorAt( page, { x: handoffUnit.x, y: handoffUnit.y }, { expectedMode: 'move' } ); await page.keyboard.press('Enter'); await page.waitForFunction( (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.selectedUnitId === unitId && battle.pendingMove?.unitId === unitId && battle.commandMenuKeyboard?.visible === true ); }, handoffUnit.id ); await focusMenuItem(page, 'wait'); await page.keyboard.press('Enter'); await page.waitForFunction( (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.actedUnitIds?.includes(unitId) && battle.commandMenuKeyboard?.visible === false ); }, handoffUnit.id ); state = await waitForAutoFocusedUnitWithKeyboard(page); const menuUnit = state.units?.find( (unit) => unit.id === state.selectedUnitId ); assert( menuUnit?.id === 'liu-bei' && menuUnit.faction === 'ally' && menuUnit.hp > 0 && menuUnit.acted === false, `Expected Zhang Fei's wait to hand keyboard focus to Liu Bei: ${JSON.stringify({ selectedUnitId: state.selectedUnitId, units: state.units })}` ); await focusBattleCursorAt( page, { x: menuUnit.x, y: menuUnit.y }, { expectedMode: 'move' } ); await page.keyboard.press('Enter'); await page.waitForFunction( (unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.selectedUnitId === unitId && battle.pendingMove?.unitId === unitId && battle.commandMenuKeyboard?.visible === true && battle.commandMenuKeyboard.mode === 'command' ); }, menuUnit.id ); state = await readBattleState(page); assertCommandMenuFocus(state.commandMenuKeyboard, 'command'); const commandItems = state.commandMenuKeyboard.items; const attackCommand = commandItems.find(({ id }) => id === 'attack'); const strategyCommand = commandItems.find(({ id }) => id === 'strategy'); assert( attackCommand?.available === false && strategyCommand?.available === true && state.commandMenuKeyboard.focusedItem.id === 'strategy', `Default command focus must skip unavailable attack and land on strategy: ${JSON.stringify(state.commandMenuKeyboard)}` ); const availableCommandIds = commandItems .filter(({ available }) => available) .map(({ id }) => id); const strategyIndex = availableCommandIds.indexOf('strategy'); const previousCommandId = availableCommandIds[ (strategyIndex - 1 + availableCommandIds.length) % availableCommandIds.length ]; await page.keyboard.press('Shift+Tab'); state = await readBattleState(page); 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 === 'strategy', `Tab must return to strategy without visiting disabled attack: ${JSON.stringify(state.commandMenuKeyboard)}` ); 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.commandMenuKeyboard.items.some(({ available }) => !available) && state.commandMenuKeyboard.focusedItem.available === true, `Usable focus must skip unavailable choices: ${JSON.stringify(state.commandMenuKeyboard)}` ); await page.keyboard.press('Shift+Tab'); state = await readBattleState(page); assert( state.commandMenuKeyboard?.focusedItem?.available === true, `Usable Shift+Tab must keep focus on an available choice: ${JSON.stringify(state.commandMenuKeyboard)}` ); 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); assert( state.selectedUnitId === menuUnit.id && state.pendingMove?.unitId === menuUnit.id, `Esc must return one menu level without clearing the unit 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?.actedUnitIds?.includes(unitId) && battle.commandMenuKeyboard?.visible === false ); }, menuUnit.id ); const finalInputState = await page.evaluate(() => { const qa = window.__HEROS_KEYBOARD_COMMAND_QA__; if (qa?.recordPointerInput) { window.removeEventListener( 'mousedown', qa.recordPointerInput, true ); window.removeEventListener( 'pointerdown', qa.recordPointerInput, true ); } if (qa?.recordKeyboardInput) { window.removeEventListener( 'keydown', qa.recordKeyboardInput, true ); } return { pointerInputCount: qa?.pointerInputCount ?? -1, keyboardInputCount: qa?.keyboardInputs?.length ?? 0 }; }); assert( finalInputState.pointerInputCount === 0 && finalInputState.keyboardInputCount > 0, `Full keyboard regression flow must complete with zero pointer input: ${JSON.stringify(finalInputState)}` ); 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 first-battle flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` + 'deployment focus cycles with Tab/Shift+Tab and held arrows, focus-loss recovery works without keyup, Enter starts the recommended deployment, ' + 'idle ally selection, movement, attack forecast, and confirmation complete one action; disabled command and usable choices are skipped, ' + 'submenu Escape preserves the pending move, and a second action completes with zero pointer 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 dispatchKeyDownWithoutKeyUp(page, key, code) { await page.evaluate( ({ requestedKey, requestedCode }) => { window.dispatchEvent( new KeyboardEvent('keydown', { key: requestedKey, code: requestedCode, bubbles: true, cancelable: true }) ); }, { requestedKey: key, requestedCode: code } ); } async function installPointerInputRecorder(page) { await page.evaluate(() => { const recordPointerInput = () => { window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1; }; const recordKeyboardInput = (event) => { window.__HEROS_KEYBOARD_COMMAND_QA__.keyboardInputs.push({ code: event.code, key: event.key, shiftKey: event.shiftKey, repeat: event.repeat }); }; window.__HEROS_KEYBOARD_COMMAND_QA__ = { pointerInputCount: 0, recordPointerInput, recordKeyboardInput, keyboardInputs: [] }; window.addEventListener('mousedown', recordPointerInput, true); window.addEventListener('pointerdown', recordPointerInput, true); window.addEventListener('keydown', recordKeyboardInput, true); }); } function assertDeploymentKeyboardOrder(keyboard) { const ids = keyboard?.items?.map(({ id }) => id) ?? []; const firstSlotIndex = ids.findIndex((id) => id.startsWith('slot:')); const lastUnitIndex = ids.findLastIndex((id) => id.startsWith('unit:')); assert( ids.length >= 4 && ids.at(-2) === 'restore' && ids.at(-1) === 'start' && lastUnitIndex >= 0 && firstSlotIndex > lastUnitIndex && ids.slice(0, firstSlotIndex).every((id) => id.startsWith('unit:')) && ids .slice(firstSlotIndex, -2) .every((id) => id.startsWith('slot:')), `Expected deployment order roles -> slots -> restore -> start: ${JSON.stringify(keyboard)}` ); } function assertFirstBattleObjectiveDefinitions(objectives) { assert( Array.isArray(objectives) && objectives.length === 4, `Expected exactly four first-battle objective definitions: ${JSON.stringify(objectives)}` ); const byId = Object.fromEntries( objectives.map((objective) => [objective.id, objective]) ); assert( byId.leader?.kind === 'defeat-leader' && byId.leader.rewardGold === 200, `Expected the commander objective to reward 200 gold: ${JSON.stringify(byId.leader)}` ); assert( byId['liu-bei']?.kind === 'keep-unit-alive' && byId['liu-bei'].rewardGold === 100, `Expected the Liu Bei survival objective to reward 100 gold: ${JSON.stringify(byId['liu-bei'])}` ); assert( byId.village?.kind === 'secure-terrain' && byId.village.terrain === 'village' && byId.village.targetTile?.radius === 4 && byId.village.rewardGold === 210, `Expected the village objective to expose radius 4 and reward 210 gold: ${JSON.stringify(byId.village)}` ); assert( byId.quick?.kind === 'quick-victory' && byId.quick.maxTurn === 15 && byId.quick.rewardGold === 120, `Expected the quick-victory objective to expose 15 turns and reward 120 gold: ${JSON.stringify(byId.quick)}` ); } function assertDeploymentKeyboardFocus(keyboard, expectedId) { const focusedItem = keyboard?.focusedItem; const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId); const guidanceText = keyboard?.guidance?.text ?? ''; assert( keyboard?.visible === true && focusedItem?.id === expectedId && focusedItem.available !== false && matchingItem?.focused === true && keyboard.items.filter(({ focused }) => focused).length === 1 && focusedItem.bounds && boundsInsideViewport(focusedItem.bounds) && (focusedItem.lineWidth ?? matchingItem.lineWidth ?? 0) >= 3 && guidanceText.includes('Tab') && guidanceText.includes('Enter') && guidanceText.includes('Esc') && boundsInsideViewport(keyboard.guidance?.bounds), `Expected visible deployment keyboard focus and guidance on ${expectedId}: ${JSON.stringify(keyboard)}` ); } async function waitForDeploymentFocus(page, expectedId) { try { await page.waitForFunction( (id) => window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard?.focusedItem ?.id === id, expectedId ); } catch (error) { const diagnostic = await page.evaluate(() => ({ deploymentKeyboard: window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard, keyboardInputs: window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? [] })); throw new Error( `Timed out waiting for deployment keyboard focus ${expectedId}: ${JSON.stringify(diagnostic)}`, { cause: error } ); } } async function focusBattleCursorAt( page, expectedTile, { expectedMode, expectedUnitId } = {} ) { for (let attempt = 0; attempt < 96; attempt += 1) { const state = await readBattleState(page); const cursor = state?.keyboardBattleCursor; const cursorUnitId = cursor?.unitId ?? cursor?.targetId ?? state?.units?.find( (unit) => unit.hp > 0 && unit.x === cursor?.x && unit.y === cursor?.y )?.id; if ( cursor?.x === expectedTile.x && cursor?.y === expectedTile.y && (!expectedMode || cursor.mode === expectedMode) && (!expectedUnitId || cursorUnitId === expectedUnitId) ) { return state; } await page.keyboard.press('Tab'); await page.waitForTimeout(20); } throw new Error( `Could not focus ${expectedMode ?? 'battle'} cursor at ${expectedTile.x},${expectedTile.y} for ${expectedUnitId ?? 'any unit'}.` ); } function assertIdleUnitKeyboardFocus(state, expectedUnitId, expectedTile) { const focusedUnit = state?.units?.find( (unit) => unit.hp > 0 && unit.x === state.keyboardBattleCursor?.x && unit.y === state.keyboardBattleCursor?.y ); assert( state?.phase === 'idle' && state.selectedUnitId === null && state.keyboardBattleCursor?.mode === 'unit' && focusedUnit?.id === expectedUnitId && focusedUnit.faction === 'ally' && focusedUnit.acted === false && state.keyboardBattleCursor.x === expectedTile.x && state.keyboardBattleCursor.y === expectedTile.y, `Expected one keyboard-focused actionable ally before selection: ${JSON.stringify({ cursor: state?.keyboardBattleCursor, focusedUnit, selectedUnitId: state?.selectedUnitId, phase: state?.phase })}` ); } function assertBattleCursor(cursor, expectedTile, expectedMode, expectedTargetId) { assert( cursor?.mode === expectedMode && cursor.x === expectedTile.x && cursor.y === expectedTile.y && (!expectedTargetId || cursor.targetId === expectedTargetId || cursor.unitId === expectedTargetId), `Expected ${expectedMode} keyboard cursor at ${expectedTile.x},${expectedTile.y}: ${JSON.stringify(cursor)}` ); } async function waitForAutoFocusedUnitWithKeyboard(page) { for (let attempt = 0; attempt < 300; attempt += 1) { const state = await readBattleState(page); const selected = state?.units?.find( (unit) => unit.id === state.selectedUnitId ); if ( state?.phase === 'moving' && state.activeFaction === 'ally' && !state.activeBattleEvent && selected?.faction === 'ally' && selected.hp > 0 && selected.acted === false ) { return state; } if (state?.activeBattleEvent) { await page.keyboard.press('Enter'); } await page.waitForTimeout(100); } const state = await readBattleState(page); throw new Error( `Could not reach the next auto-focused ally after a keyboard action: ${JSON.stringify({ phase: state?.phase, activeFaction: state?.activeFaction, activeBattleEvent: state?.activeBattleEvent, combatCutIn: state?.combatCutIn, selectedUnitId: state?.selectedUnitId, keyboardBattleCursor: state?.keyboardBattleCursor, actedUnitIds: state?.actedUnitIds })}` ); } 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); } }