1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/scripts/ui-smoke.mjs
T
karti db074e9cf7 feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file
partition.

**The render rig was the quality ceiling.** The renderer ran three's
NoToneMapping default while atmosphere drove the sun to 2.35 and assets set
emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is
why walls blew out and every fitting looked like a white rectangle. ACES filmic
tone mapping and an explicit output colour space land in `stage.ts`, and the
atmosphere intensity table and palette headroom are re-tuned against the new
curve rather than left tuned for the clipping we removed.

`engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally,
so nothing binary is committed. There was no environment map anywhere before, so
every `metalness > 0` role had nothing to reflect and rendered dull grey — a
defect the code already documented against itself in `office/optimus.ts`, where a
whole material role was abandoned over it, and worked around in `modelX.ts` with
a fake emissive that this change deletes. Atmosphere remains the sole light
owner; the rig derives from the `LightingState` it already produced.

**Studio hardware exists.** There was no device concept anywhere in the product:
no type, no route, no state. `devices/types.ts` fixes a declaration/state/
capability/command contract that a smart light, a thermostat, a door sensor and a
charger all fit without a schema change, and both studios now carry a desk mic
and a computer speaker with deterministic simulated behaviour behind an adapter
seam a real API can occupy later. Reads are the demo and are open; commands are a
signed-in action and are kept off the read body entirely, because a shared cache
replaying a GET that turned a microphone on is exactly what the fail-closed
cache default exists to prevent.

**The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the
response was served publicly cacheable, and the attribution hardcoded adsb.lol
regardless of where the endpoint pointed — one env var away from republishing
non-redistributable data under an open-terms credit. The host is now allowlisted,
the credit is derived from the host actually configured, public cacheability is
conditional on redistributability, and a refused endpoint demotes to simulated
flights and says so in `degraded[]`. The gate is on the source, not the feature:
live aircraft and their detail cards stay open to anonymous visitors.

**The LA studio was never the smaller pack** — 16 rooms and 248 props against
SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were
ceiling troffers, it bound no props to seats, placed none of the habitat kit, and
12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather
than more instances, because `furnish.ts` draws once per kind and folds colour
into the batch key, so repeat instances add nothing the eye can read.

**The interface stops being forty imperative mutations.** Every visibility
decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the
chrome has coverage for the first time. Deleted: ~100 lines of CSS and two
bindings targeting elements that no longer exist, and a `body:has()` rule that
shifted the desktop layout by 160px for touch controls hidden there. Fixed: the
office picker tabs that drew their label and their badge on top of each other.
Added: a first-run flow, because the product is two verbs and neither was ever
stated on screen. Mobile is designed on its own terms instead of being the
desktop with things hidden — the plan view comes back, and the keyboard-only
shortcuts button is replaced by touch controls.

`arena/studioOps.ts` frames the whole thing as the multi-variable environment it
is, wrapping the same simulators the renderer drives rather than a headless copy.

Also removed `input/vehicle.ts`, which nothing but its own test imported.

Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six
matrix cells, no-binaries, provenance, dependency licences, zero-config boot and
arena source hashes all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:44:24 -07:00

513 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();