- commit
- fb6b325f117a66a690fcee5509e7ca5da6cf3d3a
- parent
- 2cc00719f9e49d43da6acb1e09b56b1c3bc2f719
- author
- Dami <dami.etoile@gmail.com>
| A | tests/ | 137 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,137 @@
1+2+3+/**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+ */10+11+import assert from 'node:assert/strict';12+import { mkdir, readFile } from 'node:fs/promises';13+import { resolve } from 'node:path';14+import { fileURLToPath } from 'node:url';15+import { loadMod } from '../../src/mods.js';16+import { encodeSharedPack } from '../../src/share.js';17+18+const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || '/Users/dami/.codex/skills/develop-web-game/node_modules/playwright';19+const playwrightModule = configuredPlaywright.startsWith('/')20+ ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`21+ : configuredPlaywright;22+const importedPlaywright = await import(playwrightModule);23+const { chromium } = importedPlaywright.default || importedPlaywright;24+const projectRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));25+const configuredUrl = process.argv.find(argument => argument.startsWith('--url='))?.slice(6)26+ || process.env.MIAU_URL || 'http://127.0.0.1:5173/';27+const screenshotDir = '/private/tmp/miau-stage-qa';28+const timeout = Number(process.env.MIAU_BROWSER_TIMEOUT || 8_000);29+await mkdir(screenshotDir, { recursive: true });30+31+const source = await readFile(resolve(projectRoot, 'mods/garden-basics.cjs'), 'utf8');32+const authored = loadMod(source, { filename: 'garden-basics.cjs' });33+const pond = authored.levels.find(level => level.id === 'garden-pond');34+assert.ok(pond, 'garden-basics must contain Around the Pond');35+const 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+};41+const sharedUrl = new URL(configuredUrl);42+sharedUrl.search = '';43+sharedUrl.hash = `pack=${encodeSharedPack(oneLevelPack)}`;44+45+const browser = await chromium.launch({ headless: !process.argv.includes('--headed') });46+47+async 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+}64+65+// 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.68+async 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+}91+92+async 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+ );105+106+ 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);114+115+ 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+}130+131+const immediate = await exercise('immediate-click', page => page.evaluate(() => document.querySelector('#undo-btn')?.click()));132+const delayed = await exercise('delayed-key', async page => {133+ await page.waitForTimeout(280);134+ await page.keyboard.press('z');135+});136+console.log(JSON.stringify({ immediate, delayed }));137+await browser.close();