| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; }); |
| |
| 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); |
| } |
|
|
| |
| 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 { |
| |
| { |
| 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(); |
| } |
|
|
| |
| |
| { |
| 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(); |
| } |
|
|
| |
| |
| { |
| const p = await newPage(browser, { reduce: true, deterministic: true }); |
| await enter(p, "stairway8"); |
| |
| 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}`); |
| |
| 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); |
| 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(); |
| } |
|
|
| |
| |
| { |
| 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(); |
| } |
|
|
| |
| { |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| } |
|
|
| |
| { |
| 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(); |
| } |
|
|
| |
| { |
| 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(); |
| } |
|
|
| |
| |
| { |
| 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; |
| |
| |
| 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); }); |
|
|