123/**4 * Focused visual regression check for death followed by immediate Undo.5 *6 * Usage:7 * PLAYWRIGHT_MODULE=/path/to/playwright node tests/browser/undo-death.mjs \8 * --url=http://127.0.0.1:5173/9 */1011import assert from 'node:assert/strict';12import { mkdir, readFile } from 'node:fs/promises';13import { resolve } from 'node:path';14import { fileURLToPath } from 'node:url';15import { loadMod } from '../../src/mods.js';16import { encodeSharedPack } from '../../src/share.js';1718const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || '/Users/dami/.codex/skills/develop-web-game/node_modules/playwright';19const playwrightModule = configuredPlaywright.startsWith('/')20 ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`21 : configuredPlaywright;22const importedPlaywright = await import(playwrightModule);23const { chromium } = importedPlaywright.default || importedPlaywright;24const projectRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));25const configuredUrl = process.argv.find(argument => argument.startsWith('--url='))?.slice(6)26 || process.env.MIAU_URL || 'http://127.0.0.1:5173/';27const screenshotDir = '/private/tmp/miau-stage-qa';28const timeout = Number(process.env.MIAU_BROWSER_TIMEOUT || 8_000);29await mkdir(screenshotDir, { recursive: true });3031const source = await readFile(resolve(projectRoot, 'mods/garden-basics.cjs'), 'utf8');32const authored = loadMod(source, { filename: 'garden-basics.cjs' });33const pond = authored.levels.find(level => level.id === 'garden-pond');34assert.ok(pond, 'garden-basics must contain Around the Pond');35const oneLevelPack = {36 manifest: { ...authored.manifest, id: 'browser-pond-undo', name: 'Pond Undo Check' },37 vocabulary: authored.vocabulary,38 palette: authored.palette,39 levels: [pond],40};41const sharedUrl = new URL(configuredUrl);42sharedUrl.search = '';43sharedUrl.hash = `pack=${encodeSharedPack(oneLevelPack)}`;4445const browser = await chromium.launch({ headless: !process.argv.includes('--headed') });4647async function openPond() {48 const context = await browser.newContext();49 const page = await context.newPage();50 page.setDefaultTimeout(timeout);51 await page.setViewportSize({ width: 390, height: 844 });52 const consoleErrors = [];53 page.on('console', message => {54 if (message.type() === 'error' && !/favicon\.ico/i.test(message.text())) consoleErrors.push(message.text());55 });56 await page.goto(sharedUrl.toString(), { waitUntil: 'networkidle' });57 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');58 await page.keyboard.press('Enter');59 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');60 await page.keyboard.press('Enter');61 await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');62 return { context, page, consoleErrors };63}6465// The renderer uses a 34px cell and a board origin of (42, 120) at 390x844.66// Read the actual canvas pixels so this catches a stale juice position even67// when render_game_to_text reports the restored engine state correctly.68async function brightPixelsInCell(page, column, row, grid) {69 return page.evaluate(({ column: x, row: y, gridWidth, gridHeight }) => {70 const canvas = document.querySelector('.stage-canvas');71 const context = canvas?.getContext('2d');72 if (!canvas || !context) return 0;73 const rect = canvas.getBoundingClientRect();74 const cell = Math.floor(Math.min((innerWidth - 80) / gridWidth, (innerHeight - 320) / gridHeight));75 const originX = Math.floor((innerWidth - gridWidth * cell) / 2);76 const originY = innerWidth <= 760 ? 120 : Math.max(118, Math.floor((innerHeight - gridHeight * cell) / 2 - 30));77 const scale = canvas.width / (rect.width || innerWidth);78 const left = Math.floor((originX + x * cell + cell * 0.1) * scale);79 const top = Math.floor((originY + y * cell + cell * 0.08) * scale);80 const width = Math.max(1, Math.floor(cell * 0.8 * scale));81 const height = Math.max(1, Math.floor(cell * 0.82 * scale));82 const pixels = context.getImageData(left, top, width, height).data;83 let count = 0;84 for (let index = 0; index < pixels.length; index += 4) {85 const red = pixels[index], green = pixels[index + 1], blue = pixels[index + 2];86 if (red + green + blue > 220 && Math.max(red, green, blue) - Math.min(red, green, blue) > 12) count++;87 }88 return count;89 }, { column, row, gridWidth: grid.width, gridHeight: grid.height });90}9192async function exercise(label, undo) {93 const { context, page, consoleErrors } = await openPond();94 const initial = JSON.parse(await page.evaluate(() => window.render_game_to_text()));95 const initialVisual = await brightPixelsInCell(page, 3, 3, initial.grid);96 await page.screenshot({ path: `${screenshotDir}/undo-death-${label}-initial-mobile.png` });97 assert.equal(initial.level, 'Around the Pond');98 assert.deepEqual(99 initial.objects.find(object => object.id === 'garden-pond-miau') && {100 x: initial.objects.find(object => object.id === 'garden-pond-miau').x,101 y: initial.objects.find(object => object.id === 'garden-pond-miau').y,102 },103 { x: 1, y: 3 },104 );105106 await page.keyboard.press('ArrowRight');107 await page.keyboard.press('ArrowRight');108 await page.keyboard.press('ArrowRight');109 const dead = JSON.parse(await page.evaluate(() => window.render_game_to_text()));110 const deadVisual = await brightPixelsInCell(page, 3, 3, dead.grid);111 await page.screenshot({ path: `${screenshotDir}/undo-death-${label}-after-death-mobile.png` });112 assert.equal(dead.moves, 3);113 assert.equal(dead.objects.some(object => object.id === 'garden-pond-miau'), false);114115 await undo(page);116 await page.waitForTimeout(100);117 const undone = JSON.parse(await page.evaluate(() => window.render_game_to_text()));118 const restoredVisual = await brightPixelsInCell(page, 3, 3, undone.grid);119 await page.screenshot({ path: `${screenshotDir}/undo-death-${label}-after-undo-mobile.png` });120 const restoredMiau = undone.objects.find(object => object.id === 'garden-pond-miau');121 const restoredWater = undone.objects.find(object => object.id === 'garden-pond-water');122 assert.equal(undone.moves, 2);123 assert.deepEqual({ x: restoredMiau?.x, y: restoredMiau?.y }, { x: 3, y: 3 });124 assert.deepEqual({ x: restoredWater?.x, y: restoredWater?.y }, { x: 4, y: 3 });125 assert.ok(restoredVisual > deadVisual + 5, `restored MIAU pixels should return: death=${deadVisual}, undo=${restoredVisual}`);126 assert.deepEqual(consoleErrors, [], `console errors during ${label}`);127 await context.close();128 return { label, initialMoves: initial.moves, deathMoves: dead.moves, undoMoves: undone.moves, initialVisual, deadVisual, restoredVisual };129}130131const immediate = await exercise('immediate-click', page => page.evaluate(() => document.querySelector('#undo-btn')?.click()));132const delayed = await exercise('delayed-key', async page => {133 await page.waitForTimeout(280);134 await page.keyboard.press('z');135});136console.log(JSON.stringify({ immediate, delayed }));137await browser.close();