/** * Three things about measurement that break silently. * * None of these fail loudly. Autocapture keeps working while it quietly files * a player's handle; the pageview count keeps arriving while it counts each * screen change twice or not at all; the bundle keeps building while it has * grown by a quarter of a megabyte on every page. Each is a one-line edit away * and none of them errors, so each gets a test. * * Run with `npm test`. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; const web = join(new URL(".", import.meta.url).pathname, ".."); const read = (path) => readFile(join(web, path), "utf8"); const analytics = await read("src/analytics.ts"); const layout = await read("src/layouts/Base.astro"); const router = await read("src/router.ts"); const signinForm = await read("src/signin-form.ts"); test("only the site itself reports", () => { // The key is committed and this repository is public, so the guard against a // fork filing its traffic into our project is this hostname and nothing // else. Loosen it to "not localhost" and every deployment of this repo, // including every fork, starts counting towards our numbers. const host = analytics.match(/SITE_HOST = "([^"]+)"/)?.[1]; assert.equal(host, "lance.blue", "SITE_HOST is no longer the site"); assert.match( analytics, /window\.location\.hostname === SITE_HOST/, "the hostname guard is not an equality check against SITE_HOST", ); }); test("the mark a sign-in leaves is the mark that is looked for", () => { // Two files, one string, and no import between them: signin-form.ts writes it // on the way to the provider and analytics.ts reads it on the way back. // Change one spelling and nothing errors — sign-ins simply stop being // counted, while every other number keeps arriving. const written = signinForm.match(/sessionStorage\.setItem\("([^"]+)"/)?.[1]; const looked = analytics.match(/SIGNING_IN = "([^"]+)"/)?.[1]; assert.ok(written, "signin-form.ts no longer marks the redirect"); assert.ok(looked, "analytics.ts no longer declares SIGNING_IN as a literal"); assert.equal( written, looked, "the two spellings drifted; sign-ins would stop being counted", ); }); test("what identifies a player is the hash, never the DID", () => { // The whole point of pseudonym(). identify(session.did) would put a public // identifier that resolves to a person's entire repository into a vendor's // store, and it is a plausible-looking one-line edit. const args = [...analytics.matchAll(/\.identify\((.*)\)/g)].map((m) => m[1]); assert.ok(args.length > 0, "nothing identifies a player any more"); for (const arg of args) { assert.match( arg, /^await pseudonym\(/, `identify() is called with \`${arg}\`, which did not come from pseudonym()`, ); } }); test("everything that shows who someone is opts out of autocapture", () => { // Autocapture sends the visible text of whatever was clicked. These are the // two subtrees where that text is a handle: the masthead once account.ts has // filled it in, and the sign-in form, whose typeahead suggests the player // their own address. ph-no-capture is what stops it, and it has to be on the // container rather than the element, because neither one is in the HTML the // build wrote. assert.match( layout, /class="account ph-no-capture"/, "#account lost ph-no-capture; the signed-in handle would be captured on click", ); assert.match( signinForm, /signin ph-no-capture/, "the sign-in form lost ph-no-capture; the typeahead suggests the player's own handle", ); }); test("pageviews are counted once, by the router", () => { // The pairing matters more than either half. This router navigates by // hashchange, which PostHog's automatic pageview does not watch, so the // automatic one is off and router.ts does the counting. Turn it back on // without removing the call and every screen change is counted twice; remove // the call without turning it back on and a whole session reports as one // pageview. assert.match( analytics, /capture_pageview:\s*false/, "automatic pageviews are back on; screen changes would be counted twice", ); assert.match( router, /capturePageview\(\)/, "router.ts stopped counting screen changes; a session would report one pageview", ); }); test("posthog-js stays out of the bundle every page loads", () => { // 242 KB against 83 KB for the whole app. A static import puts it in front // of the page on every visit including the blog's; the dynamic one below // makes it a chunk that only a build which actually reports ever asks for. assert.doesNotMatch( analytics, /^import posthog from "posthog-js"/m, "posthog-js is imported statically; it would ship on every page ahead of the page", ); assert.match( analytics, /await import\("posthog-js"\)/, "the dynamic import went away", ); }); test("the built pages do not fetch posthog-js up front", async () => { // The source can be right and the build still wrong: a preload hint, or a // chunking change that folds the library back into a shared chunk, would // fetch it eagerly and undo the import above. So assert against what // shipped. // // By name, not by weight. This was a size ceiling, on the reasoning that // nothing else the site builds comes near the library's 235 KB - and then // the front page's own eager bundle grew to 151 KB of perfectly innocent // application code and daily-challenge data, and the ceiling failed a build // that had nothing wrong with it. The library writes its own name into its // chunk a hundred and fifty times over; that is the thing to look for, and // it does not drift as the app grows. const assets = (await readdir(join(web, "dist", "assets"))).filter((f) => f.endsWith(".js"), ); for (const page of ["dist/index.html", "dist/blog/index.html"]) { const html = await read(page); for (const asset of assets) { if (!html.includes(asset)) continue; const code = await read(join("dist", "assets", asset)); assert.ok( !/posthog/i.test(code), `${page} fetches ${asset} up front and posthog-js is in it`, ); } } }); test("errors leave the browser", () => { // The other silent break: turn this off and the site keeps working, the // console keeps logging, and the exception stream just stops. main.ts's // trap renders the recovery screen but reports nothing off-box; this option // is the only thing that does. assert.match( analytics, /capture_exceptions:\s*true/, "exception capture is off - crashes in the field are invisible again", ); });