Public: anyone can read this; the crew can change it.
One test walks every public page and fails any that lacks a title, description, og tags or canonical URL #9637
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9625-one-test-walks-every-public-page-and-fai → main 1 commit fork
1 file
changed +138
−0
test/public-head.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp as createForge, type App } from "../src/app.ts"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { getRepo } from "../src/core/repos.ts"; | |
| 8 | import { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { catalog } from "../src/packs/catalog.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | ||
| 14 | /** The content of the first tag matching, or undefined when there is none. */ | |
| 15 | function meta(html: string, key: "name" | "property", value: string): string | undefined { | |
| 16 | for (const m of html.matchAll(/<meta\s[^>]*>/g)) { | |
| 17 | const tag = m[0]; | |
| 18 | if (!new RegExp(`${key}="${value}"`).test(tag)) continue; | |
| 19 | return /content="([^"]*)"/.exec(tag)?.[1]; | |
| 20 | } | |
| 21 | return undefined; | |
| 22 | } | |
| 23 | ||
| 24 | function canonical(html: string): string | undefined { | |
| 25 | for (const m of html.matchAll(/<link\s[^>]*>/g)) | |
| 26 | if (/rel="canonical"/.test(m[0])) return /href="([^"]*)"/.exec(m[0])?.[1]; | |
| 27 | return undefined; | |
| 28 | } | |
| 29 | ||
| 30 | /** Every missing or empty head tag on a page, as "tag" names. */ | |
| 31 | function headProblems(html: string, origin: string): string[] { | |
| 32 | const out: string[] = []; | |
| 33 | const title = /<title>([\s\S]*?)<\/title>/.exec(html)?.[1]; | |
| 34 | if (!title?.trim()) out.push("<title>"); | |
| 35 | for (const [label, key, value] of [ | |
| 36 | ["meta name=description", "name", "description"], | |
| 37 | ["og:title", "property", "og:title"], | |
| 38 | ["og:description", "property", "og:description"], | |
| 39 | ["og:image", "property", "og:image"], | |
| 40 | ] as const) | |
| 41 | if (!meta(html, key, value)?.trim()) out.push(label); | |
| 42 | const c = canonical(html); | |
| 43 | if (!c?.trim()) out.push("link rel=canonical"); | |
| 44 | else { | |
| 45 | let ok = false; | |
| 46 | try { | |
| 47 | ok = new URL(c).origin === origin && c.startsWith(origin + "/"); | |
| 48 | } catch {} | |
| 49 | if (!ok) out.push(`link rel=canonical (${c} is not an absolute URL on ${origin})`); | |
| 50 | } | |
| 51 | return out; | |
| 52 | } | |
| 53 | ||
| 54 | describe("every public page names itself", () => { | |
| 55 | let dataDir: string; | |
| 56 | let app: App; | |
| 57 | let servers: Servers; | |
| 58 | let base: string; | |
| 59 | let pages: string[] = []; | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-publichead-")); | |
| 63 | app = createForge({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | base = servers.url; | |
| 66 | const u = await createUser(app.db, { | |
| 67 | origin: "seed", | |
| 68 | trusted: true, | |
| 69 | username: "alice", | |
| 70 | email: "[email protected]", | |
| 71 | password: "alice-passphrase", | |
| 72 | adminIfFirst: true, | |
| 73 | }); | |
| 74 | if (!u.ok) throw new Error(u.error); | |
| 75 | for (const name of ["shown", "widget"]) { | |
| 76 | const made = await createRepo(app, u.value, { name, visibility: "public" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | } | |
| 79 | const shown = getRepo(app.db, "alice", "shown")!.id; | |
| 80 | for (const kind of ["issue", "pull"] as const) { | |
| 81 | const i = createIssue(app.db, { repoId: shown, authorId: u.value.id, title: `A ${kind}`, kind }); | |
| 82 | if (!i.ok) throw new Error(String(i.error)); | |
| 83 | if (kind === "pull") | |
| 84 | app.db | |
| 85 | .query( | |
| 86 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) | |
| 87 | VALUES (?1, 'main', 'topic', '', '', ?2)`, | |
| 88 | ) | |
| 89 | .run(i.value.id, shown); | |
| 90 | } | |
| 91 | const rec = createAppRecord(app.db, { repoId: getRepo(app.db, "alice", "widget")!.id, slug: "widget", access: "public" }); | |
| 92 | if (!rec.ok) throw new Error(rec.error); | |
| 93 | const pack = catalog().packs[0]; | |
| 94 | if (!pack) throw new Error("the catalog ships no pack to walk"); | |
| 95 | // Walked as a stranger. Left out, on purpose: redirects to sign in (/map, | |
| 96 | // /new, /board, /feed...), feeds and files (/changelog.atom, /sitemap.xml), | |
| 97 | // and /signin-style forms, which og-tags.test.ts already covers. | |
| 98 | pages = [ | |
| 99 | "/", | |
| 100 | "/explore", | |
| 101 | "/alice/shown", | |
| 102 | "/alice/widget", | |
| 103 | "/alice/shown/issues/1", | |
| 104 | "/alice/shown/pulls/2", | |
| 105 | "/packs", | |
| 106 | `/packs/${pack.name}`, | |
| 107 | "/api", // the API reference: the docs page a stranger can open | |
| 108 | "/agents", // the page for AI assistants | |
| 109 | "/changelog", | |
| 110 | ]; | |
| 111 | }); | |
| 112 | ||
| 113 | afterAll(async () => { | |
| 114 | await servers.stop(); | |
| 115 | await rm(dataDir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | test("the head checker names what is missing", () => { | |
| 119 | const bad = `<html><head><title> </title><link rel="canonical" href="https://elsewhere.test/x"></head></html>`; | |
| 120 | const found = headProblems(bad, "http://forge.test"); | |
| 121 | expect(found).toContain("<title>"); | |
| 122 | expect(found).toContain("og:image"); | |
| 123 | expect(found.some((f) => f.startsWith("link rel=canonical (https://elsewhere.test/x"))).toBe(true); | |
| 124 | }); | |
| 125 | ||
| 126 | test("home, explore, repo, app, issue, pull, packs, docs and changelog carry a full head", async () => { | |
| 127 | const failures: string[] = []; | |
| 128 | for (const path of pages) { | |
| 129 | const res = await fetch(base + path, { redirect: "manual" }); | |
| 130 | if (res.status !== 200) { | |
| 131 | failures.push(`${path}: answered ${res.status}, not a page`); | |
| 132 | continue; | |
| 133 | } | |
| 134 | for (const p of headProblems(await res.text(), new URL(base).origin)) failures.push(`${path}: ${p}`); | |
| 135 | } | |
| 136 | expect(failures).toEqual([]); | |
| 137 | }); | |
| 138 | }); |