300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
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));
|
|
}
|