import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const rendererNames = ['canvas', 'webgl']; const renderer = process.env.VERIFY_PROLOGUE_CHECKPOINT_RENDERER; const selectedOrderId = 'mobile'; if (!renderer) { for (const requestedRenderer of rendererNames) { const result = spawnSync( process.execPath, [fileURLToPath(import.meta.url)], { cwd: process.cwd(), env: { ...process.env, VERIFY_PROLOGUE_CHECKPOINT_RENDERER: requestedRenderer }, stdio: 'inherit' } ); if (result.status !== 0) { process.exit(result.status ?? 1); } } process.exit(0); } assert( rendererNames.includes(renderer), `Unsupported prologue checkpoint renderer "${renderer}".` ); const baseUrl = process.env.VERIFY_PROLOGUE_CHECKPOINT_URL ?? 'http://127.0.0.1:41811/heros_web/'; const targetUrl = withDebugOptions(baseUrl, renderer); let serverProcess; let browser; try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_PROLOGUE_CHECKPOINT_HEADLESS !== '0', args: renderer === 'webgl' ? [ '--use-angle=swiftshader', '--enable-unsafe-swiftshader' ] : [] }); 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 waitForDebugApi(page); await assertDesktopRuntime(page, renderer); const villageSeed = await seedVillage(page); assert.equal(villageSeed.step, 'prologue-town'); assert.equal(villageSeed.checkpoint, null); assert.equal(villageSeed.entered, true); await page.evaluate( async () => window.__HEROS_DEBUG__?.goToVillage() ); await waitForVillageReady(page); await assertDesktopRuntime(page, renderer); const villagePlayer = await createMovementCheckpoint( page, 'village' ); const villagePositionResume = await reloadAndContinueToVillage(page); assertExactPlayer( villagePositionResume.player, villagePlayer, `${renderer}: village position resume` ); assertCheckpoint( villagePositionResume.explorationCheckpoint, 'prologue-village', 'prologue-village', villagePlayer, `${renderer}: village position checkpoint` ); const villageDialogueBeforeReload = await openVillageDialogueLine(page); const villageDialogueResume = await reloadAndContinueToVillage(page); assertExactPlayer( villageDialogueResume.player, villageDialogueBeforeReload.player, `${renderer}: village dialogue position resume` ); assertExplorationDialogue( villageDialogueResume, { key: 'prologue-village-interaction', source: 'zhang-fei', lineIndex: villageDialogueBeforeReload.dialogue.lineIndex }, `${renderer}: village dialogue line resume` ); const completedVillage = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('PrologueVillageScene') ?.debugCompleteVillage?.() ?? false ); assert.equal( completedVillage, true, `${renderer}: could not complete the village transition.` ); await waitForStoryReady(page, 'PrologueMilitiaCampScene'); const villageExitState = await readCampaignCheckpoint(page); assert.equal(villageExitState.step, 'prologue-town'); assert.equal(villageExitState.checkpoint, null); assert.equal(villageExitState.villageComplete, true); await page.keyboard.press('Space'); const campAfterVillage = await waitForMilitiaCampReady(page); assertCheckpoint( campAfterVillage.explorationCheckpoint, 'prologue-militia-camp', 'prologue-militia-camp', campAfterVillage.player, `${renderer}: village transition changed checkpoint context` ); assert.equal( campAfterVillage.dialogue.dialogueKey, 'prologue-camp-interaction' ); assert.equal( campAfterVillage.dialogue.sourceNpcId, 'camp-intro' ); await finishMilitiaDialogue(page); const campPlayer = await createMovementCheckpoint( page, 'militia-camp' ); const campPositionResume = await reloadAndContinueToMilitiaCamp(page); assertExactPlayer( campPositionResume.player, campPlayer, `${renderer}: militia camp position resume` ); assertCheckpoint( campPositionResume.explorationCheckpoint, 'prologue-militia-camp', 'prologue-militia-camp', campPlayer, `${renderer}: militia camp position checkpoint` ); const campDialogueBeforeReload = await openMilitiaDialogueLine(page); const campDialogueResume = await reloadAndContinueToMilitiaCamp(page); assertExactPlayer( campDialogueResume.player, campDialogueBeforeReload.player, `${renderer}: militia dialogue position resume` ); assertExplorationDialogue( campDialogueResume, { key: 'prologue-camp-interaction', source: 'guan-yu', lineIndex: campDialogueBeforeReload.dialogue.lineIndex }, `${renderer}: militia dialogue line resume` ); await finishMilitiaDialogue(page); const completedObjectives = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('PrologueMilitiaCampScene') ?.debugCompleteRequiredObjectives?.() ?? [] ); assert.deepEqual( [...completedObjectives].sort(), [ 'inspect-arms', 'ready-vanguard', 'review-scout-report' ].sort() ); try { await page.waitForFunction( () => { const camp = window.__HEROS_DEBUG__?.militiaCamp?.(); return ( camp?.ready === true && camp?.navigationPending === false && camp?.movement?.npcMovementPending === false && camp?.dialogue?.active === false && camp?.commandChoice?.open === false ); }, undefined, { timeout: 15000 } ); } catch (error) { throw new Error( `${renderer}: militia camp did not settle before command interaction: ` + JSON.stringify(await readMilitiaCamp(page)), { cause: error } ); } const commanderInteracted = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('PrologueMilitiaCampScene') ?.debugInteractWith?.('zou-jing') ?? false ); assert.equal( commanderInteracted, true, `${renderer}: could not start the departure command dialogue.` ); await waitForMilitiaDialogue(page, { key: 'prologue-camp-departure', source: 'zou-jing', lineIndex: 0 }); await page.waitForTimeout(320); await page.keyboard.press('e'); await waitForCommandChoice(page); const choiceOpened = await readMilitiaCamp(page); const mobileCard = choiceOpened.commandChoice.cards.find( ({ orderId }) => orderId === selectedOrderId ); assert( mobileCard?.bounds, `${renderer}: mobile command card is missing.` ); await clickBounds(page, mobileCard.bounds); await page.waitForFunction( (orderId) => { const camp = window.__HEROS_DEBUG__?.militiaCamp?.(); return ( camp?.commandChoice?.open === true && camp.commandChoice.pendingId === orderId && camp.commandChoice.confirmEnabled === true && camp.explorationCheckpoint?.overlay?.type === 'choice' && camp.explorationCheckpoint.overlay.mode === 'prologue-command' && camp.explorationCheckpoint.overlay.choiceId === orderId ); }, selectedOrderId ); const pendingChoiceBeforeReload = await readMilitiaCamp(page); const pendingChoiceResume = await reloadAndContinueToMilitiaCamp(page); await waitForCommandChoice(page, selectedOrderId); const readyPendingChoiceResume = await readMilitiaCamp(page); assertExactPlayer( readyPendingChoiceResume.player, pendingChoiceBeforeReload.player, `${renderer}: command choice position resume` ); assert.equal( pendingChoiceResume.commandChoice.pendingId, selectedOrderId ); assert.equal( readyPendingChoiceResume.commandChoice.confirmEnabled, true ); assert.equal( readyPendingChoiceResume.commandChoice.cards.find( ({ orderId }) => orderId === selectedOrderId )?.pending, true ); assert.deepEqual( readyPendingChoiceResume.explorationCheckpoint?.overlay, { type: 'choice', mode: 'prologue-command', sourceNpcId: 'zou-jing', choiceId: selectedOrderId }, `${renderer}: pending command selection checkpoint` ); const commandChosen = await page.evaluate((orderId) => window.__HEROS_DEBUG__ ?.scene('PrologueMilitiaCampScene') ?.debugChooseFirstCommand?.(orderId) ?? false, selectedOrderId ); assert.equal( commandChosen, true, `${renderer}: could not confirm the command choice.` ); await waitForMilitiaDialogue(page, { key: 'prologue-camp-command-response', source: `first-command-${selectedOrderId}`, lineIndex: 0 }); await page.waitForTimeout(320); await page.keyboard.press('e'); await waitForMilitiaDialogue(page, { key: 'prologue-camp-command-response', source: `first-command-${selectedOrderId}`, lineIndex: 1 }); const responseBeforeReload = await readMilitiaCamp(page); const responseResume = await reloadAndContinueToMilitiaCamp(page); assertExactPlayer( responseResume.player, responseBeforeReload.player, `${renderer}: command response position resume` ); assertExplorationDialogue( responseResume, { key: 'prologue-camp-command-response', source: `first-command-${selectedOrderId}`, lineIndex: 1 }, `${renderer}: command response line resume` ); assert.deepEqual( (await readCampaignCheckpoint(page)) .sortieOrderSelection, { battleId: 'first-battle-zhuo-commandery', orderId: selectedOrderId } ); await page.waitForTimeout(320); await page.keyboard.press('e'); await waitForStoryReady(page, 'BattleScene'); const campExitState = await readCampaignCheckpoint(page); assert.equal(campExitState.step, 'prologue-camp'); assert.equal(campExitState.checkpoint, null); assert.equal(campExitState.campComplete, true); assert.deepEqual(campExitState.sortieOrderSelection, { battleId: 'first-battle-zhuo-commandery', orderId: selectedOrderId }); await reloadToTitle(page); await clickContinue(page); await waitForStoryReady(page, 'BattleScene'); assert( !(await page.evaluate(() => window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('PrologueMilitiaCampScene') )), `${renderer}: Continue reopened a completed militia camp.` ); assert.equal( (await readCampaignCheckpoint(page)).checkpoint, null ); const actionableConsoleErrors = consoleErrors.filter( (message) => !message.includes( 'The AudioContext was not allowed to start' ) ); assert.deepEqual( pageErrors, [], `${renderer}: page errors: ${pageErrors.join('\n')}` ); assert.deepEqual( actionableConsoleErrors, [], `${renderer}: console errors: ${actionableConsoleErrors.join( '\n' )}` ); console.log( JSON.stringify( { renderer, viewport: { ...desktopBrowserViewport, deviceScaleFactor: desktopBrowserDeviceScaleFactor, browserZoom: '100%' }, checkpoints: { villageExactPlayerResume: villagePlayer, villageDialogueLineResume: true, villageExitCleared: true, villageToCampContextChangedSafely: true, militiaExactPlayerResume: campPlayer, militiaDialogueLineResume: true, commandChoiceResume: true, pendingCommandSelectionResume: selectedOrderId, commandResponseLineResume: true, militiaExitCleared: true }, pageErrors: pageErrors.length, consoleErrors: actionableConsoleErrors.length }, null, 2 ) ); } 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); return parsed.toString(); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function assertDesktopRuntime(page, expectedRenderer) { const runtime = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return { innerWidth: window.innerWidth, innerHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio, visualViewportScale: window.visualViewport?.scale ?? 1, rendererType: window.__HEROS_GAME__?.renderer?.type ?? null, canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null } : null }; }); assert.equal(runtime.innerWidth, desktopBrowserViewport.width); assert.equal(runtime.innerHeight, desktopBrowserViewport.height); assert.equal( runtime.devicePixelRatio, desktopBrowserDeviceScaleFactor ); assert.equal(runtime.visualViewportScale, 1); assert.equal( runtime.rendererType, expectedRenderer === 'canvas' ? 1 : 2 ); assert.equal( runtime.canvas?.width, desktopBrowserViewport.width ); assert.equal( runtime.canvas?.height, desktopBrowserViewport.height ); assert.equal( runtime.canvas?.clientWidth, desktopBrowserViewport.width ); assert.equal( runtime.canvas?.clientHeight, desktopBrowserViewport.height ); assert.equal( runtime.canvas?.bounds?.width, desktopBrowserViewport.width ); assert.equal( runtime.canvas?.bounds?.height, desktopBrowserViewport.height ); } async function seedVillage(page) { return page.evaluate(async () => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); campaignModule.resetCampaignState(); const state = campaignModule.createInitialCampaignState(); state.step = 'prologue-town'; state.completedTutorialIds = [ campaignModule.prologueVillageCampaignTutorialIds .entered ]; const saved = campaignModule.setCampaignState(state); return { step: saved.step, checkpoint: saved.explorationCheckpoint ?? null, entered: saved.completedTutorialIds.includes( campaignModule.prologueVillageCampaignTutorialIds .entered ) }; }); } async function waitForVillageReady(page) { try { await page.waitForFunction( () => { const village = window.__HEROS_DEBUG__?.village?.(); return ( window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('PrologueVillageScene') && village?.scene === 'PrologueVillageScene' && village?.ready === true && village?.requiredTexturesReady === true ); }, undefined, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], village: window.__HEROS_DEBUG__?.village?.() ?? null, title: window.__HEROS_DEBUG__?.title?.() ?? null })); throw new Error( `${renderer}: village did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); return readVillage(page); } async function waitForMilitiaCampReady(page) { try { await page.waitForFunction( () => { const camp = window.__HEROS_DEBUG__?.militiaCamp?.(); return ( window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('PrologueMilitiaCampScene') && camp?.scene === 'PrologueMilitiaCampScene' && camp?.ready === true && camp?.requiredTexturesReady === true ); }, undefined, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null, story: window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null })); throw new Error( `${renderer}: militia camp did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); return readMilitiaCamp(page); } async function readVillage(page) { return page.evaluate( () => window.__HEROS_DEBUG__?.village?.() ?? null ); } async function readMilitiaCamp(page) { return page.evaluate( () => window.__HEROS_DEBUG__?.militiaCamp?.() ?? null ); } async function createMovementCheckpoint(page, sceneKind) { const reader = sceneKind === 'village' ? readVillage : readMilitiaCamp; await page.waitForTimeout(450); const before = await reader(page); const teleported = await page.evaluate( ({ sceneKey, targetId }) => window.__HEROS_DEBUG__ ?.scene(sceneKey) ?.debugTeleportTo?.(targetId) ?? false, sceneKind === 'village' ? { sceneKey: 'PrologueVillageScene', targetId: 'zhang-fei' } : { sceneKey: 'PrologueMilitiaCampScene', targetId: 'quartermaster' } ); assert.equal( teleported, true, `${renderer}: ${sceneKind} checkpoint teleport failed.` ); await page.waitForTimeout(100); await page.keyboard.down('ArrowRight'); await page.waitForTimeout(240); await page.keyboard.up('ArrowRight'); await page.waitForFunction( (kind) => { const state = kind === 'village' ? window.__HEROS_DEBUG__?.village?.() : window.__HEROS_DEBUG__?.militiaCamp?.(); const player = state?.player; const checkpoint = state?.explorationCheckpoint; return ( player?.moving === false && player?.direction === 'east' && checkpoint?.overlay === undefined && Math.abs(checkpoint?.player?.x - player.x) <= 0.05 && Math.abs(checkpoint?.player?.y - player.y) <= 0.05 && checkpoint?.player?.direction === player.direction ); }, sceneKind, { timeout: 10000 } ); const state = await reader(page); assert( Math.hypot( state.player.x - before.player.x, state.player.y - before.player.y ) > 20, `${renderer}: ${sceneKind} movement checkpoint was not distinct.` ); return exactPlayer(state.player); } async function openVillageDialogueLine(page) { const interacted = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('PrologueVillageScene') ?.debugInteractWith?.('zhang-fei') ?? false ); assert.equal( interacted, true, `${renderer}: could not interact with Zhang Fei.` ); await waitForVillageDialogue(page, { key: 'prologue-village-interaction', source: 'zhang-fei', lineIndex: 0 }); await page.waitForTimeout(320); await page.keyboard.press('e'); await waitForVillageDialogue(page, { key: 'prologue-village-interaction', source: 'zhang-fei', lineIndex: 1 }); const village = await readVillage(page); return { ...village, player: exactPlayer(village.player) }; } async function openMilitiaDialogueLine(page) { const interacted = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('PrologueMilitiaCampScene') ?.debugInteractWith?.('guan-yu') ?? false ); assert.equal( interacted, true, `${renderer}: could not interact with Guan Yu.` ); await waitForMilitiaDialogue(page, { key: 'prologue-camp-interaction', source: 'guan-yu', lineIndex: 0 }); await page.waitForTimeout(320); await page.keyboard.press('e'); await waitForMilitiaDialogue(page, { key: 'prologue-camp-interaction', source: 'guan-yu', lineIndex: 1 }); const camp = await readMilitiaCamp(page); return { ...camp, player: exactPlayer(camp.player) }; } async function waitForVillageDialogue( page, expected ) { await page.waitForFunction( ({ key, source, lineIndex }) => { const village = window.__HEROS_DEBUG__?.village?.(); return ( village?.dialogue?.active === true && village.dialogue.dialogueKey === key && village.dialogue.sourceNpcId === source && village.dialogue.lineIndex === lineIndex && village.explorationCheckpoint?.overlay?.type === 'dialogue' && village.explorationCheckpoint.overlay .dialogueKey === key && village.explorationCheckpoint.overlay .sourceNpcId === source && village.explorationCheckpoint.overlay .lineIndex === lineIndex ); }, expected ); } async function waitForMilitiaDialogue( page, expected ) { await page.waitForFunction( ({ key, source, lineIndex }) => { const camp = window.__HEROS_DEBUG__?.militiaCamp?.(); return ( camp?.dialogue?.active === true && camp.dialogue.dialogueKey === key && camp.dialogue.sourceNpcId === source && camp.dialogue.lineIndex === lineIndex && camp.explorationCheckpoint?.overlay?.type === 'dialogue' && camp.explorationCheckpoint.overlay.dialogueKey === key && camp.explorationCheckpoint.overlay.sourceNpcId === source && camp.explorationCheckpoint.overlay.lineIndex === lineIndex ); }, expected, { timeout: 15000 } ); } async function finishMilitiaDialogue(page) { for (let attempt = 0; attempt < 12; attempt += 1) { const camp = await readMilitiaCamp(page); if (!camp?.dialogue?.active) { return; } const advanced = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene( 'PrologueMilitiaCampScene' ); if (!scene || typeof scene.advanceDialogue !== 'function') { return false; } scene.advanceDialogue(); return true; }); assert.equal( advanced, true, `${renderer}: militia dialogue advance helper is unavailable.` ); await page.waitForTimeout(100); } throw new Error( `${renderer}: militia camp dialogue did not finish.` ); } async function waitForCommandChoice( page, pendingId = null ) { await page.waitForFunction( (expectedPendingId) => { const camp = window.__HEROS_DEBUG__?.militiaCamp?.(); return ( camp?.commandChoice?.open === true && camp.commandChoice.ready === true && camp.explorationCheckpoint?.overlay?.type === 'choice' && camp.explorationCheckpoint.overlay.mode === 'prologue-command' && (expectedPendingId === null || (camp.commandChoice.pendingId === expectedPendingId && camp.explorationCheckpoint.overlay.choiceId === expectedPendingId)) ); }, pendingId, { timeout: 30000 } ); } function assertExplorationDialogue( state, { key, source, lineIndex }, label ) { assert.equal(state?.dialogue?.active, true, label); assert.equal(state.dialogue.dialogueKey, key, label); assert.equal(state.dialogue.sourceNpcId, source, label); assert.equal(state.dialogue.lineIndex, lineIndex, label); assert.deepEqual( state.explorationCheckpoint?.overlay, { type: 'dialogue', dialogueKey: key, sourceNpcId: source, lineIndex }, label ); } async function waitForStoryReady(page, nextScene) { try { await page.waitForFunction( (expectedNextScene) => { const debug = window.__HEROS_DEBUG__; const storyScene = debug?.scene('StoryScene'); const story = storyScene?.getDebugState?.(); return ( debug?.activeScenes?.().includes('StoryScene') && story?.ready === true && storyScene?.transitioning === false && story?.nextScene === expectedNextScene ); }, nextScene, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], story: window.__HEROS_DEBUG__ ?.scene('StoryScene') ?.getDebugState?.() ?? null, village: window.__HEROS_DEBUG__?.village?.() ?? null, militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null })); throw new Error( `${renderer}: StoryScene did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); } async function readCampaignCheckpoint(page) { return page.evaluate(async () => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const campaign = campaignModule.getCampaignState(); return { step: campaign.step, checkpoint: campaign.explorationCheckpoint ?? null, villageComplete: campaign.completedTutorialIds.includes( campaignModule.prologueVillageCampaignTutorialIds .complete ), campComplete: campaign.completedTutorialIds.includes( campaignModule .prologueMilitiaCampCampaignTutorialIds.complete ), sortieOrderSelection: campaign.sortieOrderSelection ?? null }; }); } async function reloadAndContinueToVillage(page) { await reloadToTitle(page); await clickContinue(page); return waitForVillageReady(page); } async function reloadAndContinueToMilitiaCamp(page) { await reloadToTitle(page); await clickContinue(page); return waitForMilitiaCampReady(page); } async function reloadToTitle(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const title = debug?.title?.(); const continueItem = title?.focus?.items?.find( ({ id }) => id === 'continue' ); return ( debug?.activeScenes?.().includes('TitleScene') && title?.scene === 'TitleScene' && title?.navigating === false && title?.focus?.scope === 'main' && continueItem?.enabled === true && continueItem?.bounds ); }, undefined, { timeout: 90000 } ); await afterTwoFrames(page); } async function clickContinue(page) { const item = await page.evaluate( () => window.__HEROS_DEBUG__ ?.title?.() ?.focus?.items?.find( ({ id }) => id === 'continue' ) ?? null ); assert.equal( item?.enabled, true, `${renderer}: Continue is unavailable.` ); await clickBounds(page, item.bounds); } async function clickBounds(page, bounds) { assertFiniteBounds(bounds); assert( bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= desktopBrowserViewport.width && bounds.y + bounds.height <= desktopBrowserViewport.height, `Bounds are outside the FHD viewport: ${JSON.stringify( bounds )}` ); await page.mouse.click( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 ); } function exactPlayer(player) { assert(player, 'Player debug state is required.'); assert(Number.isFinite(player.x)); assert(Number.isFinite(player.y)); assert( ['north', 'south', 'east', 'west'].includes( player.direction ) ); return { x: player.x, y: player.y, direction: player.direction }; } function assertExactPlayer(actual, expected, label) { assert(actual, label); assert( Math.abs(actual.x - expected.x) <= 0.05, `${label}: x ${actual.x} !== ${expected.x}` ); assert( Math.abs(actual.y - expected.y) <= 0.05, `${label}: y ${actual.y} !== ${expected.y}` ); assert.equal(actual.direction, expected.direction, label); } function assertCheckpoint( checkpoint, scene, contextId, expectedPlayer, label ) { assert.equal(checkpoint?.version, 1, label); assert.equal(checkpoint?.scene, scene, label); assert.equal(checkpoint?.contextId, contextId, label); assertExactPlayer( checkpoint?.player, exactPlayer(expectedPlayer), label ); } function assertFiniteBounds(bounds) { assert(bounds, 'Interactive bounds are required.'); for (const value of [ bounds.x, bounds.y, bounds.width, bounds.height ]) { assert(Number.isFinite(value)); } assert(bounds.width > 0); assert(bounds.height > 0); } async function afterTwoFrames(page) { await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve) ) ) ); } 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 || '41811', '--strictPort' ], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } ); child.stderr.on('data', (chunk) => stderr.push(chunk.toString()) ); child.stdout.on('data', () => {}); for (let attempt = 0; attempt < 160; attempt += 1) { if (await canReach(url)) { return child; } await delay(250); } child.kill(); throw new Error( `Vite server did not start at ${url}\n${stderr.join('')}` ); } async function canReach(url) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return response.ok; } catch { return false; } } async function stopServerProcess(child) { if (!child || child.killed) { return; } child.kill(); await delay(80); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }