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