123/** Narrow browser fixtures for automaton blocking, cues, and property transfer. */45import assert from 'node:assert/strict';6import { mkdir } from 'node:fs/promises';7import { resolve } from 'node:path';8import { fileURLToPath } from 'node:url';910const projectRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));11const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || 'playwright';12const playwrightModule = configuredPlaywright.startsWith('/')13 ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`14 : configuredPlaywright;15const importedPlaywright = await import(playwrightModule);16const { chromium } = importedPlaywright.default || importedPlaywright;17const { encodeSharedPack } = await import(resolve(projectRoot, 'src/share.js'));18const baseUrl = process.env.MIAU_URL19 || process.argv.find(argument => argument.startsWith('--url='))?.slice(6)20 || 'http://127.0.0.1:5173/';21const screenshots = '/private/tmp/miau-stage-qa';22const timeout = Number(process.env.MIAU_BROWSER_TIMEOUT || 10_000);2324const fixture = {25 manifest: { id: 'automata-edge-check', name: 'Automata Edge Check', version: '1.0.0', engine: 1 },26 vocabulary: ['MIAU', 'MOUSE', 'BELL'],27 palette: { MIAU: '🐈', MOUSE: '🐭', BELL: '🔔' },28 levels: [{29 id: 'block-and-transfer', title: 'Blocked beat and transfer', hint: 'The carrier changes when TICK moves.', width: 8, height: 5,30 objects: [31 { kind: 'word', text: 'BELL', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 },32 { kind: 'word', text: 'MIAU', x: 0, y: 4 }, { kind: 'word', text: 'IS', x: 1, y: 4 }, { kind: 'word', text: 'YOU', x: 2, y: 4 },33 { kind: 'word', text: 'MOUSE', x: 0, y: 1 }, { kind: 'word', text: 'IS', x: 1, y: 1 }, { id: 'tick-word', kind: 'word', text: 'TICK', x: 2, y: 1 },34 { id: 'cat', kind: 'object', noun: 'MIAU', x: 2, y: 3 }, { id: 'mouse', kind: 'object', noun: 'MOUSE', x: 0, y: 3 }, { id: 'bell', kind: 'object', noun: 'BELL', x: 6, y: 3 },35 ],36 automata: [{ id: 'tick-clock', property: 'TICK', pattern: [37 { action: 'move', dx: -1, dy: 0, cue: 'BLOCK', sound: 'tick' },38 { action: 'wait', cue: 'NEXT', sound: 'tick' },39 ] }],40 }],41};4243const sharedUrl = new URL(baseUrl);44sharedUrl.search = '';45sharedUrl.hash = `pack=${encodeSharedPack(fixture)}`;46await mkdir(screenshots, { recursive: true });47const browser = await chromium.launch({ headless: !process.argv.includes('--headed') });48const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });49const page = await context.newPage();50page.setDefaultTimeout(timeout);51const consoleErrors = [];52page.on('console', message => { if (message.type() === 'error' && !/favicon\.ico/i.test(message.text())) consoleErrors.push(message.text()); });53page.on('pageerror', error => consoleErrors.push(error.message));5455async function scene(expected) {56 await page.waitForFunction(value => document.querySelector('.stage-shell')?.dataset.scene === value, expected, { timeout });57}58async function state() { return page.evaluate(() => JSON.parse(window.render_game_to_text())); }59async function capture(name) { await page.waitForTimeout(500); await page.screenshot({ path: `${screenshots}/${name}` }); }60function check(label) { process.stdout.write(`✓ ${label}\n`); }6162try {63 await page.goto(sharedUrl.toString(), { waitUntil: 'networkidle' });64 await scene('gift');65 await page.keyboard.press('Enter');66 await scene('index');67 // Wait for the index opening beat to settle before selecting its first node.68 await page.waitForTimeout(700);69 await page.keyboard.press('Enter');70 // The index presents a world card before entering the selected proof.71 await page.waitForTimeout(350);72 await page.keyboard.press('Enter');73 await scene('proof');7475 let current = await state();76 const automaton = () => current.automata.find(system => system.id === 'tick-clock');77 assert.equal(automaton().active, true);78 assert.deepEqual(automaton().carriers, [{ noun: 'MOUSE', instanceIds: ['mouse'] }]);79 assert.deepEqual(automaton().next, { action: 'move', dx: -1, dy: 0, cue: 'BLOCK', sound: 'tick' });80 await capture('automata-edge-initial-mobile.png');81 check('initial active carrier exposes current move and cue');8283 // MIAU moves up; MOUSE is at the left edge, so the active move is blocked.84 await page.keyboard.press('ArrowUp');85 await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 1, { timeout });86 current = await state();87 const blockedCarrier = current.objects.find(object => object.id === 'mouse');88 assert.deepEqual({ x: blockedCarrier.x, y: blockedCarrier.y }, { x: 0, y: 3 });89 assert.equal(automaton().phase, 1);90 assert.deepEqual(automaton().next, { action: 'wait', cue: 'NEXT', sound: 'tick' });91 assert.match(await page.locator('.stage-sr').textContent(), /MOUSE IS TICK/);92 await capture('automata-edge-blocked-mobile.png');93 check('blocked automaton action preserves position, advances phase, and exposes next cue');9495 // The second up pushes TICK from MOUSE IS TICK into BELL IS TICK.96 await page.keyboard.press('ArrowUp');97 await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).moves === 2, { timeout });98 current = await state();99 assert.deepEqual(automaton().carriers, [{ noun: 'BELL', instanceIds: ['bell'] }]);100 assert.equal(automaton().active, true);101 assert.equal(current.objects.find(object => object.id === 'tick-word').y, 0);102 assert.equal(current.objects.find(object => object.id === 'mouse').x, 0);103 assert.deepEqual(automaton().next, { action: 'move', dx: -1, dy: 0, cue: 'BLOCK', sound: 'tick' });104 assert.match(await page.locator('.stage-sr').textContent(), /BELL IS TICK/);105 await capture('automata-edge-transfer-mobile.png');106 check('property transfer moves the active carrier from MOUSE to BELL');107108 assert.deepEqual(consoleErrors, [], 'automata edge fixture should have no console errors');109 process.stdout.write(`screenshots=${screenshots}/automata-edge-initial-mobile.png,${screenshots}/automata-edge-blocked-mobile.png,${screenshots}/automata-edge-transfer-mobile.png\n`);110} finally {111 await context.close();112 await browser.close();113}