// 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/" 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/, tolerating #/lobby/", () => { 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/ 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\(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", ); });