#!/usr/bin/env node /** * The interface gate: does the chrome actually mount, on both shapes of screen * and on both sides of the sign-in line? * * node scripts/ui-smoke.mjs # against dist/, which must be built * node scripts/ui-smoke.mjs --headed # watch it happen * * ## Why this exists * * Until `ui/chromeState.ts` landed, every visibility decision in this product * was a line in `main.ts` that needed a `Stage`, a heightfield and a WebGL * context to reach — so the interface, on a product whose entire pitch is the * interface, was the one subsystem with no coverage at all. `chromeState` made * the *decisions* testable in `node --test`; this makes the *wiring* testable, * which is the other half and the half that breaks at a seam nobody owns. * * Four combinations, because each of the two axes has genuinely changed * behaviour behind it and neither implies the other: * * - **1600 × 1000 and 390 × 844.** The phone is a different design, not a narrow * desktop: no hover, no keyboard, one thumb. Live defect 10 was the plan view * disappearing entirely there, and 12 was there being no visible movement * control at all. * - **Anonymous and signed in.** Owner decision 1 makes the signed-out visitor * the audience this is designed for rather than a degraded tier, so anonymous * is the case that must be *good*, not merely the case that must not crash. * * ## GPU flags * * This box has an AMD card and no monitor. `--use-gl=angle --use-angle=vulkan` * is what makes Chrome render headlessly on it at all, and the SwiftShader * fallback behind it is what makes this runnable on a CI box with no card — * `--enable-unsafe-swiftshader` is required since Chrome 137 made software WebGL * opt-in. Exactly the ladder `scripts/performance-budget.mjs` climbs, for * exactly the same reason. */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname, join, normalize, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); const DIST = join(ROOT, "dist"); const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".webmanifest": "application/manifest+json", }; const args = process.argv.slice(2); const headed = args.includes("--headed"); const softwareOnly = args.includes("--software"); const VIEWPORTS = { desktop: { width: 1_600, height: 1_000 }, phone: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 3 }, }; /** * The two tiers, as the two bodies `/api/v1/session` can return. * * `/health` says `mode: "jwt"` in both, which is what makes the anonymous case * a *real* anonymous case: `resolveAccess` treats `mode: "none"` as a self-host * that chose to stay open and hands it `member`, so a smoke that left auth off * would never once exercise the tier this product is designed for. */ const TIERS = { anonymous: { authenticated: false, subject: null, passwordLogin: true, admin: false }, "signed-in": { authenticated: true, subject: "smoke@lumbridgecorp.test", passwordLogin: true, admin: false, }, }; // ---- The deployment, as a few hundred bytes of JSON ----------------------- async function serve(tier) { const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://local.invalid"); const json = (status, body) => { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body)); }; if (url.pathname === "/api/v1/health") { return json(200, { auth: { mode: "jwt", entryUrl: "/login.html" }, // Every source off, which is the shipped default and the configuration // CONTRACT §0's acceptance test is written against. The city still gets // a sky, traffic and a studio full of instruments — from the simulators. sources: { weather: "none", flights: "none", satellites: "none", markers: "none" }, regions: [], }); } if (url.pathname === "/api/v1/session") return json(200, TIERS[tier]); if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" }); const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, ""); for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) { const target = resolve(DIST, `.${relative}`); if (!target.startsWith(`${resolve(DIST)}/`)) continue; try { const body = await readFile(target); res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" }); res.end(body); return; } catch { /* fall through to the SPA entry */ } } res.writeHead(404).end("not found"); }); await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", ok); }); const address = server.address(); if (!address || typeof address === "string") throw new Error("no TCP port"); return { server, port: address.port }; } // ---- Chrome --------------------------------------------------------------- const COMMON_ARGS = [ "--no-sandbox", "--disable-dev-shm-usage", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", ]; async function launch() { const ladder = softwareOnly ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]] : [ ["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]], ]; let last; for (const [backend, flags] of ladder) { try { const browser = await chromium.launch({ channel: "chrome", headless: !headed, args: [...COMMON_ARGS, ...flags], }); return { browser, backend }; } catch (error) { last = error; } } throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`); } // ---- Assertions ----------------------------------------------------------- const failures = []; const notes = []; function check(label, condition, detail = "") { if (condition) return true; failures.push(`${label}${detail === "" ? "" : ` — ${detail}`}`); return false; } /** * A `console.error` or an uncaught exception, and nothing softer. * * Warnings are deliberately not fatal: three.js warns about the tone-mapping * change on some drivers and a missing optional API is a warning by design in * three separate adapters here. An *error* means something threw or something * decided the page was broken, and neither may happen on a first load. */ function watchForErrors(page, seen) { page.on("console", (message) => { if (message.type() !== "error") return; /* * Chrome logs its own console error for every non-2xx response, and the * optional API surface answering 4xx is not an application error — it is the * architecture. `adapters/http.ts` degrades to bundled fiction rather than * failing, and CONTRACT §0's acceptance test is a clone with no server at * all, so a build that treated `/api/v1/*` 404s as fatal would be gating on * the opposite of the promise. * * Everything else stays fatal, and the case that matters is a missing * `/assets/*.js` chunk: a deploy that moved a file under an open tab is * exactly the failure this check exists to catch, and it arrives as a 404 on * a URL that is not under `/api/v1/`. */ const from = message.location()?.url ?? ""; if (/\/api\/v1\//.test(from)) return; seen.push(`console: ${message.text()}${from === "" ? "" : ` (${from})`}`); }); page.on("pageerror", (error) => seen.push(`uncaught: ${String(error)}`)); page.on("requestfailed", (request) => { // A cancelled navigation request is not a failure of the page. const failure = request.failure(); if (failure && !/ERR_ABORTED/.test(failure.errorText)) { seen.push(`request: ${request.url()} ${failure.errorText}`); } }); page.on("response", (response) => { const url = new URL(response.url()); if (url.pathname.startsWith("/api/v1/")) return; if (response.status() >= 400) seen.push(`http ${response.status()}: ${url.pathname}`); }); } const chromeSnapshot = () => { const visible = (id) => { const element = document.getElementById(id); return element !== null && element.hidden === false; }; const text = (id) => document.getElementById(id)?.textContent?.trim() ?? ""; return { bootHidden: document.getElementById("boot")?.hidden === true, chapters: document.querySelectorAll("#chapters .chapter").length, boards: document.querySelectorAll("#cities .board").length, activeBoard: document.querySelectorAll("#cities .board[aria-pressed='true']").length, modeButtons: document.querySelectorAll("#mode-dock [data-control-mode]:not([hidden])").length, tierVisible: visible("tier"), tierLabel: text("tier-label"), tierAdds: text("tier-adds"), signInVisible: visible("tier-signin"), enterLabel: text("enter"), panelToggleVisible: visible("panel-toggle"), planVisible: visible("corner"), planPressed: document.getElementById("plan-toggle")?.getAttribute("aria-pressed"), helpLabel: text("help"), onboardingCards: document.querySelectorAll("#onboarding-host [data-onboarding-step]").length, onboardingHostChildren: document.getElementById("onboarding-host")?.childElementCount ?? 0, deviceSectionVisible: visible("device-section"), devicesButtonVisible: visible("devices"), deviceCards: document.querySelectorAll("#device-host [data-device]").length, deviceControls: document.querySelectorAll("#device-host button, #device-host input").length, stickVisible: visible("play-stick"), canvasLabel: document.getElementById("scene")?.getAttribute("aria-label") ?? "", bodyClasses: [...document.body.classList], // The renderer counting its own draws is the one witness a fast frame // cannot fool: an increment means a frame of the real scene was submitted. frames: window.__teraSmokeFrames ?? 0, }; }; /** Count real WebGL draws, so "it booted" means pixels rather than a DOM tree. */ const countFrames = () => { let frames = 0; const patch = (prototype, method) => { if (!prototype || typeof prototype[method] !== "function") return; const original = prototype[method]; prototype[method] = function (...values) { frames += 1; return original.apply(this, values); }; }; for (const prototype of [ globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype, ]) { patch(prototype, "drawArrays"); patch(prototype, "drawElements"); patch(prototype, "drawArraysInstanced"); patch(prototype, "drawElementsInstanced"); } Object.defineProperty(globalThis, "__teraSmokeFrames", { get: () => frames }); }; async function settle(page, timeoutMs = 120_000) { await page.waitForFunction(() => document.getElementById("boot")?.hidden === true, null, { timeout: timeoutMs, }); // One more paint after the card goes, so anything the fade uncovers has been // through `apply` at least once. await page.evaluate( () => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))), ); } // ---- One combination ------------------------------------------------------- async function run(browser, port, viewportName, tier) { const label = `${viewportName} · ${tier}`; const viewport = VIEWPORTS[viewportName]; const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor ?? 1, isMobile: viewport.isMobile ?? false, hasTouch: viewport.hasTouch ?? false, }); const errors = []; try { const page = await context.newPage(); watchForErrors(page, errors); await page.addInitScript(countFrames); await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "domcontentloaded" }); await settle(page); const city = await page.evaluate(chromeSnapshot); // ---- The board ---- check(`${label}: boot card faded`, city.bootHidden); check( `${label}: a real frame reached the glass`, city.frames > 0, "no WebGL draw call was ever issued, so the card faded over an empty canvas", ); check(`${label}: chapter list mounted`, city.chapters > 0, `${city.chapters} rows`); check(`${label}: board strip mounted`, city.boards === 3, `${city.boards} tabs`); check(`${label}: exactly one board is current`, city.activeBoard === 1); check( `${label}: canvas describes itself`, /California/.test(city.canvasLabel), JSON.stringify(city.canvasLabel), ); check(`${label}: layout class applied`, city.bodyClasses.some((c) => c.startsWith("layout-"))); // ---- The tier, and the anon-first promise ---- check(`${label}: tier badge visible`, city.tierVisible); if (tier === "anonymous") { check( `${label}: the signed-out badge names what you get`, city.tierLabel === "Open demo", JSON.stringify(city.tierLabel), ); check(`${label}: a way in is offered`, city.signInVisible); check( `${label}: the offer says what signing in adds`, city.tierAdds.length > 0, "'Public view · Sign in' was the entire signed-out story before this line", ); } else { check( `${label}: the signed-in badge reads Full view`, city.tierLabel === "Full view", JSON.stringify(city.tierLabel), ); check(`${label}: no sign-in offer once you are in`, city.signInVisible === false); } // ---- The two layouts ---- if (viewportName === "phone") { check( `${label}: the plan view is on screen`, city.planVisible, "live defect 10: the minimap disappeared entirely on a phone, because the " + "seed was width > 600 and a phone has no M key", ); check(`${label}: the panel has a toggle`, city.panelToggleVisible); check( `${label}: the shortcuts button is labelled for a thumb`, city.helpLabel === "Guide", `live defect 11: "? shortcuts" on a device with no keyboard; got ` + JSON.stringify(city.helpLabel), ); check( `${label}: no joystick while the camera is the only thing you control`, city.stickVisible === false, ); } else { check(`${label}: the panel is furniture, not a sheet`, city.panelToggleVisible === false); check(`${label}: the shortcuts button is the key hint`, city.helpLabel === "?"); } // ---- Onboarding, which must appear exactly once ---- check( `${label}: the first-run coach appeared`, city.onboardingHostChildren > 0, "there was no onboarding of any kind in this product before this build", ); await page.reload({ waitUntil: "domcontentloaded" }); await settle(page); const second = await page.evaluate(chromeSnapshot); check( `${label}: the coach does not come back on the second visit`, second.onboardingHostChildren === 0, `${second.onboardingHostChildren} children after a reload in the same context`, ); // ---- Taking control ---- // // Live defect 12 was that a phone in VIEW mode offered no on-screen way to // move and no way to discover that one existed. The joystick has been in // `input/pointerStick.ts` since the play modes landed; what it never had was // a state that showed it. await page.click("#mode-dock [data-control-mode='actor']"); await page.evaluate( () => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))), ); const playing = await page.evaluate(chromeSnapshot); check( `${label}: the play HUD appears with a body under control`, playing.bodyClasses.includes("playing"), JSON.stringify(playing.bodyClasses), ); if (viewportName === "phone") { check( `${label}: and a thumb is given something to move with`, playing.stickVisible, "live defect 12: VIEW mode on a phone had no visible movement control at all", ); } await page.click("#mode-dock [data-control-mode='overview']"); // ---- The studio, through the door rather than through the URL ---- // // The in-page swap is the path a visitor takes and the one that exercises // `enterOffice` / `leaveOffice`: the city is paused and kept, the office is // built, its hardware feed and its car are started, and the whole lot is torn // down again on the way out. if (viewportName === "phone") await page.click("#panel-toggle"); await page.click("#enter"); await settle(page); const office = await page.evaluate(chromeSnapshot); check( `${label}: the studio opened`, /Back to the city/.test(office.enterLabel), JSON.stringify(office.enterLabel), ); check( `${label}: the hardware panel is offered`, office.devicesButtonVisible && office.deviceSectionVisible, "the studio declares mic and speaker hardware and the panel is anon-visible " + "by design — the declarations are authored, only the readings are an account", ); check( `${label}: the hardware panel rendered one card per declared device`, office.deviceCards === 2, `lumbridge-hq declares a desk mic and a desk speaker; found ${office.deviceCards}`, ); check( `${label}: every card carries real controls`, office.deviceControls >= 4, `power, mute, gain and volume are the minimum; found ${office.deviceControls}`, ); check(`${label}: the studio has viewpoints`, office.chapters > 0); check( `${label}: the board strip became the studio picker`, office.boards === 3 && office.activeBoard === 1, ); // ---- And back out, which is where a leak would show ---- await page.click("#enter"); await settle(page); const back = await page.evaluate(chromeSnapshot); check( `${label}: stepping back out returns to the board`, back.chapters === city.chapters && !/Back to the city/.test(back.enterLabel), `${back.chapters} chapters, enter reads ${JSON.stringify(back.enterLabel)}`, ); check( `${label}: the studio's hardware panel went with the studio`, back.deviceSectionVisible === false && back.deviceCards === 0, ); notes.push( `${label}: ${city.chapters} chapters, ${office.chapters} viewpoints, ` + `${office.deviceCards} devices / ${office.deviceControls} controls, ` + `${city.frames} draws`, ); } finally { await context.close(); } if (errors.length > 0) { for (const error of errors) failures.push(`${label}: ${error}`); } } // ---- Main ------------------------------------------------------------------ async function main() { try { await readFile(join(DIST, "index.html")); } catch { console.error("ui-smoke: dist/index.html is missing. Run `npm run build` first."); process.exit(2); } const { browser, backend } = await launch(); console.log(`ui-smoke: chrome up on ${backend}`); try { for (const tier of Object.keys(TIERS)) { const { server, port } = await serve(tier); try { for (const viewportName of Object.keys(VIEWPORTS)) { await run(browser, port, viewportName, tier); } } finally { await new Promise((ok) => server.close(ok)); } } } finally { await browser.close(); } for (const note of notes) console.log(` ${note}`); if (failures.length > 0) { console.error(`\nui-smoke: FAIL — ${failures.length} problem(s)\n`); for (const failure of failures) console.error(` ✖ ${failure}`); console.error(""); process.exit(1); } console.log("\nui-smoke: PASS — 2 viewports × 2 access tiers, no console errors\n"); } await main();