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

1322 lines
43 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 = '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);
assertBattleMenuDiscovery(state.battleHud?.header);
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?.key === 'opening' &&
battle.firstBattleTutorial?.active === false
);
},
undefined,
{ timeout: 30000 }
);
const automaticTutorialMenuGuardSetup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (
!scene ||
typeof scene.hideBattleEventBanner !== 'function'
) {
return { ready: false };
}
scene.hideBattleEventBanner();
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'm',
code: 'KeyM',
bubbles: true,
cancelable: true
})
);
window.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'm',
code: 'KeyM',
bubbles: true,
cancelable: true
})
);
const battle = window.__HEROS_DEBUG__?.battle();
return {
ready: true,
activeBattleEvent: battle?.activeBattleEvent ?? null,
mapMenuKeyboard: battle?.mapMenuKeyboard ?? null,
firstBattleTutorial: battle?.firstBattleTutorial ?? null
};
});
assert(
automaticTutorialMenuGuardSetup.ready === true &&
automaticTutorialMenuGuardSetup.activeBattleEvent === null &&
automaticTutorialMenuGuardSetup.mapMenuKeyboard?.visible === true &&
automaticTutorialMenuGuardSetup.firstBattleTutorial?.active === false,
`Expected the opening event to close and the M menu to open before the automatic tutorial retry: ${JSON.stringify(automaticTutorialMenuGuardSetup)}`
);
assertMapMenuFocus(automaticTutorialMenuGuardSetup.mapMenuKeyboard);
await page.waitForTimeout(540);
state = await readBattleState(page);
assert(
state.mapMenuKeyboard?.visible === true &&
state.activeBattleEvent === null &&
state.firstBattleTutorial?.active === false &&
state.firstBattleTutorial.step === null,
`Automatic tutorial must remain inactive across multiple retry intervals while the M menu is open: ${JSON.stringify({
mapMenuKeyboard: state.mapMenuKeyboard,
activeBattleEvent: state.activeBattleEvent,
firstBattleTutorial: state.firstBattleTutorial
})}`
);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, false);
await page.waitForFunction(
() => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.phase === 'idle' &&
battle.activeBattleEvent === null &&
battle.mapMenuKeyboard?.visible === false &&
battle.firstBattleTutorial?.active === true &&
battle.firstBattleTutorial.step === 'select-unit'
);
},
undefined,
{ timeout: 30000 }
);
state = await readBattleState(page);
assertFirstBattleTutorialStep(
state.firstBattleTutorial,
'select-unit',
1,
false
);
assert(
boundsInsideViewport(state.firstBattleTutorial.skipBounds),
`Expected the first-battle tutorial skip control inside the desktop viewport: ${JSON.stringify(state.firstBattleTutorial)}`
);
await page.keyboard.press('KeyK');
await page.waitForFunction(() => {
const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
return (
tutorial?.active === false &&
tutorial.completion === 'skipped' &&
tutorial.step === null
);
});
state = await readBattleState(page);
assert(
state.firstBattleTutorial?.completed === true &&
state.firstBattleTutorial.cardBounds === null &&
state.firstBattleTutorial.skipBounds === null,
`K must dismiss and persist the initially active first-battle tutorial: ${JSON.stringify(state.firstBattleTutorial)}`
);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, true);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard);
await page.waitForTimeout(80);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, false);
await page.waitForTimeout(80);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, true);
await page.waitForTimeout(80);
await page.keyboard.press('Escape');
await waitForMapMenuVisibility(page, false);
await page.waitForTimeout(80);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, true);
state = await readBattleState(page);
const mapMenuItems = state.mapMenuKeyboard.items;
const disabledMapMenuIndex = mapMenuItems.findIndex(
(item, index) =>
item.available === false &&
mapMenuItems[index - 1]?.available === true &&
mapMenuItems[index + 1]?.available === true
);
assert(
disabledMapMenuIndex > 0 &&
mapMenuItemId(mapMenuItems[disabledMapMenuIndex]) === 'load',
`Expected an unavailable load action between enabled map-menu actions after clearing saves: ${JSON.stringify(state.mapMenuKeyboard)}`
);
const beforeDisabledMapMenuId =
mapMenuItemId(mapMenuItems[disabledMapMenuIndex - 1]);
const afterDisabledMapMenuId =
mapMenuItemId(mapMenuItems[disabledMapMenuIndex + 1]);
await focusMapMenuItem(page, beforeDisabledMapMenuId);
await page.keyboard.press('ArrowDown');
await waitForMapMenuFocus(page, afterDisabledMapMenuId);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, afterDisabledMapMenuId);
await page.keyboard.press('ArrowUp');
await waitForMapMenuFocus(page, beforeDisabledMapMenuId);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, beforeDisabledMapMenuId);
await page.keyboard.press('Tab');
await waitForMapMenuFocus(page, afterDisabledMapMenuId);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, afterDisabledMapMenuId);
await page.keyboard.press('Shift+Tab');
await waitForMapMenuFocus(page, beforeDisabledMapMenuId);
await focusMapMenuItem(page, 'tutorial');
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, 'tutorial');
await page.screenshot({
path: `dist/verification-battle-keyboard-map-menu-${renderer}.png`,
fullPage: true
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
const tutorial = battle?.firstBattleTutorial;
return (
battle?.mapMenuKeyboard?.visible === false &&
tutorial?.active === true &&
tutorial.step === 'select-unit' &&
tutorial.stepNumber === 1 &&
tutorial.totalSteps === 4
);
});
state = await readBattleState(page);
assertFirstBattleTutorialStep(
state.firstBattleTutorial,
'select-unit',
1,
true
);
assert(
state.firstBattleTutorial.completion === null,
`On-demand tutorial replay must begin as a fresh run: ${JSON.stringify(state.firstBattleTutorial)}`
);
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' &&
battle.firstBattleTutorial.stepNumber === 2
);
},
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.firstBattleTutorial.stepNumber === 3 &&
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' &&
battle.firstBattleTutorial.stepNumber === 4
);
},
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 &&
battle.firstBattleTutorial.completion === 'completed'
);
},
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,
tutorialCompletion:
state?.firstBattleTutorial?.completion ?? null,
pointerInputCount: qa?.pointerInputCount ?? -1
};
}, tutorialPath.unitId);
assert(
completed.acted &&
completed.commandMenu?.visible === false &&
completed.tutorialActive === false &&
completed.tutorialCompletion === 'completed',
`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, ' +
'the automatic tutorial stays paused while the M menu is open and resumes after it closes; K skips it, M toggles the map menu, Esc closes it, arrows/Tab skip its disabled load action, and Enter replays the four-step tutorial; ' +
'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 assertFirstBattleTutorialStep(
tutorial,
expectedStep,
stepNumber,
expectedCompleted
) {
assert(
tutorial?.active === true &&
tutorial.completed === expectedCompleted &&
tutorial.onDemandAvailable === true &&
tutorial.step === expectedStep &&
tutorial.stepNumber === stepNumber &&
tutorial.totalSteps === 4 &&
tutorial.cardBounds &&
tutorial.highlightBounds &&
tutorial.skipBounds &&
boundsInsideViewport(tutorial.cardBounds) &&
boundsInsideViewport(tutorial.highlightBounds) &&
boundsInsideViewport(tutorial.skipBounds),
`Expected visible first-battle tutorial step ${stepNumber}/4 (${expectedStep}) inside the desktop viewport: ${JSON.stringify(tutorial)}`
);
}
function assertBattleMenuDiscovery(header) {
assert(
header?.menuLabel === '메뉴 M' &&
header.menuBounds &&
boundsInsideViewport(header.menuBounds),
`Expected a visible 메뉴 M discovery control inside the desktop HUD: ${JSON.stringify(header)}`
);
}
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 waitForMapMenuVisibility(page, expectedVisible) {
try {
await page.waitForFunction(
(visible) =>
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard?.visible === visible,
expectedVisible
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
phase: window.__HEROS_DEBUG__?.battle()?.phase,
mapMenuVisible: window.__HEROS_DEBUG__?.battle()?.mapMenuVisible,
mapMenuKeyboard:
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard ?? null,
firstBattleTutorial:
window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial ?? null,
keyboardInputs:
window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? []
}));
throw new Error(
`Timed out waiting for map-menu visibility ${expectedVisible}: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
}
async function waitForMapMenuFocus(page, expectedId) {
try {
await page.waitForFunction(
(id) => {
const focused =
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard?.focusedItem;
return (focused?.id ?? focused?.action) === id;
},
expectedId
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
mapMenuKeyboard:
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard ?? null,
keyboardInputs:
window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? []
}));
throw new Error(
`Timed out waiting for map-menu focus ${expectedId}: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
}
async function focusMapMenuItem(page, expectedId) {
for (let attempt = 0; attempt < 24; attempt += 1) {
const keyboard = await page.evaluate(
() => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard
);
assertMapMenuFocus(keyboard);
if (mapMenuItemId(keyboard.focusedItem) === expectedId) {
return;
}
const target = keyboard.items.find(
(item) => mapMenuItemId(item) === expectedId
);
assert(
target?.available === true,
`Expected ${expectedId} to be keyboard-selectable in the map menu: ${JSON.stringify(keyboard)}`
);
await page.keyboard.press('Tab');
await page.waitForTimeout(20);
}
throw new Error(`Could not focus map-menu item ${expectedId}.`);
}
function assertMapMenuFocus(keyboard, expectedId) {
const focusedId = mapMenuItemId(keyboard?.focusedItem);
const matchingItem = keyboard?.items?.find(
(item) => mapMenuItemId(item) === focusedId
);
const guidanceText = keyboard?.guidance?.text ?? '';
assert(
keyboard?.visible === true &&
keyboard.bounds &&
boundsInsideViewport(keyboard.bounds) &&
Array.isArray(keyboard.items) &&
keyboard.items.length >= 4 &&
keyboard.items.every(
(item) =>
typeof mapMenuItemId(item) === 'string' &&
typeof item.available === 'boolean' &&
item.bounds &&
boundsInside(item.bounds, keyboard.bounds) &&
boundsInsideViewport(item.bounds)
) &&
keyboard.items.some(({ available }) => available === false) &&
keyboard.focusedItem?.available === true &&
matchingItem?.focused === true &&
keyboard.items.filter(({ focused }) => focused).length === 1 &&
keyboard.focusedItem.bounds &&
boundsInsideViewport(keyboard.focusedItem.bounds) &&
(!expectedId || focusedId === expectedId) &&
guidanceText.includes('M') &&
guidanceText.includes('Tab') &&
guidanceText.includes('Enter') &&
guidanceText.includes('Esc') &&
keyboard.guidance?.bounds &&
boundsInsideViewport(keyboard.guidance.bounds),
`Expected a visible map menu with one enabled keyboard focus, desktop-safe bounds, and M/Tab/Enter/Esc guidance${expectedId ? ` on ${expectedId}` : ''}: ${JSON.stringify(keyboard)}`
);
}
function mapMenuItemId(item) {
return item?.id ?? item?.action ?? null;
}
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);
}
}