- commit
- 4d13d1e890bd916401c6ebf087555a1028196158
- parent
- 89dd89f6755a1d2cf013aba653711de62ae09b94
- author
- Dami <dami.etoile@gmail.com>
| A | tests/ | 565 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,565 @@
1+2+3+/**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+ */17+18+import assert from 'node:assert/strict';19+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';20+import { tmpdir } from 'node:os';21+import { dirname, resolve } from 'node:path';22+import { fileURLToPath } from 'node:url';23+24+const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');25+const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || 'playwright';26+const playwrightModule = configuredPlaywright.startsWith('/')27+ ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`28+ : configuredPlaywright;29+30+let playwright;31+try {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+}38+39+const { chromium } = playwright.default || playwright;40+const url = process.env.MIAU_URL || process.argv.find(argument => argument.startsWith('--url='))?.slice(6) || 'http://127.0.0.1:5173/';41+const playerUrl = (() => { const value = new URL(url); value.searchParams.delete('dev'); value.hash = ''; return value.toString(); })();42+const devUrl = (() => { const value = new URL(url); value.searchParams.set('dev', '1'); value.hash = ''; return value.toString(); })();43+const headed = process.argv.includes('--headed');44+const timeout = Number(process.env.MIAU_BROWSER_TIMEOUT || 8_000);45+const stageScreenshotDir = '/private/tmp/miau-stage-qa';46+47+const fixtureOne = `48+module.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+`;62+63+const fixtureTwo = `64+module.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+`;78+79+const invalidFixture = `module.exports = { manifest: { name: 'Broken pack' }, levels: [] };\n`;80+81+function report(label) {82+ process.stdout.write(`✓ ${label}\n`);83+}84+85+async function firstVisible(page, locators, label) {86+ for (const locator of locators) {87+ const candidate = typeof locator === 'function' ? locator(page) : page.locator(locator);88+ if (await candidate.count() && await candidate.first().isVisible().catch(() => false)) return candidate.first();89+ }90+ throw new Error(`Could not find visible ${label}; tried: ${locators.map(String).join(', ')}`);91+}92+93+async function maybeVisible(page, locators) {94+ for (const locator of locators) {95+ const candidate = typeof locator === 'function' ? locator(page) : page.locator(locator);96+ if (await candidate.count() && await candidate.first().isVisible().catch(() => false)) return candidate.first();97+ }98+ return null;99+}100+101+async function firstPresent(page, locators, label) {102+ for (const locator of locators) {103+ const candidate = typeof locator === 'function' ? locator(page) : page.locator(locator);104+ if (await candidate.count()) return candidate.first();105+ }106+ throw new Error(`Could not find ${label}; tried: ${locators.map(String).join(', ')}`);107+}108+109+async function packChoices(page) {110+ const candidates = [111+ page.locator('[data-pack-id]'),112+ page.locator('.pack-choice'),113+ page.locator('[role="listitem"][data-pack]'),114+ ];115+ for (const candidate of candidates) if (await candidate.count()) return candidate;116+ return page.locator('#level-list [data-pack-id], #level-list .pack-choice');117+}118+119+async function packCount(page) {120+ const choices = await packChoices(page);121+ return choices.count();122+}123+124+async function currentPackId(page) {125+ const active = await maybeVisible(page, [126+ '[data-active-pack-id]',127+ '[data-pack-id][aria-current="true"]',128+ '[data-pack-id].active',129+ '.pack-choice[aria-selected="true"]',130+ ]);131+ return active ? (await active.getAttribute('data-active-pack-id')) || (await active.getAttribute('data-pack-id')) : null;132+}133+134+async function selectPack(page, idOrName) {135+ const exact = page.locator(`[data-pack-id="${idOrName}"]`);136+ if (await exact.count()) {137+ await exact.first().click();138+ return;139+ }140+ const named = page.getByText(idOrName, { exact: true });141+ if (await named.count()) {142+ await named.first().click();143+ return;144+ }145+ throw new Error(`Could not select pack ${idOrName}`);146+}147+148+async function importLocalModule(page, filePath, { install = true } = {}) {149+ const input = await firstPresent(page, [150+ '#mod-input',151+ 'input[type="file"][accept*="cjs"]',152+ 'input[type="file"][accept*="js"]',153+ 'input[type="file"]',154+ ], 'local CommonJS import input');155+ await input.setInputFiles(filePath);156+ await page.waitForTimeout(150);157+ if (install) {158+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');159+ await page.keyboard.press('Enter');160+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');161+ }162+}163+164+async function importSharedUrl(page, value) {165+ const input = await firstVisible(page, [166+ '#shared-url-input',167+ '[data-shared-url-input]',168+ 'input[type="url"]',169+ 'textarea[data-share-url]',170+ ], 'shared URL input');171+ await input.fill(value);172+ const submit = await firstVisible(page, [173+ '#import-shared-url',174+ '[data-import-shared-url]',175+ page => page.getByRole('button', { name: /import\s+(shared\s+)?url|open\s+shared/i }),176+ ], 'shared URL import action');177+ await submit.click();178+ await page.waitForTimeout(200);179+}180+181+async function readShareUrl(page) {182+ const field = await firstVisible(page, [183+ '#share-url-input',184+ '[data-share-url]',185+ 'input[readonly][type="url"]',186+ 'textarea[readonly]',187+ ], 'generated share URL field');188+ const value = await field.inputValue();189+ assert.match(value, /^https?:\/\//, 'share URL should be an absolute URL');190+ assert.match(value, /(?:[?#].*miau\d?\.|[?#].*(?:pack|share))/i, 'share URL should carry a Miau share payload');191+ return value;192+}193+194+async function triggerShare(page) {195+ const action = await firstVisible(page, [196+ '#copy-share',197+ '#share-pack',198+ '[data-share-pack]',199+ '[data-share]',200+ page => page.getByRole('button', { name: /^share(?:\s+pack)?$/i }),201+ page => page.getByRole('button', { name: /share/i }),202+ ], 'pack share action');203+ await action.click();204+ await page.waitForFunction(() => document.querySelector('#share-url-input, [data-share-url]')?.value?.length > 0).catch(() => page.waitForTimeout(150));205+}206+207+async function downloadPack(page) {208+ const action = await firstVisible(page, [209+ '#export-pack',210+ '[data-export-pack]',211+ '[data-download-pack]',212+ page => page.getByRole('button', { name: /export|download/i }),213+ ], 'pack export action');214+ const downloadPromise = page.waitForEvent('download');215+ await action.click();216+ const download = await downloadPromise;217+ assert.match(download.suggestedFilename(), /\.(?:cjs|js)$/i, 'export must be a CommonJS file');218+ return download;219+}220+221+async function assertFeedback(page, pattern, label) {222+ const statuses = page.locator('[role="status"], [aria-live="polite"], [data-pack-status]');223+ await page.waitForFunction(({ source, statuses: statusCount }) => {224+ const nodes = Array.from(document.querySelectorAll('[role="status"], [aria-live="polite"], [data-pack-status]')).slice(0, statusCount);225+ return nodes.some(node => new RegExp(source, 'i').test(node.textContent || ''));226+ }, { source: pattern.source, statuses: await statuses.count() });227+ report(label);228+}229+230+async function openDevProof(page) {231+ await page.keyboard.press('Enter');232+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');233+ await page.keyboard.press('Enter');234+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');235+}236+237+const tempDir = await mkdtemp(`${tmpdir()}/miau-share-flow-`);238+await mkdir(stageScreenshotDir, { recursive: true });239+const firstPath = resolve(tempDir, 'browser-smoke-one.cjs');240+const secondPath = resolve(tempDir, 'browser-smoke-two.cjs');241+const invalidPath = resolve(tempDir, 'browser-smoke-invalid.cjs');242+await writeFile(firstPath, fixtureOne);243+await writeFile(secondPath, fixtureTwo);244+await writeFile(invalidPath, invalidFixture);245+246+const browser = await chromium.launch({ headless: !headed });247+const context = await browser.newContext({ acceptDownloads: true });248+const page = await context.newPage();249+page.setDefaultTimeout(timeout);250+const consoleErrors = [];251+page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()); });252+253+try {254+ const stagePage = await context.newPage();255+ stagePage.setDefaultTimeout(timeout);256+ const stageErrors = [];257+ stagePage.on('console', message => { if (message.type() === 'error') stageErrors.push(message.text()); });258+ stagePage.on('pageerror', error => stageErrors.push(error.message));259+ await stagePage.goto(playerUrl, { waitUntil: 'networkidle' });260+ await stagePage.waitForFunction(() => document.body.classList.contains('stage-player'));261+ await stagePage.screenshot({ path: `${stageScreenshotDir}/title.png` });262+ assert.equal(await stagePage.locator('body').evaluate(node => node.classList.contains('stage-player')), true, 'default route should enter stage player mode');263+ assert.equal(await stagePage.locator('.stage-canvas').count(), 1, 'default route should render one stage canvas');264+ assert.equal(await stagePage.locator('.stage-shell').getAttribute('data-scene'), 'title', 'default route should open on the title scene');265+ await stagePage.keyboard.press('Enter');266+ await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');267+ await stagePage.screenshot({ path: `${stageScreenshotDir}/index.png` });268+ await stagePage.keyboard.press('Enter');269+ await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');270+ await stagePage.screenshot({ path: `${stageScreenshotDir}/proof.png` });271+ const stageInitial = JSON.parse(await stagePage.evaluate(() => window.render_game_to_text()));272+ await stagePage.keyboard.press('ArrowRight');273+ await stagePage.waitForFunction(initial => JSON.parse(window.render_game_to_text()).moves > initial, stageInitial.moves);274+ const stageMoved = JSON.parse(await stagePage.evaluate(() => window.render_game_to_text()));275+ await stagePage.keyboard.press('z');276+ await stagePage.waitForFunction(moves => JSON.parse(window.render_game_to_text()).moves < moves, stageMoved.moves);277+ for (const key of ['ArrowRight', 'ArrowRight', 'ArrowUp', 'ArrowUp', ...Array(4).fill('ArrowDown'), ...Array(7).fill('ArrowRight')]) await stagePage.keyboard.press(key);278+ await stagePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');279+ await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'win');280+ await stagePage.screenshot({ path: `${stageScreenshotDir}/win.png` });281+ assert.equal(await stagePage.locator('.stage-shell').getAttribute('data-scene'), 'win', 'winning a proof should open the win scene');282+ const progressKeys = await stagePage.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('miau.index.progress/')));283+ assert.ok(progressKeys.some(key => key.includes('core/')), 'winning should persist core proof progress');284+ await stagePage.keyboard.press('Enter');285+ await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');286+ const progressBeforeReload = await stagePage.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('miau.index.progress/')));287+ await stagePage.reload({ waitUntil: 'networkidle' });288+ const progressAfterReload = await stagePage.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith('miau.index.progress/')));289+ assert.deepEqual(progressAfterReload.sort(), progressBeforeReload.sort(), 'solved proof progress should survive reload');290+ report('default stage title, INDEX, proof, movement, undo, win, and progress persistence work');291+ assert.deepEqual(stageErrors, [], 'default stage should have no browser errors');292+ await stagePage.close();293+294+ await page.goto(devUrl, { waitUntil: 'networkidle' });295+ await openDevProof(page);296+ await page.locator('#level-title').waitFor();297+ const initialPackCount = await packCount(page);298+ assert.ok(initialPackCount >= 1, 'the built-in catalog must expose at least one pack');299+ assert.ok(await page.locator('#level-list [data-level], #level-list .level-choice').count() >= 3, 'the built-in campaign must remain playable');300+ report(`built-in catalog loaded (${initialPackCount} pack choice${initialPackCount === 1 ? '' : 's'})`);301+302+ await importLocalModule(page, firstPath);303+ await page.waitForFunction(() => document.body.textContent.includes('Browser Smoke One'));304+ const afterFirst = await packCount(page);305+ assert.ok(afterFirst > initialPackCount, 'first local module should append a pack');306+ report('first local CommonJS module imported');307+308+ await importLocalModule(page, secondPath);309+ await page.waitForFunction(() => document.body.textContent.includes('Browser Smoke Two'));310+ const afterSecond = await packCount(page);311+ assert.ok(afterSecond > afterFirst, 'second local module should be retained alongside the first');312+ report(`multiple local modules retained (${afterSecond} pack choices)`);313+314+ await selectPack(page, 'browser-smoke-two');315+ await page.waitForFunction(() => document.body.textContent.includes('Smoke Two'));316+ assert.equal(await currentPackId(page), 'browser-smoke-two', 'selected pack should be observable');317+ report('second imported pack selected');318+319+ await triggerShare(page);320+ const shareUrl = await readShareUrl(page);321+ report('selected pack encoded as a share URL');322+323+ const giftContext = await browser.newContext();324+ const giftPage = await giftContext.newPage();325+ giftPage.setDefaultTimeout(timeout);326+ const giftErrors = [];327+ giftPage.on('console', message => { if (message.type() === 'error') giftErrors.push(message.text()); });328+ giftPage.on('pageerror', error => giftErrors.push(error.message));329+ await giftPage.goto(shareUrl, { waitUntil: 'networkidle' });330+ await giftPage.waitForFunction(() => document.body.classList.contains('stage-player'));331+ await giftPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');332+ await giftPage.screenshot({ path: `${stageScreenshotDir}/gift.png` });333+ assert.equal(await giftPage.locator('.stage-shell').getAttribute('data-scene'), 'gift', 'shared player URL should open Gift');334+ await giftPage.keyboard.press('Enter');335+ await giftPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');336+ assert.deepEqual(giftErrors, [], 'Gift player route should have no browser errors');337+ report('Gift opens from a shared player URL and Keep installs into INDEX');338+ await giftPage.close();339+ await giftContext.close();340+341+ const conflictPage = await context.newPage();342+ conflictPage.setDefaultTimeout(timeout);343+ await conflictPage.goto(shareUrl, { waitUntil: 'networkidle' });344+ await conflictPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');345+ await conflictPage.screenshot({ path: `${stageScreenshotDir}/gift-conflict.png` });346+ await conflictPage.keyboard.press('Enter');347+ await conflictPage.screenshot({ path: `${stageScreenshotDir}/gift-conflict-choice.png` });348+ await conflictPage.keyboard.press('Enter');349+ await conflictPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');350+ report('Gift conflict opens Replace/Keep both choices and Replace overwrites');351+ await conflictPage.close();352+ const copyPage = await context.newPage();353+ copyPage.setDefaultTimeout(timeout);354+ await copyPage.goto(shareUrl, { waitUntil: 'networkidle' });355+ await copyPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');356+ await copyPage.keyboard.press('Enter');357+ await copyPage.keyboard.press('ArrowDown');358+ await copyPage.keyboard.press('Enter');359+ await copyPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');360+ const forkedIds = await copyPage.evaluate(() => {361+ const data = JSON.parse(localStorage.getItem('miau.pack-library.v1') || '{}');362+ return (data.packs || []).map(pack => pack.manifest?.id);363+ });364+ assert.ok(forkedIds.includes('browser-smoke-two-2'), 'Gift Keep both should persist a forked pack');365+ report('Gift Keep both forks a duplicate pack and persists it');366+ await copyPage.close();367+368+ const workshopPage = await context.newPage();369+ workshopPage.setDefaultTimeout(timeout);370+ await workshopPage.goto(playerUrl, { waitUntil: 'networkidle' });371+ await openDevProof(workshopPage);372+ await workshopPage.keyboard.press('Escape');373+ await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'pause');374+ await workshopPage.keyboard.press('w');375+ await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');376+ await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop.png` });377+ assert.equal(await workshopPage.locator('.stage-shell').getAttribute('data-scene'), 'workshop', 'pause W should open Workshop');378+ report('Workshop opens from pause and renders its canvas scene');379+ for (let index = 0; index < 4; index += 1) await workshopPage.keyboard.press('ArrowDown');380+ for (let index = 0; index < 3; index += 1) await workshopPage.keyboard.press('ArrowRight');381+ await workshopPage.keyboard.press('Enter');382+ await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop-remove-confirm.png` });383+ await workshopPage.keyboard.press('ArrowRight');384+ await workshopPage.keyboard.press('Enter');385+ assert.equal(await workshopPage.locator('.stage-shell').getAttribute('data-scene'), 'workshop', 'remove cancel should stay in Workshop');386+ await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop-remove-cancel.png` });387+ await workshopPage.keyboard.press('Enter');388+ await workshopPage.keyboard.press('Enter');389+ const remainingAfterRemove = await workshopPage.evaluate(() => {390+ const data = JSON.parse(localStorage.getItem('miau.pack-library.v1') || '{}');391+ return (data.packs || []).map(pack => pack.manifest?.id);392+ });393+ assert.ok(!remainingAfterRemove.includes('browser-smoke-one'), 'confirmed Workshop remove should delete the selected pack');394+ await workshopPage.reload({ waitUntil: 'networkidle' });395+ await openDevProof(workshopPage);396+ await workshopPage.keyboard.press('Escape');397+ await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'pause');398+ await workshopPage.keyboard.press('w');399+ await workshopPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'workshop');400+ const afterReload = await workshopPage.evaluate(() => {401+ const data = JSON.parse(localStorage.getItem('miau.pack-library.v1') || '{}');402+ return (data.packs || []).map(pack => pack.manifest?.id);403+ });404+ assert.ok(!afterReload.includes('browser-smoke-one'), 'Workshop remove should persist after reload');405+ await workshopPage.screenshot({ path: `${stageScreenshotDir}/workshop-after-remove.png` });406+ report('Workshop remove cancel, confirm, and reload persistence work');407+ await workshopPage.close();408+409+ const sharedPage = await context.newPage();410+ sharedPage.setDefaultTimeout(timeout);411+ const sharedDevUrl = new URL(shareUrl); sharedDevUrl.searchParams.set('dev', '1');412+ await sharedPage.goto(sharedDevUrl.toString(), { waitUntil: 'networkidle' });413+ await sharedPage.keyboard.press('Escape');414+ await sharedPage.locator('#level-title').waitFor();415+ await sharedPage.waitForFunction(() => document.body.textContent.includes('Smoke Two'));416+ assert.ok(await sharedPage.locator('[data-pack-id="browser-smoke-two"], .pack-choice').count(), 'shared URL should register the shared pack');417+ report('share URL opens and rehydrates the selected pack');418+ await sharedPage.close();419+420+ const download = await downloadPack(page);421+ const exportedPath = await download.path();422+ assert.ok(exportedPath, 'download should have a readable temporary path');423+ const exportedSource = await readFile(exportedPath, 'utf8');424+ assert.match(exportedSource, /module\.exports/, 'export should be executable CommonJS source');425+ report('selected pack downloaded as CommonJS');426+427+ const reimportContext = await browser.newContext({ acceptDownloads: true });428+ const reimportPage = await reimportContext.newPage();429+ reimportPage.setDefaultTimeout(timeout);430+ await reimportPage.goto(devUrl, { waitUntil: 'networkidle' });431+ await openDevProof(reimportPage);432+ await reimportPage.locator('#level-title').waitFor();433+ const reimportBefore = await packCount(reimportPage);434+ await importLocalModule(reimportPage, exportedPath, { install: false });435+ await reimportPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift' || /rejected|problem|error/i.test(document.querySelector('#mod-status')?.textContent || ''));436+ assert.equal(await reimportPage.locator('.stage-shell').getAttribute('data-scene'), 'gift', `fresh-session import should preview Gift (status: ${await reimportPage.locator('#mod-status').textContent()})`);437+ await reimportPage.keyboard.press('Enter');438+ await reimportPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');439+ await reimportPage.waitForFunction(() => document.body.textContent.includes('Browser Smoke Two'));440+ assert.ok(await reimportPage.locator('[data-pack-id="browser-smoke-two"]').count(), 'downloaded CommonJS should register its pack on a fresh session');441+ assert.ok(await reimportPage.locator('[data-level-id="browser-smoke-two:smoke-two"]').count(), 're-imported pack should expose its level');442+ assert.ok(await packCount(reimportPage) > reimportBefore, 're-import should append one pack in a fresh session');443+ report('downloaded CommonJS pack re-imported in a fresh session');444+ await reimportPage.close();445+ await reimportContext.close();446+447+ const beforeCollision = await packCount(page);448+ await importLocalModule(page, exportedPath, { install: false });449+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');450+ await page.screenshot({ path: `${stageScreenshotDir}/gift-local-duplicate.png` });451+ await page.keyboard.press('Enter');452+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');453+ await page.keyboard.press('Escape');454+ await page.waitForFunction(() => ['title', 'index'].includes(document.querySelector('.stage-shell')?.dataset.scene));455+ assert.equal(await packCount(page), beforeCollision, 'duplicate re-import must not replace the existing pack');456+ report('duplicate local import previews Gift conflict and cancel preserves the pack');457+458+ await importLocalModule(page, exportedPath, { install: false });459+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');460+ await page.keyboard.press('Enter');461+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'gift');462+ await page.keyboard.press('Enter');463+ await page.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'index');464+ assert.equal(await packCount(page), beforeCollision, 'duplicate Replace should overwrite without adding a pack');465+ report('duplicate local import Replace overwrites without increasing pack count');466+467+ const mechanicsPage = await context.newPage();468+ mechanicsPage.setDefaultTimeout(timeout);469+ await mechanicsPage.goto(devUrl, { waitUntil: 'networkidle' });470+ await openDevProof(mechanicsPage);471+ await selectPack(mechanicsPage, 'core');472+ const firstCoreLevel = mechanicsPage.locator('[data-level-id^="core:"]').first();473+ await firstCoreLevel.click();474+ await mechanicsPage.waitForFunction(() => document.body.textContent.includes('Break the rule'));475+ const initialState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));476+ const initialRuleText = initialState.rules.map(rule => rule.text).join('|');477+ for (const key of ['ArrowRight', 'ArrowRight', 'ArrowUp', 'ArrowUp']) await mechanicsPage.keyboard.press(key);478+ const editedState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));479+ const editedRuleText = editedState.rules.map(rule => rule.text).join('|');480+ assert.notEqual(editedRuleText, initialRuleText, 'core word movement should update active rules');481+ report('core word movement changes the active rule set');482+ await mechanicsPage.keyboard.press('z');483+ const undoneState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));484+ assert.ok(undoneState.moves < editedState.moves, 'undo should reverse a core word movement');485+ await mechanicsPage.keyboard.press('r');486+ const resetState = JSON.parse(await mechanicsPage.evaluate(() => window.render_game_to_text()));487+ assert.equal(resetState.moves, 0, 'reset should restore the core level');488+ report('core rule edit, undo, and reset remain playable');489+ await mechanicsPage.close();490+491+ await importLocalModule(page, invalidPath, { install: false });492+ await assertFeedback(page, /rejected|manifest|invalid|error/i, 'invalid local module reports actionable feedback');493+494+ await selectPack(page, 'core');495+ await page.waitForFunction(() => document.body.textContent.includes('Miau') || document.body.textContent.includes('Garden'));496+ assert.ok(await page.locator('#level-list [data-level], #level-list .level-choice').count() >= 3, 'built-in levels remain available after imports');497+ report('built-in campaign remains available after sharing flow');498+499+ await importLocalModule(page, resolve(projectRoot, 'mods/example.cjs'));500+ await page.waitForFunction(() => document.body.textContent.includes('Moon Cat'));501+ report('existing example mod remains importable');502+503+ const navigationPage = await context.newPage();504+ navigationPage.setDefaultTimeout(timeout);505+ await navigationPage.goto(devUrl, { waitUntil: 'networkidle' });506+ await openDevProof(navigationPage);507+ await navigationPage.locator('#level-title').waitFor();508+ await selectPack(navigationPage, 'word-lab');509+ await navigationPage.waitForFunction(() => document.querySelector('[data-pack-id="word-lab"]')?.classList.contains('active'));510+ const wordLabLevels = navigationPage.locator('[data-level-id^="word-lab:"]');511+ assert.equal(await wordLabLevels.count(), 3, 'Word Lab should expose multiple levels for pack navigation');512+ const firstWordLabLevel = await navigationPage.locator('#level-list .level-choice.active').getAttribute('data-level-id');513+ await navigationPage.locator('#next-level').click();514+ await navigationPage.waitForFunction(first => document.querySelector('#level-list .level-choice.active')?.dataset.levelId !== first, firstWordLabLevel);515+ const secondWordLabLevel = await navigationPage.locator('#level-list .level-choice.active').getAttribute('data-level-id');516+ assert.match(secondWordLabLevel, /^word-lab:/, 'next arrow should stay within the active pack');517+ assert.equal(await currentPackId(navigationPage), 'word-lab', 'next arrow should preserve the active pack');518+ await navigationPage.locator('#prev-level').click();519+ await navigationPage.waitForFunction(first => document.querySelector('#level-list .level-choice.active')?.dataset.levelId === first, firstWordLabLevel);520+ assert.equal(await currentPackId(navigationPage), 'word-lab', 'previous arrow should preserve the active pack');521+ report('level arrows navigate within the selected pack');522+ await navigationPage.close();523+524+ const singlePackContext = await browser.newContext();525+ const singlePackPage = await singlePackContext.newPage();526+ singlePackPage.setDefaultTimeout(timeout);527+ await singlePackPage.goto(devUrl, { waitUntil: 'networkidle' });528+ await openDevProof(singlePackPage);529+ await singlePackPage.locator('#level-title').waitFor();530+ await importLocalModule(singlePackPage, firstPath);531+ await singlePackPage.waitForFunction(() => document.body.textContent.includes('Browser Smoke One'));532+ await selectPack(singlePackPage, 'browser-smoke-one');533+ await singlePackPage.keyboard.press('Enter');534+ await singlePackPage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'proof');535+ for (const key of ['ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowRight']) await singlePackPage.keyboard.press(key);536+ await singlePackPage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');537+ assert.equal(await singlePackPage.locator('#continue-btn').isDisabled(), true, 'Continue should be disabled after winning the final level in a pack');538+ report('Continue is disabled after completing a single-level pack');539+ await singlePackPage.close();540+ await singlePackContext.close();541+542+ const mobilePage = await context.newPage();543+ mobilePage.setDefaultTimeout(timeout);544+ mobilePage.on('console', message => { if (message.type() === 'error') consoleErrors.push(`mobile: ${message.text()}`); });545+ await mobilePage.setViewportSize({ width: 390, height: 844 });546+ await mobilePage.goto(devUrl, { waitUntil: 'networkidle' });547+ await openDevProof(mobilePage);548+ await mobilePage.locator('#level-title').waitFor();549+ const mobileInitial = JSON.parse(await mobilePage.evaluate(() => window.render_game_to_text()));550+ await mobilePage.locator('[data-dir="right"]').click();551+ const mobileTapped = JSON.parse(await mobilePage.evaluate(() => window.render_game_to_text()));552+ assert.ok(mobileTapped.moves > mobileInitial.moves, 'mobile D-pad tap should advance the puzzle');553+ report('mobile D-pad movement updates the rendered game state');554+ await mobilePage.keyboard.press('ArrowLeft');555+ const mobileKeyboard = JSON.parse(await mobilePage.evaluate(() => window.render_game_to_text()));556+ assert.ok(mobileKeyboard.moves > mobileTapped.moves, 'keyboard movement should remain available at mobile width');557+ report('keyboard movement remains available at mobile width');558+ await mobilePage.close();559+560+ if (consoleErrors.length) throw new Error(`browser console errors: ${consoleErrors.join('; ')}`);561+ report('no browser console errors');562+} finally {563+ await browser.close();564+ await rm(tempDir, { recursive: true, force: true });565+}