From 7d68ff00e2873306e8f4e722243fa39a205d7987 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 12 Aug 2026 17:41:01 -0400 Subject: [PATCH] fix(web): keep the front page up when the control plane is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router read the session before putting Home up, and a failed read went to the error screen. Everything on Home but the sign-in panel — the headline, the pitch, the three facts, the footer — is in the bundle that was just served off S3 and asks the API for nothing, so an API outage was telling strangers the site was broken instead of what the site is. Home now takes a panel rather than a session, and the third state fills the sign-in form's place with the message and a Try again. Not the form with an error over it: whatever stopped /api/session will stop /api/login too, which was the error screen's reason and is the half worth keeping. Matches still gets the error screen, having nothing to show without the API. --- TODO.md | 13 +++ web/scripts/outage-panel.fixture.ts | 7 ++ web/scripts/outage-panel.test.mjs | 132 ++++++++++++++++++++++++++++ web/src/outage-panel.ts | 46 ++++++++++ web/src/router.ts | 45 +++++++--- web/src/screens/home.ts | 44 ++++++++-- web/src/styles.css | 9 +- 7 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 web/scripts/outage-panel.fixture.ts create mode 100644 web/scripts/outage-panel.test.mjs create mode 100644 web/src/outage-panel.ts diff --git a/TODO.md b/TODO.md index 906d2d5..26cab8f 100644 --- a/TODO.md +++ b/TODO.md @@ -232,6 +232,19 @@ rediscovering them: `web/src/signin-form.ts`, and what it says about the grant is already true. The waiting screen's lines are off the list too: they rotate real trivia from `web/src/content/trivia.csv` now. +- [x] **An unreachable API used to take the front page down with it.** The + router read the session before putting Home up, and a failed read went to + the error screen — so a stranger arriving while headquarters-api was down + was told the site was broken rather than what the site is, even though + the headline, the pitch, the three facts and the footer had all just been + served off S3 and asked the API for nothing. + + Home now keeps all of that and replaces one panel: the sign-in form's + place holds the message and a Try again, from `web/src/outage-panel.ts`. + Not the form with an error over it — whatever stopped `/api/session` will + stop `/api/login` too, which was the original reason for the error + screen and is the half worth keeping. Matches still gets the error + screen, because that screen is nothing but what the API answers. - [ ] **The hero's host list goes stale on its own.** The pitch ends on a name that rotates through account hosts — `HOSTS` in `web/src/host-cycle.ts`, checked against the community PDS list on diff --git a/web/scripts/outage-panel.fixture.ts b/web/scripts/outage-panel.fixture.ts new file mode 100644 index 0000000..922449f --- /dev/null +++ b/web/scripts/outage-panel.fixture.ts @@ -0,0 +1,7 @@ +/** + * The panel the hero shows when the control plane cannot be reached. + * + * Bundled by outage-panel.test.mjs, the way profile.fixture.ts is, because the + * module under test is TypeScript with extensionless imports. + */ +export { outagePanel } from "../src/outage-panel"; diff --git a/web/scripts/outage-panel.test.mjs b/web/scripts/outage-panel.test.mjs new file mode 100644 index 0000000..f6502c7 --- /dev/null +++ b/web/scripts/outage-panel.test.mjs @@ -0,0 +1,132 @@ +/** + * The panel that stands in for the sign-in form when the API is down must not + * be a sign-in form. + * + * The whole reason the front page used to throw itself away during an outage + * was the reasoning in router.ts: a form that cannot submit is worse than no + * form, so rather than show one the router showed the error screen instead — + * and took the headline, the pitch and the three facts down with it, none of + * which ever asked the API for anything. + * + * Keeping the page therefore only works while the panel keeps the other half + * of that bargain. A field added here later would collect a handle and fail + * with it, silently and on the front page, so this asserts the absence: no + * form, no input, and an alert a screen reader is actually told about. + * + * A real test rather than a source read, which outage-panel.ts is written to + * allow: it imports ./dom and nothing else, so the fake below is enough. Its + * neighbours in the hero cannot be tested this way — chrome.ts asks the API + * for its version at import time, which is right in a browser and wrong here. + * + * The module under test is TypeScript with extensionless imports, so this + * bundles it the way profile.test.mjs does. Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +// The same shape dom.test.mjs uses, plus the listener el() never touches and +// this panel's button does. +class FakeElement { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.attributes = {}; + this.children = []; + this.listeners = {}; + } + setAttribute(name, value) { + this.attributes[name] = value; + } + append(...nodes) { + this.children.push(...nodes); + } + addEventListener(type, handler) { + this.listeners[type] = handler; + } + /** Every element in the tree, this one included. */ + *walk() { + yield this; + for (const child of this.children) if (child.walk) yield* child.walk(); + } +} + +globalThis.document = { createElement: (tag) => new FakeElement(tag) }; + +const run = promisify(execFile); +const web = join(new URL(".", import.meta.url).pathname, ".."); + +const dir = await mkdtemp(join(tmpdir(), "outage-panel-")); +const bundle = join(dir, "fixture.mjs"); +await run( + join(web, "node_modules/.bin/rolldown"), + [ + join(web, "scripts/outage-panel.fixture.ts"), + "--format", + "esm", + "--platform", + "node", + "--file", + bundle, + ], + { cwd: web }, +); + +const { outagePanel } = await import(bundle); + +/** The message api.ts writes when fetch itself could not reach the API. */ +const MESSAGE = "Cannot reach the control plane."; + +test("the panel asks for nothing", () => { + const panel = outagePanel(MESSAGE, () => {}); + const tags = [...panel.walk()].map((node) => node.tagName); + + for (const asking of ["FORM", "INPUT", "SELECT", "TEXTAREA"]) { + assert.ok( + !tags.includes(asking), + `the outage panel grew a <${asking.toLowerCase()}> — whatever stopped ` + + `/api/session will stop the submit too`, + ); + } +}); + +test("the message is announced, not just printed", () => { + const panel = outagePanel(MESSAGE, () => {}); + // A property and not an attribute, because `role` has no hyphen and el() + // splits on that — which is a real assignment in a browser, where role is + // reflected. See dom.test.mjs for why that split exists at all. + const alert = [...panel.walk()].find((node) => node.role === "alert"); + + assert.ok( + alert, + "nothing on the panel is an alert — a player who pressed Sign in and got " + + "this panel back is told nothing", + ); + assert.equal(alert.textContent, MESSAGE); +}); + +test("try again runs the retry, once per press", () => { + let asked = 0; + const panel = outagePanel(MESSAGE, () => { + asked += 1; + }); + const button = [...panel.walk()].find((node) => node.tagName === "BUTTON"); + + assert.ok(button, "the panel has no way out of the outage"); + // type="button" and not a submit: there is no form, and a bare