import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const explicitUrl = process.env.TITLE_ACCESSIBILITY_QA_URL; const serverUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41764/heros_web/'); let serverProcess; let browser; try { if (!explicitUrl) { serverProcess = spawn( process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41764', '--strictPort'], { cwd: process.cwd(), stdio: 'ignore', windowsHide: true } ); await waitForUrl(serverUrl, 20000); } browser = await chromium.launch({ headless: true }); for (const renderer of ['canvas', 'webgl']) { await verifyRenderer(browser, renderer); } console.info('Verified title keyboard focus, modal return, save-slot skipping, and live reduced motion in Canvas and WebGL.'); } finally { await browser?.close(); await stopServerProcess(serverProcess); } async function verifyRenderer(browserInstance, renderer) { const context = await browserInstance.newContext(desktopBrowserContextOptions); const page = await context.newPage(); await page.emulateMedia({ reducedMotion: 'reduce' }); const pageErrors = []; const consoleErrors = []; page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()); } }); try { const targetUrl = new URL(serverUrl); targetUrl.searchParams.set('debug', '1'); targetUrl.searchParams.set('renderer', renderer); await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForTitle(page); const initial = await titleSnapshot(page); assert.deepEqual(initial.viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, scale: 1 }); assert.deepEqual(initial.canvas, { x: 0, y: 0, width: 1920, height: 1080 }); assert.equal(initial.title.focus.scope, 'main'); assert.equal(initial.title.focus.itemId, 'new-game'); assert.equal(focusItem(initial.title, 'continue').enabled, false); assert.equal(focusItem(initial.title, 'new-game').focusIndicatorVisible, true); assert.equal(initial.title.motion.mode, 'reduced'); assert.equal(initial.title.motion.reduced, true); assert.equal(initial.title.motion.activeInfiniteTweenCount, 0); await page.keyboard.press('ArrowDown'); assert.equal((await titleState(page)).focus.itemId, 'settings', 'ArrowDown must skip disabled Continue.'); await page.keyboard.press('Enter'); let title = await waitForFocus(page, 'settings', 'sound'); assert.equal(title.panels.settings, true); await page.keyboard.press('Space'); for (const [expectedId, activationKey] of [ ['music', 'Enter'], ['effects', 'Space'], ['ambience', 'Enter'], ['combat-presentation', 'Space'] ]) { await page.keyboard.press('Tab'); title = await waitForFocus(page, 'settings', expectedId); await page.keyboard.press(activationKey); assert.equal((await titleState(page)).focus.itemId, expectedId); } await page.keyboard.press('Tab'); title = await waitForFocus(page, 'settings', 'visual-motion'); assert.equal(title.motion.reduced, true); await page.keyboard.press('Enter'); title = await titleState(page); assert.equal(title.motion.mode, 'full'); assert.equal(title.motion.reduced, false); assert.equal(title.motion.activeInfiniteTweenCount, 36); await page.keyboard.press('Space'); title = await titleState(page); assert.equal(title.motion.mode, 'reduced'); assert.equal(title.motion.reduced, true); assert.equal(title.motion.activeInfiniteTweenCount, 0); await page.keyboard.press('Tab'); await waitForFocus(page, 'settings', 'close'); await page.keyboard.press('Enter'); title = await waitForFocus(page, 'main', 'settings'); assert.equal(title.panels.settings, false); await page.keyboard.press('Enter'); await waitForFocus(page, 'settings', 'sound'); await page.keyboard.press('Shift+Tab'); await waitForFocus(page, 'settings', 'close'); await page.keyboard.press('Escape'); await waitForFocus(page, 'main', 'settings'); const newGameBounds = focusItem(await titleState(page), 'new-game').bounds; await page.mouse.move(newGameBounds.x + newGameBounds.width / 2, newGameBounds.y + newGameBounds.height / 2); await waitForFocus(page, 'main', 'new-game'); await page.mouse.move(4, 4); title = await titleState(page); assert.equal(title.focus.itemId, 'new-game', 'Pointer-out must preserve the persistent focus.'); assert.equal(focusItem(title, 'new-game').focusIndicatorVisible, true); await seedMultipleSavesAndRestartTitle(page); title = await waitForFocus(page, 'main', 'continue'); assert.equal(title.motion.reduced, true); assert.equal(title.motion.activeInfiniteTweenCount, 0); await page.keyboard.press('Enter'); title = await waitForFocus(page, 'save-slots', 'slot-1'); assert.equal(title.panels.saveSlots, true); assert.equal(focusItem(title, 'slot-2').enabled, false); await page.keyboard.press('ArrowDown'); await waitForFocus(page, 'save-slots', 'slot-3'); await page.keyboard.press('Tab'); await waitForFocus(page, 'save-slots', 'close'); await page.keyboard.press('Shift+Tab'); await waitForFocus(page, 'save-slots', 'slot-3'); await page.screenshot({ path: `dist/qa-title-accessibility-${renderer}-1920x1080.png`, fullPage: true }); const slot1Bounds = focusItem(await titleState(page), 'slot-1').bounds; await page.mouse.move(slot1Bounds.x + slot1Bounds.width / 2, slot1Bounds.y + slot1Bounds.height / 2); await waitForFocus(page, 'save-slots', 'slot-1'); await page.mouse.move(4, 4); assert.equal((await titleState(page)).focus.itemId, 'slot-1'); await page.keyboard.press('Escape'); await waitForFocus(page, 'main', 'continue'); await page.keyboard.press('ArrowUp'); await waitForFocus(page, 'main', 'new-game'); await page.keyboard.press('Space'); await waitForFocus(page, 'new-game-confirm', 'confirm'); await page.keyboard.press('ArrowDown'); await waitForFocus(page, 'new-game-confirm', 'cancel'); await page.keyboard.press('Enter'); await waitForFocus(page, 'main', 'new-game'); await page.keyboard.press('ArrowDown'); await waitForFocus(page, 'main', 'continue'); await page.keyboard.press('Space'); await waitForFocus(page, 'save-slots', 'slot-1'); await page.keyboard.press('ArrowDown'); await waitForFocus(page, 'save-slots', 'slot-3'); await page.evaluate(() => { const scene = window.__HEROS_GAME__.scene.getScene('TitleScene'); window.__TITLE_QA_ACTIVATED_SLOT__ = undefined; scene.continueGame = (slot) => { window.__TITLE_QA_ACTIVATED_SLOT__ = slot; }; }); await page.keyboard.press('Space'); await page.waitForFunction(() => window.__TITLE_QA_ACTIVATED_SLOT__ === 3); assert.deepEqual(pageErrors, [], `${renderer} page errors: ${pageErrors.join('\n')}`); assert.deepEqual(consoleErrors, [], `${renderer} console errors: ${consoleErrors.join('\n')}`); } finally { await context.close(); } } async function seedMultipleSavesAndRestartTitle(page) { await page.evaluate(() => { const makeSave = (slot, updatedAt) => ({ version: 1, updatedAt, step: 'prologue', activeSaveSlot: slot }); const slot1 = makeSave(1, '2026-07-27T08:00:00.000Z'); const slot3 = makeSave(3, '2026-07-27T09:00:00.000Z'); window.localStorage.setItem('heros-web:campaign-state', JSON.stringify(slot1)); window.localStorage.setItem('heros-web:campaign-state:slot-1', JSON.stringify(slot1)); window.localStorage.removeItem('heros-web:campaign-state:slot-2'); window.localStorage.setItem('heros-web:campaign-state:slot-3', JSON.stringify(slot3)); window.__HEROS_GAME__.scene.getScene('TitleScene').scene.restart(); }); await waitForTitle(page); } async function waitForTitle(page) { await page.waitForFunction(() => { const title = window.__HEROS_DEBUG__?.title?.(); return title?.scene === 'TitleScene' && title.focus?.items?.length > 0; }, undefined, { timeout: 90000 }); } async function waitForFocus(page, scope, itemId) { try { await page.waitForFunction( ({ expectedScope, expectedItemId }) => { const focus = window.__HEROS_DEBUG__?.title?.()?.focus; return focus?.scope === expectedScope && focus?.itemId === expectedItemId; }, { expectedScope: scope, expectedItemId: itemId }, { timeout: 5000 } ); } catch (error) { const actual = await titleState(page); throw new Error( `Timed out waiting for ${scope}/${itemId}; current focus is ${actual?.focus?.scope}/${actual?.focus?.itemId}.`, { cause: error } ); } return titleState(page); } async function titleState(page) { return page.evaluate(() => window.__HEROS_DEBUG__.title()); } async function titleSnapshot(page) { return page.evaluate(() => { const canvasBounds = document.querySelector('canvas')?.getBoundingClientRect(); return { viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, scale: window.visualViewport?.scale ?? 1 }, canvas: canvasBounds ? { x: canvasBounds.x, y: canvasBounds.y, width: canvasBounds.width, height: canvasBounds.height } : null, title: window.__HEROS_DEBUG__.title() }; }); } function focusItem(title, id) { const item = title.focus.items.find((candidate) => candidate.id === id); assert(item, `Expected focus item ${id}: ${JSON.stringify(title.focus)}`); return item; } async function waitForUrl(url, timeoutMs) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { try { const response = await fetch(url); if (response.ok) { return; } } catch { // The local development server is still starting. } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`Timed out waiting for ${url.href}`); } 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 delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }