Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
18 kB · 465 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466// Every masthead link goes somewhere.//// The two halves of the nav are answered by different things: a hash is a// screen main.ts renders, and a path is a page Astro builds. Neither half// notices when a link points at the other one's missing half, and the failure// is a 200 that shows the wrong page rather than an error.
import { test } from "node:test";import assert from "node:assert/strict";import { existsSync } from "node:fs";import { readFile } from "node:fs/promises";import { fileURLToPath } from "node:url";
import { ABOUT_PAGES, DESTINATIONS, NAV_GROUPS, PLAY_LABEL, RESUME_LABEL, aboutHref, isAllowed, isAppPage, isKnownAddress, lobbyIdFromHash, movedRouteForHash, routeForHash,} from "../src/destinations.ts";import { isFinished } from "../src/api.ts";
const src = fileURLToPath(new URL("../src/", import.meta.url));
test("every destination is a hash on the app page or a page of its own", () => { for (const { id, href, soon } of DESTINATIONS) { // A destination under construction has no address, which is the point of // it. The pair of them is checked below. if (soon) continue;
assert.ok( href.startsWith("/"), `${id}: href must be absolute, got ${href}`, );
if (href.startsWith("/#")) continue;
// "/blog" is built from src/pages/blog/index.astro or src/pages/blog.astro. const path = href.slice(1); const candidates = [ `${src}pages/${path}.astro`, `${src}pages/${path}/index.astro`, ]; assert.ok( candidates.some(existsSync), `${id}: ${href} has no page. Looked for ${candidates.join(" and ")}`, ); }});
test("ids are unique, so markCurrent marks one link", () => { const ids = DESTINATIONS.map((d) => d.id); assert.equal(new Set(ids).size, ids.length);});
test("the bar is groups, and every destination is in exactly one", () => { assert.equal( DESTINATIONS.length, NAV_GROUPS.flatMap((group) => group.destinations).length, ); // The first group starts the bar, so a separator on it would draw a rule // against nothing. assert.equal(NAV_GROUPS[0].separator, undefined); for (const group of NAV_GROUPS.slice(1)) { assert.ok( ["space", "rule"].includes(group.separator), `${group.id} is set off by "${group.separator}", which nothing draws`, ); }});
// --- destinations under construction --------------------------------------//// A named section with nothing behind it is a promise on the bar, and the ways// it can go wrong are all silent: an href that 404s through the not-found// screen, a link a keyboard can still reach, or a label that reads as an// ordinary destination to a screen reader because the only thing marking it is// how dim it looks.
test("a destination under construction has no address, and one with an address is not", () => { const soon = DESTINATIONS.filter((d) => d.soon); assert.ok(soon.length, "nothing on the bar is under construction any more");
for (const d of soon) { assert.equal(d.href, undefined, `${d.id} is under construction and links`); assert.equal(routeForHash(`#${d.id}`), null, `${d.id} answers as a route`); assert.equal(isKnownAddress("/", `#${d.id}`), false, `#${d.id} is live`); // Nothing to guard, so nothing may claim to be guarding it. assert.ok(!d.signedIn, `${d.id} is closed to a stranger and has no screen`); }
for (const d of DESTINATIONS.filter((d) => !d.soon)) { assert.ok( d.href, `${d.id} has no href and is not marked under construction`, ); }});
test("the masthead renders an unbuilt destination as text, and says so", async () => { const layout = await readFile(`${src}layouts/Base.astro`, "utf8"); const styles = await readFile(`${src}styles.css`, "utf8");
assert.match( layout, /d\.soon \?/, "Base.astro renders every destination as a link, so the ones with nothing " + "behind them are clickable", ); assert.match( layout, /not ready yet/, "the unbuilt destinations are marked by styling alone, which is nothing " + "to a screen reader", ); assert.match( styles, /\.nav \.soon \{/, "styles.css draws an unbuilt destination exactly like a link", );});
// --- the signed-in half of the nav ----------------------------------------//// A destination behind sign-in is four files agreeing, and every way they can// disagree is silent. The layout ships the link hidden, a stylesheet rule// decides whether "hidden" actually hides it, account.ts reveals it once it// knows who is here, and the router is the only one of the four that closes// the address itself. Nothing throws when one of them stops holding up its// end: the link is either on the bar for strangers, or missing for the player// it was built for, and both look like a design decision.
const signedInOnly = DESTINATIONS.filter((d) => d.signedIn);
test("a signed-in destination is closed to a stranger and open to a player", () => { assert.ok(signedInOnly.length, "no destination is behind sign-in any more");
for (const { id } of signedInOnly) { assert.equal(isAllowed(id, false), false, `${id} is open signed out`); assert.equal(isAllowed(id, true), true, `${id} is closed signed in`); }
// Everything else is open to everyone, including a route this list has never // heard of — the guard says what is closed, not what exists. for (const { id } of DESTINATIONS.filter((d) => !d.signedIn)) { assert.equal(isAllowed(id, false), true, `${id} is closed to a stranger`); } assert.equal(isAllowed("camo", false), true); assert.equal(isAllowed("nothing-by-this-name", false), true);
// A page under a destination is as open as the destination is. for (const page of ABOUT_PAGES) { assert.equal(isAllowed(`about/${page.id}`, false), true); }});
// Play is the one that is closed and still shown: a stranger who clicks it is// asked to sign in, which is the answer to the question they were asking. The// two beside it mean nothing to a stranger, so they are not on the bar at all.test("a link is hidden only where showing it would say nothing", () => { // Nothing on the bar is hidden today - Operations, which was, is reached // from the account menu now. The invariant still has to hold for whatever // is next, and the mechanism itself is pinned further down by the check // that Base.astro renders `hidden` from this flag: without that, a link // meant for a player would be on the bar before anything read the session. const hidden = DESTINATIONS.filter((d) => d.hiddenSignedOut); for (const { id, signedIn } of hidden) { assert.ok(signedIn, `${id} is hidden from a stranger but open to one`); }
const play = DESTINATIONS.find((d) => d.prominent); assert.ok(play, "nothing on the bar is the way into a game"); assert.equal(play.signedIn, true); assert.ok( !play.hiddenSignedOut, "the way into a game is hidden from everyone who is not already in one", );});
const hashDestinations = DESTINATIONS.filter((d) => d.href?.startsWith("/#"));
test("every page under About is an address of its own, under About", () => { for (const { id } of ABOUT_PAGES) { const href = aboutHref(id); assert.equal(routeForHash(href.slice(1)), `about/${id}`); assert.equal(isKnownAddress("/", href.slice(1)), true, `${href} is dead`); } // The hub itself, and nothing else under it. assert.equal(routeForHash("#about"), "about"); assert.equal(routeForHash("#about/nowhere"), null);});
// The FAQ was a tab of its own before About became a hub, so its old address is// out there in whatever anyone has linked. It is a redirect, not a dead link,// and it is the router that follows it.test("the FAQ's old address still leads to the FAQ", async () => { assert.equal(movedRouteForHash("#faq"), "about/faq"); assert.equal(movedRouteForHash("#/faq"), "about/faq"); assert.equal(isKnownAddress("/", "#faq"), true); assert.equal(movedRouteForHash("#about/faq"), null);
const router = await readFile(`${src}router.ts`, "utf8"); assert.match( router, /followMovedHash\(\)/, "router.ts no longer follows a moved address, so #faq is a dead link", );});
test("every hash destination is reachable by its own href, and by #/id", () => { for (const { id, href } of hashDestinations) { // Home's href is "/#", which is the bare hash and not an id at all. const hash = href.slice(1); assert.equal( routeForHash(hash) ?? "home", id, `${href} does not resolve to ${id}`, ); assert.equal(routeForHash(`#/${id}`) ?? "home", id, `#/${id} is not ${id}`); }});
// The bug this pair exists to stop: routeForHash used to be inlined in// router.ts as a Set lookup that fell through to "home", and start() marked// that answer in the masthead. So a dead link put the current-page marker on// Home, and — because the hash listener then compared "home" against "home" —// the Home link changed the address and rendered nothing.test("a hash that names no destination has no route", () => { for (const hash of ["#nowhere", "#/nowhere", "#matcheses", "#blog", "#-"]) { assert.equal(movedRouteForHash(hash), null, `${hash} is a moved address`); assert.equal(routeForHash(hash), null, `${hash} resolved to a destination`); }});
test("the site's addresses are its own paths, and nothing else", () => { // The bare root, with or without an empty hash: the OAuth callback returns // here, and so does anyone typing the domain in. assert.equal(isKnownAddress("/", ""), true); assert.equal(isKnownAddress("/", "#"), true); assert.equal(isKnownAddress("/index.html", ""), true);
for (const { href } of hashDestinations) { assert.equal(isKnownAddress("/", href.slice(1)), true, `${href} is dead`); }
assert.equal(isKnownAddress("/", "#nowhere"), false); assert.equal(isKnownAddress("/nowhere-at-all", ""), false); // A path the build writes is a page, not one of the app's addresses. The // router never runs on it, so it must not claim it either. assert.equal(isKnownAddress("/blog", ""), false);});
// --- the one parameterized address ------------------------------------//// "#lobby/<id>" is deliberately not in DESTINATIONS - a lobby is live,// ephemeral and per-visit, never a fixed place the masthead should list, and// every id is different. It is matched by shape (lobbyIdFromHash) rather// than being one more entry in the fixed list, and it must stay that way.
test("lobby is not one of the masthead's fixed destinations", () => { assert.equal( DESTINATIONS.find((d) => d.id === "lobby"), undefined, "a lobby must stay a parameterized route, not a fixed masthead entry", );});
test("lobbyIdFromHash reads the id out of #lobby/<id>, tolerating #/lobby/<id>", () => { assert.equal(lobbyIdFromHash("#lobby/abc123"), "abc123"); assert.equal(lobbyIdFromHash("#/lobby/abc123"), "abc123"); // Percent-decoded, the same as every other hash id here. assert.equal(lobbyIdFromHash("#lobby/a%2Fb"), "a/b");});
test("lobbyIdFromHash names no id for anything that is not a lobby address", () => { for (const hash of ["#lobby/", "#lobby", "#matches", "#", "", "#lobbies/1"]) { assert.equal( lobbyIdFromHash(hash), null, `${hash} should not name a lobby`, ); }});
test("a #lobby/<id> address is known; a bare #lobby/ is not", () => { assert.equal(isKnownAddress("/", "#lobby/abc123"), true); assert.equal(isKnownAddress("/", "#lobby/"), false); assert.equal(isKnownAddress("/", "#lobby"), false); // Off the app's own paths, the same as every other address. assert.equal(isKnownAddress("/blog", "#lobby/abc123"), false);});
test("a lobby id is never one of the fixed routes, and vice versa", () => { for (const { id, href } of hashDestinations) { if (!href.startsWith("/#")) continue; assert.equal( lobbyIdFromHash(href.slice(1)), null, `${href} (${id}) must not also parse as a lobby address`, ); } assert.equal(routeForHash("lobby/abc123"), null);});
test("nothing is marked in the masthead on an address the site lacks", async () => { const router = await readFile(`${src}router.ts`, "utf8"); const guard = router.slice(router.indexOf("if (!isKnownRoute())")); assert.ok(guard.length, "router.ts no longer guards on isKnownRoute()");
const branch = guard.slice(0, guard.indexOf("\n }")); assert.match( branch, /markCurrent\(null\)/, "the not-found screen leaves a destination marked as the current page", ); assert.match( branch, /shown = null/, "the not-found screen leaves `shown` at a route, so a hash change to " + "that route is read as no change and the dead end never clears", );});
test("the router is what closes the address, not the masthead", async () => { const router = await readFile(`${src}router.ts`, "utf8"); assert.match( router, /isAllowed\(route, session !== null\)/, "router.ts no longer asks destinations.ts whether the route is allowed, " + "so hiding the link is the only thing standing between a signed-out " + "visitor and a signed-in screen", );});
test("the masthead ships a signed-in link hidden, and something reveals it", async () => { const layout = await readFile(`${src}layouts/Base.astro`, "utf8"); const account = await readFile(`${src}account.ts`, "utf8"); const styles = await readFile(`${src}styles.css`, "utf8");
assert.match( layout, /hidden=\{d\.hiddenSignedOut\}/, "Base.astro renders every destination unhidden, so a link built for a " + "player is on the bar before anything has read the session", ); assert.match( layout, /data-signed-in=/, "Base.astro no longer marks the links account.ts has to find", ); assert.match( account, /a\[data-signed-in\]/, "nothing reveals the hidden links, so a signed-in player never sees them", ); // .nav a sets display: flex, which beats the browser's own rule for // [hidden]. Without this one line the attribute is decorative and the link // is on the bar for everyone. assert.match( styles, /\.nav a\[hidden\]\s*\{\s*display:\s*none;/, "styles.css lets .nav a's own display beat [hidden]", );});
// --- the way back into a match that is still going -------------------------//// The filled link says Resume instead of Play when the player has a match// still going, and the ways that goes wrong are the ways the hidden links can// go wrong plus two of its own: a request nobody asked for, on every page of a// blog and for readers who are not signed in at all, and a change of label// that only a sighted reader is told about.
test("a match is finished only at the two statuses that are an end", () => { for (const status of ["over", "failed"]) { assert.equal(isFinished({ status }), true, `${status} is still going`); } // Everything else is in progress, an unknown status included: a status this // side has never heard of keeps its way back in rather than being filed away. for (const status of ["ready", "starting", "running", "something-new", ""]) { assert.equal(isFinished({ status }), false, `${status} counts as over`); }});
test("only the app's own addresses are the app's page", () => { assert.equal(isAppPage("/"), true); assert.equal(isAppPage("/index.html"), true); // Pages the build wrote. The masthead is on them too, and this is what keeps // it from spending a request there. assert.equal(isAppPage("/blog"), false); assert.equal(isAppPage("/blog/some-post/"), false);});
test("the filled link ships as Play and has one other label", () => { const play = DESTINATIONS.find((d) => d.prominent); assert.equal( play.label, PLAY_LABEL, "the layout writes a label account.ts cannot put back", ); assert.notEqual(RESUME_LABEL, PLAY_LABEL, "the flip changes nothing");});
test("the flip costs a stranger and the blog nothing", async () => { const account = await readFile(`${src}account.ts`, "utf8");
const guard = account.indexOf("if (!session || !isAppPage("); assert.notEqual( guard, -1, "account.ts asks the API for the matches without checking who is here " + "and what page this is, so a signed-out reader and every blog page pay " + "for a masthead label", ); assert.ok( guard < account.indexOf("await listMatches()"), "the matches are read before that check, which is the same request", ); assert.match( account, /console\.warn\("account: the matches could not be read"/, "an unreachable API is not a warning here, so the bar breaks with it", );});
test("the label change is said out loud, not only painted", async () => { const layout = await readFile(`${src}layouts/Base.astro`, "utf8"); const account = await readFile(`${src}account.ts`, "utf8"); const styles = await readFile(`${src}styles.css`, "utf8");
// The region has to be in the HTML: one created and written in the same // breath announces nothing, and the link is the thing that changed. assert.match( layout, /role="status" id="nav-status"/, "the layout ships no live region, so the label changes silently under a " + "screen reader that has already read Play", ); assert.match( account, /querySelector<HTMLElement>\(NAV_STATUS\)/, "nothing writes to the live region", ); // The word carries the state for a reader; the colour carries it for the // eye. Neither may be the only one. assert.match(account, /link\.textContent = resumable \? RESUME_LABEL/);
const rule = styles.slice(styles.indexOf("\n.nav a.prominent.resume {")); assert.ok(rule.length, "styles.css draws the Resume link like the Play one"); const block = rule.slice(0, rule.indexOf("\n}")); assert.match(block, /var\(--resume\)/); assert.match(block, /var\(--on-resume\)/); assert.doesNotMatch( block, /#[0-9a-f]{3,8}/i, "the Resume colour is an inline hex, which contrast.test.mjs cannot read", );});