clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / tests / browser / share-flow.mjs
share-flow.mjs 52.8K
1#!/usr/bin/env node23/**4 * Browser acceptance check for the level-pack catalog and sharing flow.5 *6 * The app intentionally owns the visual layout. This script uses semantic7 * labels first and the stable data hooks second, so a visual pass can move8 * panels without changing the contract under test.9 *10 * Usage (the repository does not need Playwright as a runtime dependency):11 *12 * PLAYWRIGHT_MODULE=/path/to/playwright node tests/browser/share-flow.mjs13 *14 * The bundled Codex web-game skill supplies Playwright at:15 * /Users/dami/.codex/skills/develop-web-game/node_modules/playwright16 */1718import assert from 'node:assert/strict';19import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';20import { tmpdir } from 'node:os';21import { dirname, resolve } from 'node:path';22import { fileURLToPath } from 'node:url';2324const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');25const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || 'playwright';26const playwrightModule = configuredPlaywright.startsWith('/')27 ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`28 : configuredPlaywright;2930let playwright;31try {32 playwright = await import(playwrightModule);33} catch (error) {34 throw new Error(35 `Cannot load Playwright. Set PLAYWRIGHT_MODULE to its installed module path (${error.message})`,36 );37}3839const { chromium } = playwright.default || playwright;40const url = process.env.MIAU_URL || process.argv.find(argument => argument.startsWith('--url='))?.slice(6) || 'http://127.0.0.1:5173/';41const playerUrl = (() => { const value = new URL(url); value.searchParams.delete('dev'); value.hash = ''; return value.toString(); })();42const devUrl = (() => { const value = new URL(url); value.searchParams.set('dev', '1'); value.hash = ''; return value.toString(); })();43const headed = process.argv.includes('--headed');44const timeout = Number(process.env.MIAU_BROWSER_TIMEOUT || 8_000);45const stageScreenshotDir = '/private/tmp/miau-stage-qa';4647const fixtureOne = `48module.exports = {49 manifest: { id: 'browser-smoke-one', name: 'Browser Smoke One', version: '1.0.0' },50 vocabulary: ['CAT', 'FLAG'],51 palette: { CAT: '🐈', FLAG: '🚩' },52 levels: [{53 id: 'smoke-one', title: 'Smoke One', hint: 'A first shared garden.', width: 5, height: 3,54 objects: [55 { kind: 'word', text: 'CAT', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'YOU', x: 2, y: 0 },56 { kind: 'word', text: 'FLAG', x: 0, y: 2 }, { kind: 'word', text: 'IS', x: 1, y: 2 }, { kind: 'word', text: 'WIN', x: 2, y: 2 },57 { kind: 'object', noun: 'CAT', x: 0, y: 1 }, { kind: 'object', noun: 'FLAG', x: 4, y: 1 },58 ],59 }],60};61`;6263const fixtureTwo = `64module.exports = {65 manifest: { id: 'browser-smoke-two', name: 'Browser Smoke Two', version: '1.0.0' },66 vocabulary: ['CAT', 'STAR'],67 palette: { CAT: '🐈', STAR: '⭐' },68 levels: [{69 id: 'smoke-two', title: 'Smoke Two', hint: 'A second imported garden.', width: 4, height: 3,70 objects: [71 { kind: 'word', text: 'CAT', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'YOU', x: 2, y: 0 },72 { kind: 'word', text: 'STAR', x: 0, y: 2 }, { kind: 'word', text: 'IS', x: 1, y: 2 }, { kind: 'word', text: 'WIN', x: 2, y: 2 },73 { kind: 'object', noun: 'CAT', x: 0, y: 1 }, { kind: 'object', noun: 'STAR', x: 3, y: 1 },74 ],75 }],76};77`;7879const invalidFixture = `module.exports = { manifest: { name: 'Broken pack' }, levels: [] };\n`;80const selfWinFixture = `81module.exports = {82 manifest: { id: 'browser-self-win', name: 'Browser Self Win', version: '1.0.0' },83 vocabulary: ['MIAU'],84 palette: { MIAU: '🐈' },85 levels: [{ id: 'self-win', title: 'Self Win', hint: 'MIAU IS YOU and MIAU IS WIN.', width: 5, height: 3,86 objects: [87 { kind: 'word', text: 'MIAU', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'YOU', x: 2, y: 0 },88 { kind: 'word', text: 'MIAU', x: 0, y: 2 }, { kind: 'word', text: 'IS', x: 1, y: 2 }, { kind: 'word', text: 'WIN', x: 2, y: 2 },89 { kind: 'object', noun: 'MIAU', x: 0, y: 1 },90 ],91 }],92};93`;9495function report(label) {96 process.stdout.write(`✓ ${label}\n`);97}9899async function firstVisible(page, locators, label) {100 for (const locator of locators) {101 const candidate = typeof locator === 'function' ? locator(page) : page.locator(locator);102 if (await candidate.count() && await candidate.first().isVisible().catch(() => false)) return candidate.first();103 }104 throw new Error(`Could not find visible ${label}; tried: ${locators.map(String).join(', ')}`);105}106107async function maybeVisible(page, locators) {108 for (const locator of locators) {109 const candidate = typeof locator === 'function' ? locator(page) : page.locator(locator);110 if (await candidate.count() && await candidate.first().isVisible().catch(() => false)) return candidate.first();111 }112 return null;113}114115async function firstPresent(page, locators, label) {116 for (const locator of locators) {117 const candidate = typeof locator === 'function' ? locator(page) : page.locator(locator);118 if (await candidate.count()) return candidate.first();119 }120 throw new Error(`Could not find ${label}; tried: ${locators.map(String).join(', ')}`);121}122123async function packChoices(page) {124 const candidates = [125 page.locator('[data-pack-id]'),126 page.locator('.pack-choice'),127 page.locator('[role="listitem"][data-pack]'),128 ];129 for (const candidate of candidates) if (await candidate.count()) return candidate;130 return page.locator('#level-list [data-pack-id], #level-list .pack-choice');131}132133async function packCount(page) {134 const choices = await packChoices(page);135 return choices.count();136}137138async function currentPackId(page) {139 const active = await maybeVisible(page, [140 '[data-active-pack-id]',141 '[data-pack-id][aria-current="true"]',142 '[data-pack-id].active',143 '.pack-choice[aria-selected="true"]',144 ]);145 return active ? (await active.getAttribute('data-active-pack-id')) || (await active.getAttribute('data-pack-id')) : null;146}147148async function selectPack(page, idOrName) {149 const exact = page.locator(`[data-pack-id="${idOrName}"]`);150 if (await exact.count()) {151 await exact.first().click();152 return;153 }154 const named = page.getByText(idOrName, { exact: true });155 if (await named.count()) {156 await named.first().click();157 return;158 }159 throw new Error(`Could not select pack ${idOrName}`);160}161162async function importLocalModule(page, filePath, { install = true } = {}) {163 const input = await firstPresent(page, [164 '#mod-input',165 'input[type="file"][accept*="cjs"]',166 'input[type="file"][accept*="js"]',167 'input[type="file"]',168 ], 'local CommonJS import input');169 await input.setInputFiles(filePath);170 await page.waitForTimeout(150);171 if (install) {172 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');173 await page.keyboard.press('Enter');174 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');175 await dismissFirstWorldCard(page);176 }177}178179async function importSharedUrl(page, value) {180 const input = await firstVisible(page, [181 '#shared-url-input',182 '[data-shared-url-input]',183 'input[type="url"]',184 'textarea[data-share-url]',185 ], 'shared URL input');186 await input.fill(value);187 const submit = await firstVisible(page, [188 '#import-shared-url',189 '[data-import-shared-url]',190 page => page.getByRole('button', { name: /import\s+(shared\s+)?url|open\s+shared/i }),191 ], 'shared URL import action');192 await submit.click();193 await page.waitForTimeout(200);194}195196async function readShareUrl(page) {197 const field = await firstVisible(page, [198 '#share-url-input',199 '[data-share-url]',200 'input[readonly][type="url"]',201 'textarea[readonly]',202 ], 'generated share URL field');203 const value = await field.inputValue();204 assert.match(value, /^https?:\/\//, 'share URL should be an absolute URL');205 assert.match(value, /(?:[?#].*miau\d?\.|[?#].*(?:pack|share))/i, 'share URL should carry a Miau share payload');206 return value;207}208209async function triggerShare(page) {210 const action = await firstVisible(page, [211 '#copy-share',212 '#share-pack',213 '[data-share-pack]',214 '[data-share]',215 page => page.getByRole('button', { name: /^share(?:\s+pack)?$/i }),216 page => page.getByRole('button', { name: /share/i }),217 ], 'pack share action');218 await action.click();219 await page.waitForFunction(() => document.querySelector('#share-url-input, [data-share-url]')?.value?.length > 0).catch(() => page.waitForTimeout(150));220}221222async function downloadPack(page) {223 const action = await firstVisible(page, [224 '#export-pack',225 '[data-export-pack]',226 '[data-download-pack]',227 page => page.getByRole('button', { name: /export|download/i }),228 ], 'pack export action');229 const downloadPromise = page.waitForEvent('download');230 await action.click();231 const download = await downloadPromise;232 assert.match(download.suggestedFilename(), /\.(?:cjs|js)$/i, 'export must be a CommonJS file');233 return download;234}235236async function assertFeedback(page, pattern, label) {237 const statuses = page.locator('[role="status"], [aria-live="polite"], [data-pack-status]');238 await page.waitForFunction(({ source, statuses: statusCount }) => {239 const nodes = Array.from(document.querySelectorAll('[role="status"], [aria-live="polite"], [data-pack-status]')).slice(0, statusCount);240 return nodes.some(node => new RegExp(source, 'i').test(node.textContent || ''));241 }, { source: pattern.source, statuses: await statuses.count() });242 report(label);243}244245async function assertCanvasRound3Typography(page, { ledger = false, win = false } = {}) {246 const checks = await page.evaluate(() => {247 const canvas = document.querySelector('.stage-canvas');248 const ctx = canvas?.getContext('2d');249 if (!canvas || !ctx) return null;250 const pixel = ctx.getImageData(0, 0, canvas.width, canvas.height).data;251 const countLight = (left, top, width, height, threshold = 180) => {252 let count = 0;253 for (let y = top; y < top + height; y += 1) for (let x = left; x < left + width; x += 1) {254 const offset = (y * canvas.width + x) * 4;255 if (pixel[offset] > threshold && pixel[offset + 1] > threshold && pixel[offset + 2] > threshold) count += 1;256 }257 return count;258 };259 // The first ledger operator pill is stable at desktop proof geometry.260 const ledgerOperatorPixels = countLight(365, 653, 22, 17);261 // The first operator in the centred FLAG IS WIN sentence on the Win card.262 const winOperatorPixels = countLight(620, 245, 55, 45);263 const rows = [];264 for (let y = 315; y < 420; y += 1) {265 if (countLight(345, y, 590, 1, 70) > 2) rows.push(y);266 }267 const runs = [];268 for (const row of rows) {269 const prior = runs[runs.length - 1];270 if (prior && row === prior[1] + 1) prior[1] = row;271 else runs.push([row, row]);272 }273 return { ledgerOperatorPixels, winOperatorPixels, textRuns: runs };274 });275 assert.ok(checks, 'stage canvas should be readable for round-3 typography checks');276 if (ledger) assert.ok(checks.ledgerOperatorPixels > 2, 'ledger IS pill should contain visible light operator text');277 if (win) assert.ok(checks.winOperatorPixels > 2, 'Win card IS pill should contain visible light operator text');278 if (!win) return;279 const headline = checks.textRuns.find(run => run[0] >= 330 && run[0] <= 370);280 const progress = checks.textRuns.find(run => run[0] > (headline?.[1] ?? 0) && run[0] < 405);281 assert.ok(headline && progress, 'Win card should render headline and progress text bands');282 assert.ok(progress[0] - headline[1] >= 10, 'Win card progress line should have visible spacing below headline');283}284285async function assertGiftActionsBelowPreview(page, label) {286 const box = await page.evaluate(() => {287 const canvas = document.querySelector('.stage-canvas');288 const ctx = canvas?.getContext('2d');289 if (!canvas || !ctx) return null;290 const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;291 const orange = (x, y) => {292 const i = (y * canvas.width + x) * 4;293 return pixels[i] > 180 && pixels[i + 1] > 90 && pixels[i + 1] < 210 && pixels[i + 2] < 100;294 };295 const actionRows = [];296 for (let y = Math.floor(canvas.height * 0.4); y < canvas.height; y += 1) {297 let count = 0;298 for (let x = 0; x < canvas.width; x += 1) if (orange(x, y)) count += 1;299 if (count > 100) actionRows.push(y);300 }301 let minX = canvas.width;302 let maxX = -1;303 let minY = actionRows.length ? Math.min(...actionRows) : canvas.height;304 let maxY = actionRows.length ? Math.max(...actionRows) : -1;305 for (const y of actionRows) for (let x = 0; x < canvas.width; x += 1) if (orange(x, y)) {306 minX = Math.min(minX, x);307 maxX = Math.max(maxX, x);308 }309 return { width: canvas.width, height: canvas.height, minX, maxX, minY, maxY };310 });311 assert.ok(box && box.maxY >= 0, `${label} should render a selected Gift action`);312 if (box.width <= 760) {313 assert.ok(box.minY >= box.height * 0.62, `${label} Gift actions should sit below the preview board`);314 } else {315 assert.ok(box.minX >= box.width * 0.40, `${label} Gift actions should sit to the right of the preview board`);316 }317}318319async function openDevProof(page) {320 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'title');321 await page.keyboard.press('Enter');322 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');323 // First entry to every world presents a card before map actions are accepted.324 await dismissFirstWorldCard(page);325 await page.keyboard.press('Enter');326 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');327}328329async function dismissFirstWorldCard(page) {330 await page.keyboard.press('Enter');331 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');332}333334const tempDir = await mkdtemp(`${tmpdir()}/miau-share-flow-`);335await mkdir(stageScreenshotDir, { recursive: true });336const firstPath = resolve(tempDir, 'browser-smoke-one.cjs');337const secondPath = resolve(tempDir, 'browser-smoke-two.cjs');338const invalidPath = resolve(tempDir, 'browser-smoke-invalid.cjs');339const selfWinPath = resolve(tempDir, 'browser-self-win.cjs');340const gardenExamplePath = resolve(projectRoot, 'mods/garden-basics.cjs');341const wordLabExamplePath = resolve(projectRoot, 'mods/word-lab.cjs');342await writeFile(firstPath, fixtureOne);343await writeFile(secondPath, fixtureTwo);344await writeFile(invalidPath, invalidFixture);345await writeFile(selfWinPath, selfWinFixture);346347const browser = await chromium.launch({ headless: !headed });348const context = await browser.newContext({ acceptDownloads: true });349const page = await context.newPage();350page.setDefaultTimeout(timeout);351const consoleErrors = [];352page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()); });353354try {355 const stagePage = await context.newPage();356 stagePage.setDefaultTimeout(timeout);357 await stagePage.setViewportSize({ width: 1280, height: 800 });358 const stageErrors = [];359 stagePage.on('console', message => { if (message.type() === 'error') stageErrors.push(message.text()); });360 stagePage.on('pageerror', error => stageErrors.push(error.message));361 await stagePage.goto(playerUrl, { waitUntil: 'networkidle' });362 await stagePage.waitForFunction(() => document.body.classList.contains('stage-player'));363 await stagePage.screenshot({ path: `${stageScreenshotDir}/title.png` });364 assert.equal(await stagePage.locator('body').evaluate(node => node.classList.contains('stage-player')), true, 'default route should enter stage player mode');365 assert.equal(await stagePage.locator('.stage-canvas').count(), 1, 'default route should render one stage canvas');366 assert.equal(await stagePage.locator('.stage-shell').getAttribute('data-scene'), 'title', 'default route should open on the title scene');367 await stagePage.keyboard.press('Space');368 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');369 await stagePage.keyboard.press('Escape');370 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'title');371 report('title Space opens Workshop and Escape returns to title');372 await stagePage.keyboard.press('Enter');373 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');374 await stagePage.waitForTimeout(700);375 await stagePage.screenshot({ path: `${stageScreenshotDir}/d20-core-world-card.png` });376 await dismissFirstWorldCard(stagePage);377 await stagePage.screenshot({ path: `${stageScreenshotDir}/index.png` });378 await stagePage.keyboard.press('Space');379 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');380 await stagePage.keyboard.press('Escape');381 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');382 report('INDEX Space opens Workshop and Escape returns to the same INDEX');383 const pointerContext = await browser.newContext();384 const pointerPage = await pointerContext.newPage();385 pointerPage.setDefaultTimeout(timeout);386 await pointerPage.setViewportSize({ width: 1280, height: 800 });387 await pointerPage.goto(playerUrl, { waitUntil: 'networkidle' });388 await pointerPage.keyboard.press('Enter');389 await pointerPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');390 await dismissFirstWorldCard(pointerPage);391 await pointerPage.waitForTimeout(900);392 for (const [x, y] of [[1035, 760], [1035, 775], [1080, 760]]) {393 await pointerPage.mouse.click(x, y);394 if (await pointerPage.locator('.stage-shell').getAttribute('data-scene') === 'workshop') break;395 }396 await pointerPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');397 await pointerPage.keyboard.press('Escape');398 await pointerPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');399 await pointerPage.close();400 await pointerContext.close();401 report('INDEX Workshop footer pointer opens Workshop and Escape returns');402 await stagePage.keyboard.press('Enter');403 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');404 await stagePage.waitForTimeout(700);405 await stagePage.screenshot({ path: `${stageScreenshotDir}/proof.png` });406 await assertCanvasRound3Typography(stagePage, { ledger: true });407 const stageInitial = JSON.parse(await stagePage.evaluate(() => window.render_game_to_text()));408 await stagePage.keyboard.press('ArrowRight');409 await stagePage.waitForFunction(initial => JSON.parse(window.render_game_to_text()).moves > initial, stageInitial.moves);410 const stageMoved = JSON.parse(await stagePage.evaluate(() => window.render_game_to_text()));411 await stagePage.keyboard.press('z');412 await stagePage.waitForFunction(moves => JSON.parse(window.render_game_to_text()).moves < moves, stageMoved.moves);413 for (const key of ['ArrowRight', 'ArrowRight', 'ArrowUp', 'ArrowUp', ...Array(4).fill('ArrowDown'), ...Array(7).fill('ArrowRight')]) await stagePage.keyboard.press(key);414 await stagePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');415 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'win');416 // D22 reveals the count and progress after the ring, slam, and tile stamps.417 // Check the settled card rather than an intermediate animation frame.418 await stagePage.waitForTimeout(1400);419 await stagePage.screenshot({ path: `${stageScreenshotDir}/win.png` });420 await assertCanvasRound3Typography(stagePage, { win: true });421 assert.equal(await stagePage.locator('.stage-shell').getAttribute('data-scene'), 'win', 'winning a proof should open the win scene');422 const progressKeys = await stagePage.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('miau.index.progress/')));423 assert.ok(progressKeys.some(key => key.includes('core/')), 'winning should persist core proof progress');424 await stagePage.keyboard.press('Enter');425 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');426 await stagePage.keyboard.press('ArrowRight');427 await stagePage.keyboard.press('Enter');428 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');429 const playerProofTwo = JSON.parse(await stagePage.evaluate(() => window.render_game_to_text()));430 const playerMiauTwo = playerProofTwo.objects.find(object => object.kind === 'object' && object.noun === 'MIAU');431 assert.equal(playerProofTwo.level, 'Stuck at the end', 'player Win Enter→INDEX→next should open proof 02');432 assert.deepEqual({ x: playerMiauTwo?.x, y: playerMiauTwo?.y }, { x: 1, y: 5 }, 'player proof 02 first frame should place MIAU at its authored coordinate');433 await stagePage.screenshot({ path: `${stageScreenshotDir}/player-proof-02-first-frame.png` });434 const progressBeforeReload = await stagePage.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('miau.index.progress/')));435 await stagePage.reload({ waitUntil: 'networkidle' });436 const progressAfterReload = await stagePage.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('miau.index.progress/')));437 assert.deepEqual(progressAfterReload.sort(), progressBeforeReload.sort(), 'solved proof progress should survive reload');438 report('default stage title, INDEX, proof, movement, undo, win, and progress persistence work');439 assert.deepEqual(stageErrors, [], 'default stage should have no browser errors');440 await stagePage.close();441442 await page.goto(devUrl, { waitUntil: 'networkidle' });443 await openDevProof(page);444 await page.locator('#level-title').waitFor();445 const initialPackCount = await packCount(page);446 assert.equal(initialPackCount, 4, 'the bundled catalog should contain core and three approved worlds');447 const bundledPackIds = await page.locator('#pack-list [data-pack-id]').evaluateAll(nodes => nodes.map(node => node.dataset.packId));448 assert.deepEqual(bundledPackIds, ['core', 'crossword', 'crate-works', 'tidewater'], 'bundled worlds should keep the approved order');449 for (const [packId, minimumLevels] of [['core', 3], ['crossword', 7], ['crate-works', 7], ['tidewater', 7]]) {450 assert.ok(await page.locator(`[data-level-id^="${packId}:"]`).count() >= minimumLevels, `${packId} should expose its authored world levels`);451 }452 await page.screenshot({ path: `${stageScreenshotDir}/d20-bundled-world-catalog.png` });453 report('bundled catalog order and per-world level cards are present');454455 const examplesContext = await browser.newContext();456 const examplesPage = await examplesContext.newPage();457 examplesPage.setDefaultTimeout(timeout);458 await examplesPage.goto(devUrl, { waitUntil: 'networkidle' });459 await openDevProof(examplesPage);460 await importLocalModule(examplesPage, gardenExamplePath);461 await examplesPage.waitForFunction(() => document.body.textContent.includes('Basic Proofs'));462 await importLocalModule(examplesPage, wordLabExamplePath);463 await examplesPage.waitForFunction(() => document.body.textContent.includes('Word Lab'));464 assert.ok(await examplesPage.locator('[data-pack-id="garden-basics"]').count(), 'Garden Basics should remain importable as an example pack');465 assert.ok(await examplesPage.locator('[data-pack-id="word-lab"]').count(), 'Word Lab should remain importable as an example pack');466 await examplesPage.close();467 await examplesContext.close();468 report('Garden Basics and Word Lab remain importable examples');469470 await importLocalModule(page, firstPath);471 await page.waitForFunction(() => document.body.textContent.includes('Browser Smoke One'));472 const afterFirst = await packCount(page);473 assert.ok(afterFirst > initialPackCount, 'first local module should append a pack');474 report('first local CommonJS module imported');475476 await importLocalModule(page, secondPath);477 await page.waitForFunction(() => document.body.textContent.includes('Browser Smoke Two'));478 const afterSecond = await packCount(page);479 assert.ok(afterSecond > afterFirst, 'second local module should be retained alongside the first');480 report(`multiple local modules retained (${afterSecond} pack choices)`);481482 await selectPack(page, 'browser-smoke-two');483 await page.waitForFunction(() => document.body.textContent.includes('Smoke Two'));484 assert.equal(await currentPackId(page), 'browser-smoke-two', 'selected pack should be observable');485 report('second imported pack selected');486487 await triggerShare(page);488 const shareUrl = await readShareUrl(page);489 report('selected pack encoded as a share URL');490491 const giftContext = await browser.newContext();492 const giftPage = await giftContext.newPage();493 giftPage.setDefaultTimeout(timeout);494 const giftErrors = [];495 giftPage.on('console', message => { if (message.type() === 'error') giftErrors.push(message.text()); });496 giftPage.on('pageerror', error => giftErrors.push(error.message));497 await giftPage.goto(shareUrl, { waitUntil: 'networkidle' });498 await giftPage.waitForFunction(() => document.body.classList.contains('stage-player'));499 await giftPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');500 await giftPage.screenshot({ path: `${stageScreenshotDir}/gift.png` });501 assert.equal(await giftPage.locator('.stage-shell').getAttribute('data-scene'), 'gift', 'shared player URL should open Gift');502 await giftPage.keyboard.press('Enter');503 await giftPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');504 assert.deepEqual(giftErrors, [], 'Gift player route should have no browser errors');505 report('Gift opens from a shared player URL and Keep installs into INDEX');506 await giftPage.close();507 await giftContext.close();508509 const conflictPage = await context.newPage();510 conflictPage.setDefaultTimeout(timeout);511 await conflictPage.goto(shareUrl, { waitUntil: 'networkidle' });512 await conflictPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');513 await conflictPage.screenshot({ path: `${stageScreenshotDir}/gift-conflict.png` });514 await conflictPage.keyboard.press('Enter');515 await conflictPage.screenshot({ path: `${stageScreenshotDir}/gift-conflict-choice.png` });516 await conflictPage.keyboard.press('Enter');517 await conflictPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');518 report('Gift conflict opens Replace/Keep both choices and Replace overwrites');519 await conflictPage.close();520 const copyPage = await context.newPage();521 copyPage.setDefaultTimeout(timeout);522 await copyPage.goto(shareUrl, { waitUntil: 'networkidle' });523 await copyPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');524 await copyPage.keyboard.press('Enter');525 await copyPage.keyboard.press('ArrowDown');526 await copyPage.keyboard.press('Enter');527 await copyPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');528 const forkedIds = await copyPage.evaluate(() => {529 const data = JSON.parse(localStorage.getItem('miau.pack-library.v1') || '{}');530 return (data.packs || []).map(pack => pack.manifest?.id);531 });532 assert.ok(forkedIds.includes('browser-smoke-two-2'), 'Gift Keep both should persist a forked pack');533 report('Gift Keep both forks a duplicate pack and persists it');534 await copyPage.close();535536 const workshopPage = await context.newPage();537 workshopPage.setDefaultTimeout(timeout);538 await workshopPage.goto(playerUrl, { waitUntil: 'networkidle' });539 await openDevProof(workshopPage);540 await workshopPage.keyboard.press('Escape');541 await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'pause');542 await workshopPage.keyboard.press('w');543 await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');544 await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop.png` });545 assert.equal(await workshopPage.locator('.stage-shell').getAttribute('data-scene'), 'workshop', 'pause W should open Workshop');546 report('Workshop opens from pause and renders its canvas scene');547 // Workshop rows begin with IMPORT, followed by the four bundled packs.548 // Browser Smoke One is therefore the fifth down step from the import row.549 for (let index = 0; index < 5; index += 1) await workshopPage.keyboard.press('ArrowDown');550 for (let index = 0; index < 3; index += 1) await workshopPage.keyboard.press('ArrowRight');551 await workshopPage.keyboard.press('Enter');552 await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop-remove-confirm.png` });553 await workshopPage.keyboard.press('ArrowRight');554 await workshopPage.keyboard.press('Enter');555 assert.equal(await workshopPage.locator('.stage-shell').getAttribute('data-scene'), 'workshop', 'remove cancel should stay in Workshop');556 await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop-remove-cancel.png` });557 await workshopPage.keyboard.press('Enter');558 await workshopPage.keyboard.press('Enter');559 const remainingAfterRemove = await workshopPage.evaluate(() => {560 const data = JSON.parse(localStorage.getItem('miau.pack-library.v1') || '{}');561 return (data.packs || []).map(pack => pack.manifest?.id);562 });563 assert.ok(!remainingAfterRemove.includes('browser-smoke-one'), 'confirmed Workshop remove should delete the selected pack');564 await workshopPage.reload({ waitUntil: 'networkidle' });565 await openDevProof(workshopPage);566 await workshopPage.keyboard.press('Escape');567 await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'pause');568 await workshopPage.keyboard.press('w');569 await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');570 const afterReload = await workshopPage.evaluate(() => {571 const data = JSON.parse(localStorage.getItem('miau.pack-library.v1') || '{}');572 return (data.packs || []).map(pack => pack.manifest?.id);573 });574 assert.ok(!afterReload.includes('browser-smoke-one'), 'Workshop remove should persist after reload');575 await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop-after-remove.png` });576 report('Workshop remove cancel, confirm, and reload persistence work');577 await workshopPage.close();578579 const returnFlowContext = await browser.newContext();580 const returnFlowPage = await returnFlowContext.newPage();581 returnFlowPage.setDefaultTimeout(timeout);582 await returnFlowPage.goto(playerUrl, { waitUntil: 'networkidle' });583 await returnFlowPage.waitForTimeout(700);584 await returnFlowPage.keyboard.press('Enter');585 await returnFlowPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');586 await dismissFirstWorldCard(returnFlowPage);587 await returnFlowPage.keyboard.press('Space');588 await returnFlowPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');589 await importLocalModule(returnFlowPage, secondPath, { install: false });590 await returnFlowPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');591 await returnFlowPage.keyboard.press('Escape');592 await returnFlowPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');593 report('local import Gift Not now returns to Workshop');594 await returnFlowPage.close();595 await returnFlowContext.close();596597 const sharedReturnContext = await browser.newContext();598 const sharedReturnPage = await sharedReturnContext.newPage();599 sharedReturnPage.setDefaultTimeout(timeout);600 await sharedReturnPage.goto(shareUrl, { waitUntil: 'networkidle' });601 await sharedReturnPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');602 await sharedReturnPage.keyboard.press('Escape');603 await sharedReturnPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'title');604 report('shared URL Gift Not now returns to title');605 await sharedReturnPage.close();606 await sharedReturnContext.close();607608 const sharedPage = await context.newPage();609 sharedPage.setDefaultTimeout(timeout);610 const sharedDevUrl = new URL(shareUrl); sharedDevUrl.searchParams.set('dev', '1');611 await sharedPage.goto(sharedDevUrl.toString(), { waitUntil: 'networkidle' });612 await sharedPage.keyboard.press('Escape');613 await sharedPage.locator('#level-title').waitFor();614 await sharedPage.waitForFunction(() => document.body.textContent.includes('Smoke Two'));615 assert.ok(await sharedPage.locator('[data-pack-id="browser-smoke-two"], .pack-choice').count(), 'shared URL should register the shared pack');616 report('share URL opens and rehydrates the selected pack');617 await sharedPage.close();618619 const download = await downloadPack(page);620 const exportedPath = await download.path();621 assert.ok(exportedPath, 'download should have a readable temporary path');622 const exportedSource = await readFile(exportedPath, 'utf8');623 assert.match(exportedSource, /module\.exports/, 'export should be executable CommonJS source');624 report('selected pack downloaded as CommonJS');625626 const reimportContext = await browser.newContext({ acceptDownloads: true });627 const reimportPage = await reimportContext.newPage();628 reimportPage.setDefaultTimeout(timeout);629 await reimportPage.goto(devUrl, { waitUntil: 'networkidle' });630 await openDevProof(reimportPage);631 await reimportPage.locator('#level-title').waitFor();632 const reimportBefore = await packCount(reimportPage);633 await importLocalModule(reimportPage, exportedPath, { install: false });634 await reimportPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift' || /rejected|problem|error/i.test(document.querySelector('#mod-status')?.textContent || ''));635 assert.equal(await reimportPage.locator('.stage-shell').getAttribute('data-scene'), 'gift', `fresh-session import should preview Gift (status: ${await reimportPage.locator('#mod-status').textContent()})`);636 await reimportPage.keyboard.press('Enter');637 await reimportPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');638 await reimportPage.waitForFunction(() => document.body.textContent.includes('Browser Smoke Two'));639 assert.ok(await reimportPage.locator('[data-pack-id="browser-smoke-two"]').count(), 'downloaded CommonJS should register its pack on a fresh session');640 assert.ok(await reimportPage.locator('[data-level-id="browser-smoke-two:smoke-two"]').count(), 're-imported pack should expose its level');641 assert.ok(await packCount(reimportPage) > reimportBefore, 're-import should append one pack in a fresh session');642 report('downloaded CommonJS pack re-imported in a fresh session');643 await reimportPage.close();644 await reimportContext.close();645646 const beforeCollision = await packCount(page);647 await importLocalModule(page, exportedPath, { install: false });648 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');649 await page.screenshot({ path: `${stageScreenshotDir}/gift-local-duplicate.png` });650 await page.keyboard.press('Enter');651 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');652 await page.keyboard.press('Escape');653 await page.waitForFunction(() => ['title', 'index'].includes(document.querySelector('.stage-shell')?.dataset.scene));654 assert.equal(await packCount(page), beforeCollision, 'duplicate re-import must not replace the existing pack');655 report('duplicate local import previews Gift conflict and cancel preserves the pack');656657 await importLocalModule(page, exportedPath, { install: false });658 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');659 await page.keyboard.press('Enter');660 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');661 await page.keyboard.press('Enter');662 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');663 assert.equal(await packCount(page), beforeCollision, 'duplicate Replace should overwrite without adding a pack');664 report('duplicate local import Replace overwrites without increasing pack count');665666 const mechanicsPage = await context.newPage();667 mechanicsPage.setDefaultTimeout(timeout);668 await mechanicsPage.goto(devUrl, { waitUntil: 'networkidle' });669 await openDevProof(mechanicsPage);670 await selectPack(mechanicsPage, 'core');671 const firstCoreLevel = mechanicsPage.locator('[data-level-id^="core:"]').first();672 await firstCoreLevel.click();673 await mechanicsPage.waitForFunction(() => document.body.textContent.includes('Break the rule'));674 const initialState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));675 const initialRuleText = initialState.rules.map(rule => rule.text).join('|');676 for (const key of ['ArrowRight', 'ArrowRight', 'ArrowUp', 'ArrowUp']) await mechanicsPage.keyboard.press(key);677 const editedState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));678 const editedRuleText = editedState.rules.map(rule => rule.text).join('|');679 assert.notEqual(editedRuleText, initialRuleText, 'core word movement should update active rules');680 report('core word movement changes the active rule set');681 await mechanicsPage.keyboard.press('z');682 const undoneState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));683 assert.ok(undoneState.moves < editedState.moves, 'undo should reverse a core word movement');684 await mechanicsPage.keyboard.press('r');685 const resetState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));686 assert.equal(resetState.moves, 0, 'reset should restore the core level');687 report('core rule edit, undo, and reset remain playable');688 await mechanicsPage.close();689690 await importLocalModule(page, invalidPath, { install: false });691 await assertFeedback(page, /rejected|manifest|invalid|error/i, 'invalid local module reports actionable feedback');692693 await selectPack(page, 'core');694 await page.waitForFunction(() => document.body.textContent.includes('Miau') || document.body.textContent.includes('Garden'));695 assert.ok(await page.locator('#level-list [data-level], #level-list .level-choice').count() >= 3, 'built-in levels remain available after imports');696 report('built-in campaign remains available after sharing flow');697698 await importLocalModule(page, resolve(projectRoot, 'mods/example.cjs'));699 await page.waitForFunction(() => document.body.textContent.includes('Moon Cat'));700 report('existing example mod remains importable');701702 const navigationPage = await context.newPage();703 navigationPage.setDefaultTimeout(timeout);704 await navigationPage.goto(devUrl, { waitUntil: 'networkidle' });705 await openDevProof(navigationPage);706 await navigationPage.locator('#level-title').waitFor();707 await importLocalModule(navigationPage, wordLabExamplePath);708 await navigationPage.waitForFunction(() => document.body.textContent.includes('Word Lab'));709 await selectPack(navigationPage, 'word-lab');710 await navigationPage.waitForFunction(() => document.querySelector('[data-pack-id="word-lab"]')?.classList.contains('active'));711 const wordLabLevels = navigationPage.locator('[data-level-id^="word-lab:"]');712 assert.equal(await wordLabLevels.count(), 3, 'Word Lab should expose multiple levels for pack navigation');713 const firstWordLabLevel = await navigationPage.locator('#level-list .level-choice.active').getAttribute('data-level-id');714 await navigationPage.locator('#next-level').click();715 await navigationPage.waitForFunction(first => document.querySelector('#level-list .level-choice.active')?.dataset.levelId !== first, firstWordLabLevel);716 const secondWordLabLevel = await navigationPage.locator('#level-list .level-choice.active').getAttribute('data-level-id');717 assert.match(secondWordLabLevel, /^word-lab:/, 'next arrow should stay within the active pack');718 assert.equal(await currentPackId(navigationPage), 'word-lab', 'next arrow should preserve the active pack');719 await navigationPage.locator('#prev-level').click();720 await navigationPage.waitForFunction(first => document.querySelector('#level-list .level-choice.active')?.dataset.levelId === first, firstWordLabLevel);721 assert.equal(await currentPackId(navigationPage), 'word-lab', 'previous arrow should preserve the active pack');722 report('level arrows navigate within the selected pack');723 await triggerShare(navigationPage);724 const wordLabShareUrl = await readShareUrl(navigationPage);725 const wordLabGiftContext = await browser.newContext();726 const wordLabGiftPage = await wordLabGiftContext.newPage();727 wordLabGiftPage.setDefaultTimeout(timeout);728 await wordLabGiftPage.setViewportSize({ width: 1280, height: 800 });729 await wordLabGiftPage.goto(wordLabShareUrl, { waitUntil: 'networkidle' });730 await wordLabGiftPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');731 await wordLabGiftPage.waitForTimeout(700);732 await wordLabGiftPage.screenshot({ path: `${stageScreenshotDir}/gift-word-lab-desktop.png` });733 await assertGiftActionsBelowPreview(wordLabGiftPage, 'desktop Word Lab Gift');734 const wordLabGiftMobile = await wordLabGiftContext.newPage();735 wordLabGiftMobile.setDefaultTimeout(timeout);736 await wordLabGiftMobile.setViewportSize({ width: 390, height: 844 });737 await wordLabGiftMobile.goto(wordLabShareUrl, { waitUntil: 'networkidle' });738 await wordLabGiftMobile.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');739 await wordLabGiftMobile.waitForTimeout(700);740 await wordLabGiftMobile.screenshot({ path: `${stageScreenshotDir}/gift-word-lab-mobile.png` });741 await assertGiftActionsBelowPreview(wordLabGiftMobile, 'mobile Word Lab Gift');742 report('Word Lab shared Gift captured at desktop and mobile sizes');743 await wordLabGiftPage.close();744 await wordLabGiftMobile.close();745 await wordLabGiftContext.close();746 await navigationPage.close();747748 const singlePackContext = await browser.newContext();749 const singlePackPage = await singlePackContext.newPage();750 singlePackPage.setDefaultTimeout(timeout);751 await singlePackPage.goto(devUrl, { waitUntil: 'networkidle' });752 await openDevProof(singlePackPage);753 await singlePackPage.locator('#level-title').waitFor();754 await importLocalModule(singlePackPage, firstPath);755 await singlePackPage.waitForFunction(() => document.body.textContent.includes('Browser Smoke One'));756 await selectPack(singlePackPage, 'browser-smoke-one');757 await singlePackPage.keyboard.press('Enter');758 await singlePackPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');759 for (const key of ['ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowRight']) await singlePackPage.keyboard.press(key);760 await singlePackPage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');761 assert.equal(await singlePackPage.locator('#continue-btn').isDisabled(), true, 'Continue should be disabled after winning the final level in a pack');762 report('Continue is disabled after completing a single-level pack');763 await singlePackPage.close();764 await singlePackContext.close();765766 const continueContext = await browser.newContext();767 const continuePage = await continueContext.newPage();768 continuePage.setDefaultTimeout(timeout);769 await continuePage.goto(devUrl, { waitUntil: 'networkidle' });770 await openDevProof(continuePage);771 for (const key of ['ArrowRight', 'ArrowRight', 'ArrowUp', 'ArrowUp', ...Array(4).fill('ArrowDown'), ...Array(7).fill('ArrowRight')]) await continuePage.keyboard.press(key);772 await continuePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');773 await continuePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'win');774 const firstProof = JSON.parse(await continuePage.evaluate(() => window.render_game_to_text()));775 await continuePage.waitForFunction(() => {776 const button = document.querySelector('#continue-btn');777 return button && !button.disabled;778 });779 await continuePage.waitForTimeout(700);780 await continuePage.locator('#continue-btn').click();781 await continuePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');782 const secondProof = JSON.parse(await continuePage.evaluate(() => window.render_game_to_text()));783 assert.notEqual(secondProof.level, firstProof.level, 'Continue should load the next proof');784 assert.equal(secondProof.mode, 'play', 'Continue should reset the next proof into play mode');785 const secondProofMiau = secondProof.objects.find(object => object.kind === 'object' && object.noun === 'MIAU');786 assert.deepEqual({ x: secondProofMiau?.x, y: secondProofMiau?.y }, { x: 1, y: 5 }, 'Continue proof 02 first frame should place MIAU at its authored coordinate');787 await continuePage.screenshot({ path: `${stageScreenshotDir}/continue-proof-02.png` });788 await continuePage.keyboard.press('ArrowRight');789 await continuePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves > 0);790 report('Continue enters proof 02 and accepts the first movement input');791 await continuePage.close();792 await continueContext.close();793794 const proofThreeContext = await browser.newContext();795 const proofThreePage = await proofThreeContext.newPage();796 proofThreePage.setDefaultTimeout(timeout);797 await proofThreePage.goto(devUrl, { waitUntil: 'networkidle' });798 await openDevProof(proofThreePage);799 const proofThreeChoice = proofThreePage.locator('#level-list [data-level-id*="two-things-can-win"]');800 assert.equal(await proofThreeChoice.count(), 1, 'proof 03 selector should identify exactly one level');801 assert.equal(await proofThreeChoice.getAttribute('data-level-id'), 'core:two-things-can-win', 'proof 03 should be the core two-things level');802 await proofThreeChoice.click();803 const proofThreeInitial = JSON.parse(await proofThreePage.evaluate(() => window.render_game_to_text()));804 assert.equal(proofThreeInitial.mode, 'play', 'proof 03 should start unwon');805 assert.ok(proofThreeInitial.rules.some(rule => /ROCK.*WIN|FLAG.*WIN/.test(rule.text)), 'proof 03 should expose the multi-object WIN rule');806 await proofThreePage.keyboard.press('ArrowRight');807 await proofThreePage.waitForTimeout(100);808 const proofThreeAfterMove = JSON.parse(await proofThreePage.evaluate(() => window.render_game_to_text()));809 assert.equal(proofThreeAfterMove.mode, 'play', 'proof 03 should remain unwon after the first movement');810 await proofThreePage.screenshot({ path: `${stageScreenshotDir}/proof-03-initial.png` });811 report('proof 03 starts unwon with the multi-object WIN rule');812 await proofThreeChoice.click();813 await proofThreePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');814 const selfWinMoves = 'RRRRRDRRRRRDLLLLLLLLLLLUUURRRRUULLLLDRRDRUU';815 for (const key of selfWinMoves) await proofThreePage.keyboard.press(`Arrow${({ R: 'Right', L: 'Left', U: 'Up', D: 'Down' })[key]}`);816 await proofThreePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');817 await proofThreePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'win');818 const proofThreeWin = JSON.parse(await proofThreePage.evaluate(() => window.render_game_to_text()));819 assert.ok(proofThreeWin.rules.some(rule => /MIAU.*WIN/.test(rule.text)), 'proof 03 self-win should finish with MIAU IS WIN');820 await proofThreePage.screenshot({ path: `${stageScreenshotDir}/proof-03-self-win.png` });821 report('proof 03 real sequence reaches self-win and opens the Win card');822 await proofThreePage.close();823 await proofThreeContext.close();824825 const selfWinContext = await browser.newContext();826 const selfWinPage = await selfWinContext.newPage();827 selfWinPage.setDefaultTimeout(timeout);828 await selfWinPage.goto(devUrl, { waitUntil: 'networkidle' });829 await openDevProof(selfWinPage);830 await importLocalModule(selfWinPage, selfWinPath);831 await selfWinPage.waitForFunction(() => document.body.textContent.includes('Browser Self Win'));832 await selectPack(selfWinPage, 'browser-self-win');833 await selfWinPage.keyboard.press('Enter');834 await selfWinPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');835 const selfWinInitial = JSON.parse(await selfWinPage.evaluate(() => window.render_game_to_text()));836 assert.equal(selfWinInitial.mode, 'won', 'self-win fixture should settle as won on its first frame');837 await selfWinPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'win');838 assert.equal(JSON.parse(await selfWinPage.evaluate(() => window.render_game_to_text())).mode, 'won', 'MIAU IS YOU plus MIAU IS WIN should reach the Win card');839 await selfWinPage.screenshot({ path: `${stageScreenshotDir}/self-win-card.png` });840 report('self-win fixture reaches the Win card after MIAU IS YOU and MIAU IS WIN settle');841 await selfWinPage.close();842 await selfWinContext.close();843844 const mobilePage = await context.newPage();845 mobilePage.setDefaultTimeout(timeout);846 mobilePage.on('console', message => { if (message.type() === 'error') consoleErrors.push(`mobile: ${message.text()}`); });847 await mobilePage.setViewportSize({ width: 390, height: 844 });848 await mobilePage.goto(devUrl, { waitUntil: 'networkidle' });849 await openDevProof(mobilePage);850 await mobilePage.locator('#level-title').waitFor();851 const mobileInitial = JSON.parse(await mobilePage.evaluate(() => window.render_game_to_text()));852 await mobilePage.locator('[data-dir="right"]').click();853 const mobileTapped = JSON.parse(await mobilePage.evaluate(() => window.render_game_to_text()));854 assert.ok(mobileTapped.moves > mobileInitial.moves, 'mobile D-pad tap should advance the puzzle');855 report('mobile D-pad movement updates the rendered game state');856 await mobilePage.keyboard.press('ArrowLeft');857 const mobileKeyboard = JSON.parse(await mobilePage.evaluate(() => window.render_game_to_text()));858 assert.ok(mobileKeyboard.moves > mobileTapped.moves, 'keyboard movement should remain available at mobile width');859 report('keyboard movement remains available at mobile width');860 await mobilePage.close();861862 const accessibilityContext = await browser.newContext({863 viewport: { width: 390, height: 844 },864 reducedMotion: 'reduce',865 });866 await accessibilityContext.route('**/audio/**', route => route.fulfill({ status: 404, body: 'audio unavailable' }));867 const accessibilityPage = await accessibilityContext.newPage();868 accessibilityPage.setDefaultTimeout(timeout);869 const accessibilityErrors = [];870 accessibilityPage.on('console', message => {871 if (message.type() === 'error' && !/responded with a status of 404/i.test(message.text())) accessibilityErrors.push(message.text());872 });873 accessibilityPage.on('pageerror', error => accessibilityErrors.push(error.message));874 await accessibilityPage.goto(playerUrl, { waitUntil: 'networkidle' });875 assert.equal(await accessibilityPage.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches), true, 'reduced motion emulation should be active');876 assert.equal(await accessibilityPage.locator('.stage-shell').getAttribute('data-scene'), 'title', 'mobile player should start at title');877 await accessibilityPage.screenshot({ path: `${stageScreenshotDir}/mobile-title.png` });878 await accessibilityPage.keyboard.press('ArrowRight');879 assert.equal(await accessibilityPage.locator('.stage-shell').getAttribute('data-scene'), 'title', 'title should accept a single movement key without leaving title');880 await accessibilityPage.keyboard.press('Enter');881 await accessibilityPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');882 await dismissFirstWorldCard(accessibilityPage);883 await accessibilityPage.screenshot({ path: `${stageScreenshotDir}/mobile-index.png` });884 const indexCanvas = accessibilityPage.locator('.stage-canvas');885 const indexBox = await indexCanvas.boundingBox();886 assert.ok(indexBox, 'mobile INDEX should expose a tappable canvas');887 // The compact mobile footer is the menu/workshop button at the lower-right888 // of the canvas; click its center rather than the adjacent empty margin.889 await accessibilityPage.mouse.click(indexBox.x + 320, indexBox.y + 826);890 await accessibilityPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'pause');891 await accessibilityPage.keyboard.press('Escape');892 await accessibilityPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');893 report('mobile INDEX menu footer pointer opens Pause and Escape returns');894 await accessibilityPage.mouse.click(indexBox.x + 64, indexBox.y + 440);895 await accessibilityPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');896 report('mobile reduced-motion title key and INDEX touch entry work without audio');897 assert.deepEqual(accessibilityErrors, [], 'missing audio should not produce browser errors');898 await accessibilityPage.close();899 await accessibilityContext.close();900901 if (consoleErrors.length) throw new Error(`browser console errors: ${consoleErrors.join('; ')}`);902 report('no browser console errors');903} finally {904 await browser.close();905 await rm(tempDir, { recursive: true, force: true });906}