#!/usr/bin/env node /** * The chapter list is a public interface. This is the check that says so. * * node scripts/check-chapter-identity.mjs # against dist/ * node scripts/check-chapter-identity.mjs --dist … # against another build * node scripts/check-chapter-identity.mjs --write # re-cut the fixture (read the warning) * * ## What it protects, and why it had to exist before the ladder work * * Twenty-six index-aimed guards outside this file point a camera at a chapter by * its **position in a list** and then assert its short label. Counted from the * source rather than taken from a plan — comment lines that *quote* the pattern * are excluded, which is where the "29" in the round's brief came from: * * - `scripts/brand-assets/shots.mjs` — 21, every one of them a still on * lumbridgecorp.com right now. * - `scripts/brand-assets/films.mjs` — 4. * - `scripts/brand-assets/capture.mjs` — 1: the Open Graph card, which is what * every link to the site renders as, and the most-seen frame in the product. * * Twenty-one of them aim at a board a `?city=` can reach — 12 at the Bay Area, * 6 at California, 3 at SoCal — and this check covers those. The other five are * office shots aiming at a studio's own view list, which no `?city=` selects. * * That pattern was chosen for a good reason and it is written up at * `capture.mjs:70`: a card once shipped for a fortnight showing a chase camera * on a freeway under the headline "Cities from above", because `keyboard.press("2")` * is an unguarded index into pack data and the default board had changed under it. * The label assertion was the fix. It is a *good* fix and it is not sufficient, * for two reasons this repo can now demonstrate rather than argue: * * 1. **Labels are not unique across boards.** "Whole Board" is chapter 01 of the * Bay Area *and* chapter 01 of SoCal; "The Valley" is Silicon Valley on one * board and the San Fernando Valley on the other. A reorder that preserves * labels passes every one of those twenty-six guards and shoots the wrong place. * 2. **A guard on the chapter you clicked says nothing about the one you did * not.** Several shots take no chapter at all and inherit whatever board the * URL opened on, which is a fallback rather than an assertion. * * So this file snapshots the *identity* of every chapter on every board — its * `data-view`, its position, its printed number and its short label — against a * checked-in fixture, and fails on any drift. It is deliberately not a test of * whether the chapters are *good*; it is a test of whether they are the same * ones the marketing imagery was aimed at. * * ## It reads the DOM, not the packs * * A unit test over `src/cities/*.ts` would be cheaper and would miss the thing * that actually breaks: the harnesses read `#chapters .chapter`, and what lands * there is `chromeState.ts`'s `views` array after `main.ts` has decided which * board is up. Number is taken from the pack *or* the ordinal, and the * `data-view-index` written into the button is the index into that rendered * list. Only the DOM knows all of it at once. `src/test/chapterIdentity.test.ts` * carries the fast pack-level half of the same contract, including the negative * case, so that a reorder fails in `npm test` in a second rather than here in a * minute. * * ## --write * * Regenerating the fixture is how you record a deliberate change, and it is not * a way to make this go green. Every drift it reports is a frame on * lumbridgecorp.com that is about to become a picture of somewhere else, and the * 26 aims above have to be re-pointed in the same commit — which means * re-shooting the imagery, which is an owner decision, not a refactor. */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFile, writeFile } 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 DEFAULT_FIXTURE = join(ROOT, "scripts", "fixtures", "chapter-identity.json"); 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 option = (name, fallback) => { const at = args.indexOf(`--${name}`); return at < 0 ? fallback : args[at + 1]; }; const has = (name) => args.includes(`--${name}`); const DIST = resolve(option("dist", join(ROOT, "dist"))); const WRITE = has("write"); const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000; /* * `--fixture` exists so the failure can be watched. * * A guard nobody has seen fail is not a guard, and the only other way to see * this one fail is to break the packs — on a shared tree, with two other * workstreams in the same files. Point it at a deliberately scrambled copy * instead: * * jq '.boards.sf.chapters |= (.[0:2] + [.[3], .[2]] + .[4:])' \ * scripts/fixtures/chapter-identity.json > /tmp/scrambled.json * node scripts/check-chapter-identity.mjs --fixture /tmp/scrambled.json # must FAIL */ const FIXTURE = resolve(option("fixture", DEFAULT_FIXTURE)); /** * The boards, by the `?city=` value that selects them. * * This list is itself part of the contract: `?city=` is the coordinate every * capture harness and every deep link uses, and `main.ts` falls back to the * first board rather than failing on an unknown id — so a board that quietly * lost its id would not throw, it would silently serve California to every shot * aimed at SoCal. The snapshot records which board answered, so that failure * shows up as a mismatch instead of as a fortnight of wrong pictures. */ const CITY_PARAMS = ["california", "sf", "socal"]; async function serve() { 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" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" }, regions: [], }); } if (url.pathname === "/api/v1/session") { return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false }); } 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 }; } async function launch() { const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1"; const common = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"]; const ladder = [ ["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]], ]; let last; for (const [backend, flags] of ladder) { try { const browser = await chromium.launch({ channel: "chrome", args: [...common, resolver, ...flags] }); return { browser, backend }; } catch (error) { last = error; } } throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`); } /** Everything the DOM knows about the chapter strip on one board. */ async function snapshotBoard(browser, port, cityParam) { const page = await browser.newPage(); try { await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${cityParam}`, { waitUntil: "networkidle", timeout: READY_TIMEOUT_MS, }); await page.waitForFunction( () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0, null, { timeout: READY_TIMEOUT_MS }, ); return await page.evaluate(() => { const text = (node) => (node?.textContent ?? "").trim(); const chapters = [...document.querySelectorAll("#chapters .chapter")].map((button, index) => { const spans = [...button.querySelectorAll("span")]; return { index, dataView: button.getAttribute("data-view"), dataViewIndex: button.getAttribute("data-view-index"), number: text(spans[0]), shortLabel: text(spans[spans.length - 1]), }; }); const boards = [...document.querySelectorAll("[data-board]")].map((button) => ({ id: button.getAttribute("data-board"), label: text(button.querySelector(".board__name")) || text(button), active: button.getAttribute("aria-pressed") === "true", })); return { chapters, boards, activeBoard: boards.find((b) => b.active)?.id ?? null }; }); } finally { await page.close(); } } // ---- The aims this exists to protect -------------------------------------- // // Read out of the capture harnesses rather than duplicated here, because a copy // would rot in exactly the way this file is trying to stop. Nothing under // `scripts/brand-assets/` is written to — this reads their source and reports // which of their `chapter: N` / `expect: "…"` pairs no longer agree with the // board they aim at. async function readAims() { const files = [ ["scripts/brand-assets/shots.mjs", "shot"], ["scripts/brand-assets/films.mjs", "film"], ["scripts/brand-assets/capture.mjs", "og-card"], ]; const aims = []; for (const [relative, kind] of files) { let source; try { source = await readFile(join(ROOT, relative), "utf8"); } catch { continue; } const lines = source.split("\n"); let id = null; let door = null; let city = null; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (/^\s*\*/.test(line)) continue; // a comment quoting one of these is not one const idAt = /^\s*id:\s*"([^"]+)"/.exec(line); if (idAt) { id = idAt[1]; door = null; city = null; } const doorAt = /^\s*door:\s*"([^"]+)"/.exec(line); if (doorAt) door = doorAt[1]; const cityAt = /^\s*city:\s*"([^"]+)"/.exec(line); if (cityAt) city = cityAt[1]; // `capture.mjs` does not carry a shot record — it calls `shootApp` with a // URL. The Open Graph card is the single most-seen frame in the product // (it is what every link to lumbridgecorp.com renders as), so its aim is // read out of the URL rather than left uncovered. const urlAt = /"https?:\/\/[^"]*[?&]city=([a-z-]+)[^"]*"/.exec(line); if (urlAt) { city = urlAt[1]; door = null; const nameAt = /"([\w-]+\.png)"/.exec(line); if (nameAt) id = nameAt[1]; } const chapterAt = /^\s*chapter:\s*(\d+)/.exec(line); if (!chapterAt) continue; let expect = null; for (let j = i + 1; j < Math.min(lines.length, i + 6); j += 1) { const expectAt = /^\s*expect:\s*"([^"]+)"/.exec(lines[j]); if (expectAt) { expect = expectAt[1]; break; } } aims.push({ file: relative, line: i + 1, kind, id: id ?? "(unnamed)", // An office shot has no `city:` — it aims at the studio's own view list, // which this check does not cover and which no `?city=` can reach. city: door === "office" ? null : city, chapter: Number(chapterAt[1]), expect, }); } } return aims; } // ---- Run ------------------------------------------------------------------ const { server, port } = await serve(); const { browser, backend } = await launch(); let observed; try { observed = {}; for (const cityParam of CITY_PARAMS) { observed[cityParam] = await snapshotBoard(browser, port, cityParam); } } finally { await browser.close(); server.close(); } const snapshot = { takenAt: "bcac6aa", note: "The chapter strip as the capture harnesses see it. Regenerate with --write ONLY as " + "part of a deliberate, owner-approved re-shoot: 26 index-aimed guards in " + "scripts/brand-assets/** point at these positions, 21 of them at a board a ?city= " + "reaches, and every one of them is a frame on lumbridgecorp.com.", boards: Object.fromEntries( CITY_PARAMS.map((cityParam) => [ cityParam, { activeBoard: observed[cityParam].activeBoard, boardTabs: observed[cityParam].boards.map((board) => ({ id: board.id, label: board.label })), chapters: observed[cityParam].chapters, }, ]), ), }; if (WRITE) { await writeFile(FIXTURE, `${JSON.stringify(snapshot, null, 2)}\n`); console.log(`check-chapter-identity: wrote ${FIXTURE}`); console.log( "check-chapter-identity: a re-cut fixture means the 26 aims in scripts/brand-assets/**\n" + " are now pointed at whatever this build happens to render. Re-point them and re-shoot.", ); process.exit(0); } let expected; try { expected = JSON.parse(await readFile(FIXTURE, "utf8")); } catch (error) { console.error(`check-chapter-identity: cannot read ${FIXTURE} — ${error}`); console.error(" Take one with --write, from a build you have looked at."); process.exit(1); } /* * TWO SEVERITIES, AND THE LINE BETWEEN THEM IS "DOES A CAMERA AIM AT IT". * * `failures` is the chapter strip: `data-view`, position, printed number, short * label, and how many there are. Every one of those is something a capture * harness resolves a frame through, so drift there is a picture of somewhere * else and stops the run. * * `notes` is the board chrome — the tab strip and which tab is pressed. That was * a hard failure in the first version of this file and it fired within the hour, * on the continuity work legitimately removing the three-tab strip. Nothing aims * a camera at a tab: `?city=` selects the board and the chapter list is what * proves which board answered, because the ids are unique per pack. So chrome * drift is printed and does not fail. * * The silent-fallback case is still caught, and caught harder: `?city=sf` * degrading to California renders California's six chapters, which is a * count-and-id failure on every row. */ const failures = []; const notes = []; const field = (where, want, got, soft = false) => { if (want === got) return; (soft ? notes : failures).push( `${where}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`, ); }; for (const cityParam of CITY_PARAMS) { const want = expected.boards?.[cityParam]; const got = snapshot.boards[cityParam]; if (!want) { failures.push(`?city=${cityParam}: not in the fixture — a board appeared`); continue; } field(`?city=${cityParam} pressed tab`, want.activeBoard, got.activeBoard, true); field( `?city=${cityParam} board tabs`, want.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "), got.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "), true, ); field(`?city=${cityParam} chapter count`, want.chapters.length, got.chapters.length); const count = Math.min(want.chapters.length, got.chapters.length); for (let i = 0; i < count; i += 1) { for (const key of ["dataView", "dataViewIndex", "number", "shortLabel", "index"]) { field(`?city=${cityParam} chapter[${i}].${key}`, want.chapters[i][key], got.chapters[i][key]); } } } for (const cityParam of Object.keys(expected.boards ?? {})) { if (!CITY_PARAMS.includes(cityParam)) failures.push(`?city=${cityParam}: gone from this build`); } // Whether or not the fixture drifted, say which aims are currently right — a // green fixture with a misaimed shot is exactly the state this repo was in at // bcac6aa, and it was found with a photograph rather than with a check. const aims = await readAims(); const misaimed = []; for (const aim of aims) { if (aim.city === null) continue; const board = snapshot.boards[aim.city]; if (!board) { misaimed.push(`${aim.file}:${aim.line} ${aim.id} — city:"${aim.city}" is not a board`); continue; } const chapter = board.chapters[aim.chapter]; if (!chapter) { misaimed.push( `${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} does not exist on ${aim.city}`, ); continue; } if (aim.expect !== null && chapter.shortLabel !== aim.expect) { misaimed.push( `${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} on ${aim.city} is ` + `"${chapter.shortLabel}" (${chapter.dataView}), the aim says "${aim.expect}"`, ); } } const covered = aims.filter((aim) => aim.city !== null).length; console.log(`check-chapter-identity — dist ${DIST} (${backend})`); for (const cityParam of CITY_PARAMS) { const board = snapshot.boards[cityParam]; console.log( ` ?city=${cityParam.padEnd(11)} ${String(board.chapters.length).padStart(2)} chapters ` + board.chapters.map((chapter) => chapter.dataView).join(", "), ); } console.log( ` ${aims.length} index-aimed guards read from the capture harnesses; ` + `${covered} of them aim at a board this check covers.`, ); if (notes.length > 0) { console.log(""); console.log("NOTE — the board chrome moved. Nothing aims a camera at it, so this is not a"); console.log("failure; re-cut the fixture with --write when the change is settled:"); for (const line of notes) console.log(` ${line}`); } if (misaimed.length > 0) { console.log(""); console.log("MISAIMED — these guards would shoot a different frame than their comment claims:"); for (const line of misaimed) console.log(` ${line}`); } if (failures.length > 0) { console.log(""); console.log(`FAIL — the chapter strip moved under ${failures.length} field(s):`); for (const line of failures) console.log(` ${line}`); console.log(""); console.log( "Every one of these is a capture aim that now points somewhere else. Re-point the\n" + "guards in scripts/brand-assets/**, re-shoot the imagery, and only then --write.", ); } const bad = failures.length + misaimed.length; if (bad === 0) console.log("check-chapter-identity: OK"); process.exit(bad === 0 ? 0 : 1);