// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 alvations (Melon Lab) // // Browser emulation tests for the client rendering mechanisms that unit tests // cannot reach: reduced-motion rendering, Act 2 touch verbs + flavour line, the // inspect aside, and the alternate-ending achievement gate. Driven with a real // (headless) Chromium via Playwright. // // Usage: node tests/emulation.cjs [chromiumPath] // Exits 0 if every check passes, 1 otherwise. Prints PASS/FAIL per check. // // It is normally run by tests/test_emulation.py, which boots the app and skips // gracefully when Chromium/Playwright are unavailable. const BASE = process.argv[2] || "http://127.0.0.1:8078"; const CHROMIUM = process.argv[3] || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome"; let playwright; try { playwright = require(process.env.PW_PATH || "/opt/node22/lib/node_modules/playwright"); } catch (e) { console.error("SKIP: playwright module not found"); process.exit(2); } const results = []; function check(name, ok, detail) { results.push({ name, ok: !!ok, detail: detail || "" }); console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok ? "" : " :: " + (detail || "")}`); } async function newPage(browser, { reduce = false, deterministic = false } = {}) { const ctx = await browser.newContext(); const p = await ctx.newPage(); if (deterministic) await p.addInitScript(() => { Math.random = () => 0; }); // seed: skip first-run suppression for all arcs, optional reduce-motion await p.goto(BASE); await p.evaluate((r) => { if (r) localStorage.setItem("h8_motion", "1"); localStorage.setItem("h8_mem", JSON.stringify({ v: 1, arcStarted: { "stairway8": true, "hallway-eight": true, "coach8": true } })); }, reduce); await p.goto(BASE); await p.waitForTimeout(300); return p; } async function enter(p, arc) { await p.evaluate((a) => document.querySelector(`[data-arc="${a}"]`)?.click(), arc); await p.waitForTimeout(600); await p.click("#begin").catch(() => {}); await p.evaluate(() => document.querySelectorAll(".cutscene-overlay:not(.hidden),#learn:not(.hidden)").forEach(o => o.querySelector("button")?.click())); await p.waitForSelector("#controls:not(.hidden)", { timeout: 8000 }); await p.waitForTimeout(250); } // advance one loop; handle the (possibly always-on) doubt prompt async function commit(p, reduce) { await p.evaluate(() => document.querySelector('.choice[data-choice="continue"]')?.click()); await p.waitForTimeout(120); await p.evaluate(() => document.querySelector('#confidence:not(.hidden) [data-conf="4"]')?.click()); await p.waitForTimeout(reduce ? 1400 : 1900); await p.waitForSelector("#controls:not(.hidden)", { timeout: 8000 }).catch(() => {}); await p.waitForTimeout(reduce ? 150 : 250); } (async () => { const browser = await playwright.chromium.launch({ executablePath: CHROMIUM, args: ["--autoplay-policy=no-user-gesture-required"] }); try { // 1) Reduced motion: prose renders in canonical order, fully visible. { const p = await newPage(browser, { reduce: true }); await enter(p, "stairway8"); const s = await p.evaluate(() => { const lines = [...document.querySelectorAll("#prose .line")]; const domOrder = lines.map(l => l.dataset.prop); let serverOrder = []; try { serverOrder = (current.room.shown || []); } catch (e) {} const allVisible = lines.every(l => l.classList.contains("show") && parseFloat(getComputedStyle(l).opacity) > 0.9); return { domOrder, serverOrder, allVisible, corridorHidden: document.querySelector("#corridor").classList.contains("hidden") }; }); check("reduce-motion prose in canonical order", JSON.stringify(s.domOrder) === JSON.stringify(s.serverOrder), `${JSON.stringify(s.domOrder)} vs ${JSON.stringify(s.serverOrder)}`); check("reduce-motion prose fully visible at settle", s.allVisible && !s.corridorHidden); await p.context().close(); } // 2) Reduced motion: touch verbs render AND are visible whenever the server // offers touches (deterministic Math.random=0 forces the roll true). { const p = await newPage(browser, { reduce: true, deterministic: true }); await enter(p, "stairway8"); let touchLoops = 0, verbVisibleLoops = 0; for (let i = 0; i < 8; i++) { const s = await p.evaluate(() => { let touchLen = 0; try { touchLen = (current.room.touch || []).length; } catch (e) {} const btns = [...document.querySelectorAll(".touch-btn")]; const vis = btns.filter(b => { const r = b.getBoundingClientRect(); return r.width > 2 && r.height > 2 && parseFloat(getComputedStyle(b).opacity) > 0.05; }); return { touchLen, vis: vis.length }; }); if (s.touchLen > 0) { touchLoops++; if (s.vis > 0) verbVisibleLoops++; } await commit(p, true); } check("reduce-motion verbs render+visible on every touch loop", touchLoops > 0 && verbVisibleLoops === touchLoops, `visible ${verbVisibleLoops}/${touchLoops}`); await p.context().close(); } // 3) Flavour line: clicking a verb shows the line, which then auto-fades, // and the fade is visible under reduced motion. { const p = await newPage(browser, { reduce: true, deterministic: true }); await enter(p, "stairway8"); // find a loop with a verb, click it let clicked = false; for (let i = 0; i < 6 && !clicked; i++) { clicked = await p.evaluate(() => { const b = document.querySelector(".touch-btn"); if (b) { b.click(); return true; } return false; }); if (!clicked) await commit(p, true); } check("flavour line: a verb was available to click", clicked); if (clicked) { await p.waitForTimeout(800); const shown = await p.evaluate(() => { const e = document.querySelector("#touch-line"); return { op: parseFloat(getComputedStyle(e).opacity), txt: (e.textContent || "").length }; }); check("flavour line appears on click", shown.op > 0.5 && shown.txt > 0, `opacity ${shown.op}`); // transition kept alive under reduced motion (so a fade is possible) const trans = await p.evaluate(() => getComputedStyle(document.querySelector("#touch-line")).transitionDuration); check("flavour line keeps a fade under reduced motion", parseFloat(trans) >= 0.3, `transition-duration ${trans}`); await p.waitForTimeout(5200); // past the 4.5s auto-fade const faded = await p.evaluate(() => parseFloat(getComputedStyle(document.querySelector("#touch-line")).opacity)); check("flavour line auto-fades after a readable dwell", faded < 0.2, `opacity ${faded}`); } await p.context().close(); } // 4) Alternate-ending achievement is gated: never unlocked by ordinary play, // and its predicate reflects mem.ends exactly. { const p = await newPage(browser, { reduce: false }); await enter(p, "hallway-eight"); for (let i = 0; i < 4; i++) await commit(p, false); const a = await p.evaluate(() => { const ach = computeAch(); const anyEnd = Object.keys(ach).some(k => k.startsWith("end_") && ach[k]); const endsEmpty = !mem.ends || Object.keys(mem.ends).length === 0; return { anyEnd, endsEmpty }; }); check("end_ achievement NOT unlocked by ordinary play", !a.anyEnd && a.endsEmpty); const b = await p.evaluate(() => { mem.ends = mem.ends || {}; mem.ends["hallway-eight"] = true; return computeAch()["end_hallway-eight"]; }); check("end_ predicate reflects mem.ends", b === true); await p.context().close(); } // 5) Ending credits + adaptive nudge. { const p = await newPage(browser, { reduce: false }); await enter(p, "hallway-eight"); const cr = await p.evaluate(() => { openCredits(); const open = !document.querySelector("#credits").classList.contains("hidden"); const roll = document.querySelector("#credits-roll").textContent; closeCredits(); return { open, hasTitle: roll.includes("EIGHT"), hasMaker: roll.includes("alvations"), hasStudio: roll.toUpperCase().includes("MELON LAB") }; }); check("credits open with our own roll content", cr.open && cr.hasTitle && cr.hasMaker && cr.hasStudio); // "View credits" is gated on having won at least once. const gate = await p.evaluate(() => { mem.wins = {}; renderAchievements(); const hiddenNoWin = document.querySelector("#credits-row").classList.contains("hidden"); mem.wins = { "hallway-eight": 1 }; renderAchievements(); const shownWithWin = !document.querySelector("#credits-row").classList.contains("hidden"); return { hiddenNoWin, shownWithWin }; }); check("View credits appears only after a win", gate.hiddenNoWin && gate.shownWithWin); // The adaptive nudge changes tier with fragment progress. const nud = await p.evaluate(() => { const arc = "hallway-eight"; const total = (ARCS_BY_ID[arc].flashbacks) || 8; mem.flash = {}; buildNudge(arc); const frags = document.querySelector("#nudge").textContent; mem.flash = { [arc]: Array.from({ length: total }, (_, i) => i) }; buildNudge(arc); const arcs = document.querySelector("#nudge").textContent; ["hallway-eight", "stairway8", "coach8"].forEach(a => { const t = (ARCS_BY_ID[a].flashbacks) || 8; mem.flash[a] = Array.from({ length: t }, (_, i) => i); }); buildNudge(arc); const eight = document.querySelector("#nudge").textContent; return { frags, arcs, eight }; }); check("nudge tier 1: unseen fragments here", /\d/.test(nud.frags) && nud.frags !== nud.arcs); check("nudge tier 2: other places (distinct)", nud.arcs && nud.arcs !== nud.frags && nud.arcs !== nud.eight); check("nudge tier 3: eight escapes (distinct)", nud.eight && nud.eight !== nud.arcs); await p.context().close(); } // 6) Under reduced motion the credits roll does not animate (reads static). { const p = await newPage(browser, { reduce: true }); await enter(p, "stairway8"); const anim = await p.evaluate(() => { openCredits(); const name = getComputedStyle(document.querySelector("#credits-roll")).animationName; closeCredits(); return name; }); check("reduced-motion credits are static (no roll animation)", anim === "none"); await p.context().close(); } // 7) Erase wipes the local memory, and only behind the confirm gate. { const p = await newPage(browser, { reduce: false }); await enter(p, "hallway-eight"); const r = await p.evaluate(() => { mem.wins = { "hallway-eight": 3 }; mem.flash = { "hallway-eight": [0, 1] }; saveMem(); const before = Object.values(computeAch()).some(Boolean); document.querySelector("#ach-erase").click(); const confirmShown = !document.querySelector("#erase-confirm").classList.contains("hidden"); document.querySelector("#erase-cancel").click(); const keptOnCancel = (mem.wins["hallway-eight"] || 0); document.querySelector("#ach-erase").click(); document.querySelector("#erase-ok").click(); const winsAfter = Object.keys(mem.wins || {}).length; const anyAch = Object.values(computeAch()).some(Boolean); const storedGone = !localStorage.getItem("h8_mem") || localStorage.getItem("h8_mem").indexOf('"hallway-eight":3') === -1; return { before, confirmShown, keptOnCancel, winsAfter, anyAch, storedGone }; }); check("erase is gated behind a confirm dialog", r.before && r.confirmShown); check("cancel keeps the memory", r.keptOnCancel === 3); check("erase wipes runs, achievements, and storage", r.winsAfter === 0 && !r.anyAch && r.storedGone); await p.context().close(); } // 8) The win screen itself renders (guards showWin / setAgainMode wiring; a // scope bug here would silently break every win). { const p = await newPage(browser, { reduce: false }); await enter(p, "hallway-eight"); const r = await p.evaluate(() => { winIsFirst = true; showWin({ win_text: ["You are out."], attempts: 1, attempt_text: "", stats: {} }); const shown = !document.querySelector("#win").classList.contains("hidden"); const again = document.querySelector("#again").textContent; // The linger auto-roll (same path the 20s timer fires) opens the credits, // and closing hands back the replay button + reveals the nudge. playCredits(); const creditsOpen = !document.querySelector("#credits").classList.contains("hidden"); closeCredits(); const afterBtn = document.querySelector("#again").textContent; const nudgeShown = !document.querySelector("#nudge").classList.contains("hidden"); return { shown, again, creditsOpen, afterBtn, nudgeShown }; }); check("win screen renders (showWin does not throw)", r.shown); check("first-win button reads End Credits", r.again === "End Credits"); check("linger auto-roll opens the credits", r.creditsOpen); check("after credits: replay button + nudge return", r.afterBtn !== "End Credits" && r.nudgeShown); await p.context().close(); } } finally { await browser.close(); } const failed = results.filter(r => !r.ok); console.log(`\n${results.length - failed.length}/${results.length} checks passed`); process.exit(failed.length ? 1 : 0); })().catch((e) => { console.error("ERROR", e); process.exit(1); });