Public: anyone can read this; the crew can change it.
Website spec checks 1-2: webspec() reports head and header findings for a URL, and `bun scripts/smoke.ts <url> --standards` prints them #2956
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2953-website-spec-checks-1-2-webspec-reports → main 2 commits fork 5 threads · 5 unresolved
4 files
changed +326
−1
README.md +7
| @@ -314,6 +314,13 @@ never read as a pass. Spend and run bounds are proven in process only; this | ||
| 314 | 314 | pull adds the proof but does not reopen signup — the gitops gate stays for a |
| 315 | 315 | person. |
| 316 | 316 | |
| 317 | `bun scripts/smoke.ts <url> --standards` prints the website-specification | |
| 318 | report (`src/release/webspec.ts`): it fetches each path in forge.json's | |
| 319 | `app.looks` from `<url>` and prints one line per head or header finding with | |
| 320 | the specification.website section it cites. It only reports and always exits | |
| 321 | 0. `(edge)` marks a finding the edge in front of the app may own (HSTS), so | |
| 322 | fix it there rather than in the app. Each fetch gives up after 10 seconds. | |
| 323 | ||
| 317 | 324 | What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your |
| 318 | 325 | thoughts, then the issues you can write to that are not shipped, oldest first |
| 319 | 326 | (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read |
scripts/smoke.ts +16 −1
| @@ -10,6 +10,10 @@ | ||
| 10 | 10 | * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch |
| 11 | 11 | * never answers with `Response.error()`. |
| 12 | 12 | * |
| 13 | * `--standards` runs the website-specification report (`release/webspec.ts`) | |
| 14 | * over forge.json's `app.looks` paths, prints one line per finding with its | |
| 15 | * section, and exits 0: it reports, it does not gate. | |
| 16 | * | |
| 13 | 17 | * `--outsider` also signs up a fresh account and proves each thing a |
| 14 | 18 | * stranger must not do is refused over HTTP and MCP (`release/outsider.ts`, |
| 15 | 19 | * #663); the terminal door is proven by the gate. It makes one real account, so run it against a forge whose signup is open to it, with |
| @@ -17,6 +21,7 @@ | ||
| 17 | 21 | */ |
| 18 | 22 | |
| 19 | 23 | import { smoke } from "../src/release/smoke.ts"; |
| 24 | import { webspec } from "../src/release/webspec.ts"; | |
| 20 | 25 | import { outsider } from "../src/release/outsider.ts"; |
| 21 | 26 | export * from "../src/release/smoke.ts"; |
| 22 | 27 | |
| @@ -29,18 +34,28 @@ if (import.meta.main) { | ||
| 29 | 34 | }; |
| 30 | 35 | const withOutsider = args.includes("--outsider"); |
| 31 | 36 | if (withOutsider) args.splice(args.indexOf("--outsider"), 1); |
| 37 | const standards = args.includes("--standards"); | |
| 38 | if (standards) args.splice(args.indexOf("--standards"), 1); | |
| 32 | 39 | const version = flag("--version"); |
| 33 | 40 | const wait = flag("--wait"); |
| 34 | 41 | const base = args[0]; |
| 35 | 42 | const seconds = wait === undefined ? 120 : Number(wait); |
| 36 | 43 | if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { |
| 37 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider]"); | |
| 44 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--standards]"); | |
| 38 | 45 | process.exit(2); |
| 39 | 46 | } |
| 40 | 47 | if (withOutsider && !process.env["FORGE_TOKEN"]) { |
| 41 | 48 | console.error("--outsider needs FORGE_TOKEN naming the owner's repos: private reads, steering, the form door and MCP tells cannot be tried without it"); |
| 42 | 49 | process.exit(2); |
| 43 | 50 | } |
| 51 | if (standards) { | |
| 52 | const looks = (await Bun.file(new URL("../forge.json", import.meta.url)).json())?.app?.looks; | |
| 53 | const paths: string[] = Array.isArray(looks) ? looks.filter((p): p is string => typeof p === "string") : ["/"]; | |
| 54 | const findings = await webspec(base, paths); | |
| 55 | for (const f of findings) console.log(`${f.path} ${f.check}${f.edge ? " (edge)" : ""}: ${f.message} <${f.section}>`); | |
| 56 | console.log(`standards: ${findings.length} finding(s) over ${paths.length} path(s); report only`); | |
| 57 | process.exit(0); | |
| 58 | } | |
| 44 | 59 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 }); |
| 45 | 60 | for (const f of failures) |
| 46 | 61 | console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`); |
src/release/webspec.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * The website specification as a report (#2915, checks 1 and 2). `webspec()` | |
| 3 | * fetches each path of a site once and returns what the page's head and | |
| 4 | * response headers get wrong, each finding citing its specification.website | |
| 5 | * section. It reports; it never throws on a bad page and never fails a run. | |
| 6 | * The HTML is read with small regexes, one function per check. | |
| 7 | */ | |
| 8 | ||
| 9 | export interface Finding { | |
| 10 | path: string; | |
| 11 | check: string; | |
| 12 | message: string; | |
| 13 | /** The specification.website section this finding cites. */ | |
| 14 | section: string; | |
| 15 | /** Set when the CDN or edge in front of the app may own the header. */ | |
| 16 | edge?: true; | |
| 17 | } | |
| 18 | ||
| 19 | type Fetcher = (input: string, init?: RequestInit) => Promise<Response>; | |
| 20 | type Problem = { check: string; message: string; section: string; edge?: true }; | |
| 21 | ||
| 22 | const TIMEOUT_MS = 10000; | |
| 23 | const SPEC = "https://specification.website/"; | |
| 24 | const s = (name: string) => `${SPEC}#${name}`; | |
| 25 | ||
| 26 | const tags = (html: string, name: string): string[] => html.match(new RegExp(`<${name}\\b[^>]*>`, "gi")) ?? []; | |
| 27 | ||
| 28 | function attr(tag: string, name: string): string | undefined { | |
| 29 | const m = new RegExp(`(?:^|[\\s"'/])${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag); | |
| 30 | return m ? (m[1] ?? m[2] ?? m[3] ?? "") : undefined; | |
| 31 | } | |
| 32 | ||
| 33 | const metas = (html: string) => tags(html, "meta"); | |
| 34 | const metaBy = (html: string, key: "name" | "property", value: string) => | |
| 35 | metas(html).filter((t) => attr(t, key)?.toLowerCase() === value); | |
| 36 | const metaContent = (html: string, key: "name" | "property", value: string) => | |
| 37 | metaBy(html, key, value)[0] && (attr(metaBy(html, key, value)[0]!, "content") ?? ""); | |
| 38 | const links = (html: string, rel: string) => | |
| 39 | tags(html, "link").filter((t) => (attr(t, "rel") ?? "").toLowerCase().split(/\s+/).includes(rel)); | |
| 40 | ||
| 41 | export function headProblems(html: string): Problem[] { | |
| 42 | const out: Problem[] = []; | |
| 43 | const add = (check: string, message: string, section: string) => out.push({ check, message, section: s(section) }); | |
| 44 | if (!/^\s*<!doctype html>/i.test(html)) add("doctype", "the page does not start with <!doctype html>", "doctype"); | |
| 45 | const htmlTag = tags(html, "html")[0] ?? ""; | |
| 46 | if (!(attr(htmlTag, "lang") ?? "").trim()) add("lang", "<html> has no lang attribute", "lang"); | |
| 47 | const charset = metas(html).some( | |
| 48 | (t) => /^utf-8$/i.test(attr(t, "charset") ?? "") || (/content-type/i.test(attr(t, "http-equiv") ?? "") && /utf-8/i.test(attr(t, "content") ?? "")), | |
| 49 | ); | |
| 50 | if (!charset) add("charset", 'no <meta charset="utf-8">', "charset"); | |
| 51 | const viewport = metaContent(html, "name", "viewport"); | |
| 52 | if (viewport === undefined) add("viewport", "no viewport meta", "viewport"); | |
| 53 | else if (/maximum-scale\s*=|user-scalable\s*=\s*(no|0)/i.test(viewport)) | |
| 54 | add("viewport", "the viewport locks zoom (maximum-scale or user-scalable=no)", "viewport"); | |
| 55 | const titles = html.match(/<title\b[^>]*>/gi)?.length ?? 0; | |
| 56 | if (titles !== 1) add("title", `expected exactly one <title>, found ${titles}`, "title"); | |
| 57 | if (!(metaContent(html, "name", "description") ?? "").trim()) add("description", "no meta description", "description"); | |
| 58 | const canonical = links(html, "canonical").map((t) => attr(t, "href") ?? "")[0]; | |
| 59 | const ogUrl = metaContent(html, "property", "og:url"); | |
outdated
|
||
| 60 | if (!canonical) add("canonical", 'no <link rel="canonical">', "canonical"); | |
| 61 | else if (canonical !== ogUrl) add("canonical", `canonical (${canonical}) is not og:url (${ogUrl ?? "missing"})`, "canonical"); | |
| 62 | const themes = metaBy(html, "name", "theme-color").map((t) => attr(t, "media") ?? ""); | |
| 63 | const has = (scheme: string) => themes.some((m) => m.replace(/\s+/g, "").includes(`prefers-color-scheme:${scheme}`)); | |
| 64 | if (!has("light") || !has("dark")) add("theme-color", "theme-color needs a light and a dark variant (media=prefers-color-scheme)", "theme-color"); | |
| 65 | if (!(metaContent(html, "name", "color-scheme") ?? "").trim()) add("color-scheme", "no color-scheme meta", "color-scheme"); | |
| 66 | for (const p of ["og:title", "og:type", "og:url", "og:description", "og:image"]) | |
| 67 | if (!(metaContent(html, "property", p) ?? "").trim()) add("open-graph", `missing ${p}`, "open-graph"); | |
| 68 | return out; | |
| 69 | } | |
| 70 | ||
| 71 | /** Targets the head points at that must resolve: icons and the manifest. */ | |
| 72 | export function headTargets(html: string, pageUrl: string): { urls: string[]; bad: string[] } { | |
| 73 | const hrefs = [...links(html, "icon"), ...links(html, "apple-touch-icon"), ...links(html, "manifest")].map((t) => attr(t, "href") ?? ""); | |
| 74 | const urls: string[] = []; | |
| 75 | const bad: string[] = []; | |
| 76 | for (const h of hrefs) { | |
| 77 | try { | |
| 78 | const u = new URL(h, pageUrl).href; | |
| 79 | if (!urls.includes(u)) urls.push(u); | |
| 80 | } catch { | |
| 81 | bad.push(h); | |
| 82 | } | |
| 83 | } | |
outdated
Empty |
||
| 84 | return { urls, bad }; | |
| 85 | } | |
| 86 | ||
| 87 | export function headerProblems(h: Headers, https: boolean): Problem[] { | |
| 88 | const out: Problem[] = []; | |
| 89 | const add = (check: string, message: string, section: string, edge?: true) => | |
| 90 | out.push({ check, message, section: s(section), ...(edge ? { edge } : {}) }); | |
| 91 | const csp = h.get("content-security-policy"); | |
| 92 | if (!csp) add("csp", "no Content-Security-Policy", "csp"); | |
| 93 | else { | |
| 94 | if (/'unsafe-eval'/i.test(csp)) add("csp", "CSP allows 'unsafe-eval'", "csp"); | |
| 95 | const directives = csp.split(";").map((d) => d.trim()); | |
| 96 | const script = directives.find((d) => /^script-src(-elem)?\s/i.test(d)) ?? directives.find((d) => /^default-src\s/i.test(d)); | |
| 97 | if (script && /'unsafe-inline'/i.test(script)) add("csp", "CSP allows 'unsafe-inline' scripts", "csp"); | |
| 98 | if (!directives.some((d) => /^frame-ancestors\s/i.test(d))) add("frame-ancestors", "CSP has no frame-ancestors", "frame-ancestors"); | |
| 99 | } | |
| 100 | if ((h.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") add("nosniff", "X-Content-Type-Options is not nosniff", "nosniff"); | |
| 101 | if (!h.get("referrer-policy")) add("referrer-policy", "no Referrer-Policy", "referrer-policy"); | |
| 102 | if (!h.get("permissions-policy")) add("permissions-policy", "no Permissions-Policy", "permissions-policy"); | |
| 103 | if (https && !h.get("strict-transport-security")) add("hsts", "no Strict-Transport-Security on an https URL", "hsts", true); | |
| 104 | if (h.has("x-xss-protection")) add("x-xss-protection", "X-XSS-Protection is set; drop it", "x-xss-protection"); | |
| 105 | if (/\bimmutable\b/i.test(h.get("cache-control") ?? "")) add("cache-html", "HTML is cached immutable; only hashed assets may be", "caching"); | |
| 106 | return out; | |
| 107 | } | |
| 108 | ||
| 109 | /** Fetches `paths` of `base` and reports head and header findings. */ | |
| 110 | export async function webspec(base: string, paths: string[], fetcher: Fetcher = fetch): Promise<Finding[]> { | |
| 111 | const findings: Finding[] = []; | |
| 112 | for (const path of paths) { | |
| 113 | const report = (ps: Problem[]) => { | |
| 114 | for (const p of ps) findings.push({ path, ...p }); | |
| 115 | }; | |
| 116 | let url: string; | |
| 117 | try { | |
| 118 | url = new URL(path, base).href; | |
| 119 | } catch { | |
| 120 | report([{ check: "fetch", message: `${path} is not a URL`, section: SPEC }]); | |
| 121 | continue; | |
|
||
| 122 | } | |
| 123 | let res: Response; | |
| 124 | let html: string; | |
| 125 | try { | |
| 126 | res = await fetcher(url, { headers: { accept: "text/html" }, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) }); | |
outdated
No timeout: a hung server stalls the whole report. Add |
||
| 127 | html = await res.text(); | |
| 128 | } catch (e) { | |
| 129 | report([{ check: "fetch", message: `could not fetch ${url}: ${e instanceof Error ? e.message : String(e)}`, section: SPEC }]); | |
| 130 | continue; | |
| 131 | } | |
| 132 | if (!res.ok) { | |
| 133 | report([{ check: "fetch", message: `${url} answered ${res.status}`, section: SPEC }]); | |
| 134 | continue; | |
| 135 | } | |
| 136 | report(headProblems(html)); | |
| 137 | const { urls, bad } = headTargets(html, url); | |
| 138 | for (const h of bad) report([{ check: "head-links", message: `"${h}" is not a URL`, section: s("icons") }]); | |
| 139 | for (const target of urls) { | |
| 140 | const status = await fetcher(target, { signal: AbortSignal.timeout(TIMEOUT_MS) }).then( | |
| 141 | async (r) => (await r.body?.cancel(), r.status), | |
| 142 | () => 0, | |
| 143 | ); | |
| 144 | if (status < 200 || status >= 300) report([{ check: "head-links", message: `${target} answered ${status || "nothing"}`, section: s("icons") }]); | |
| 145 | } | |
| 146 | report(headerProblems(res.headers, new URL(url).protocol === "https:")); | |
| 147 | const etag = res.headers.get("etag"); | |
| 148 | if (!etag) report([{ check: "etag", message: "no ETag on the HTML", section: s("caching") }]); | |
| 149 | else { | |
| 150 | const again = await fetcher(url, { headers: { accept: "text/html", "if-none-match": etag }, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) }).then( | |
| 151 | async (r) => (await r.body?.cancel(), r.status), | |
| 152 | () => 0, | |
| 153 | ); | |
| 154 | if (again !== 304) report([{ check: "etag", message: `If-None-Match answered ${again || "nothing"}, not 304`, section: s("caching") }]); | |
| 155 | } | |
| 156 | } | |
| 157 | return findings; | |
| 158 | } |
test/webspec.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { webspec } from "../src/release/webspec.ts"; | |
| 3 | ||
| 4 | const GOOD_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"><title>Home</title> | |
| 6 | <meta name="description" content="d"><link rel="canonical" href="https://x.test/"> | |
| 7 | <meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)"> | |
| 8 | <meta name="theme-color" content="#000" media="(prefers-color-scheme: dark)"> | |
| 9 | <meta name="color-scheme" content="light dark"> | |
| 10 | <meta property="og:title" content="t"><meta property="og:type" content="website"><meta property="og:url" content="https://x.test/"> | |
| 11 | <meta property="og:description" content="d"><meta property="og:image" content="https://x.test/og.png"> | |
| 12 | <link rel="icon" href="/icon.svg"><link rel="manifest" href="/m.json"></head><body>hi</body></html>`; | |
| 13 | ||
| 14 | const GOOD_HEADERS: Record<string, string> = { | |
| 15 | "content-type": "text/html", | |
| 16 | "content-security-policy": "default-src 'self'; script-src 'self'; frame-ancestors 'none'", | |
| 17 | "x-content-type-options": "nosniff", | |
| 18 | "referrer-policy": "same-origin", | |
| 19 | "permissions-policy": "camera=()", | |
| 20 | "strict-transport-security": "max-age=63072000", | |
| 21 | "cache-control": "no-cache", | |
| 22 | etag: '"v1"', | |
| 23 | }; | |
| 24 | ||
| 25 | function site(html = GOOD_HTML, headers: Record<string, string> = GOOD_HEADERS, extra: Record<string, number> = {}) { | |
| 26 | return async (input: string, init?: RequestInit) => { | |
| 27 | const u = new URL(input); | |
| 28 | if (u.pathname in extra) return new Response("x", { status: extra[u.pathname]! }); | |
| 29 | if (u.pathname === "/icon.svg" || u.pathname === "/m.json") return new Response("x"); | |
| 30 | const h = new Headers(init?.headers); | |
| 31 | if (h.get("if-none-match") && headers["etag"] === h.get("if-none-match")) return new Response(null, { status: 304 }); | |
| 32 | return new Response(html, { headers }); | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | const checks = async (html?: string, headers?: Record<string, string>, extra?: Record<string, number>) => | |
| 37 | (await webspec("https://x.test", ["/"], site(html, headers, extra))).map((f) => f.check); | |
| 38 | const without = (k: string) => Object.fromEntries(Object.entries(GOOD_HEADERS).filter(([n]) => n !== k)); | |
| 39 | ||
| 40 | test("a page meeting every check has no findings", async () => { | |
| 41 | expect(await webspec("https://x.test", ["/"], site())).toEqual([]); | |
| 42 | }); | |
| 43 | ||
| 44 | test("each head check yields exactly its finding", async () => { | |
| 45 | const cases: [string, string, string][] = [ | |
| 46 | ["doctype", GOOD_HTML.replace("<!doctype html>", ""), "doctype"], | |
| 47 | ["lang", GOOD_HTML.replace(' lang="en"', ""), "lang"], | |
| 48 | ["charset", GOOD_HTML.replace('<meta charset="utf-8">', ""), "charset"], | |
| 49 | ["zoom", GOOD_HTML.replace("initial-scale=1", "maximum-scale=1"), "viewport"], | |
| 50 | ["user-scalable", GOOD_HTML.replace("initial-scale=1", "user-scalable=no"), "viewport"], | |
| 51 | ["two titles", GOOD_HTML.replace("</head>", "<title>b</title></head>"), "title"], | |
| 52 | ["no title", GOOD_HTML.replace("<title>Home</title>", ""), "title"], | |
| 53 | ["description", GOOD_HTML.replace('<meta name="description" content="d">', ""), "description"], | |
| 54 | ["canonical missing", GOOD_HTML.replace(/<link rel="canonical"[^>]*>/, ""), "canonical"], | |
| 55 | ["canonical differs", GOOD_HTML.replace('canonical" href="https://x.test/"', 'canonical" href="https://x.test/a"'), "canonical"], | |
| 56 | ["dark theme", GOOD_HTML.replace(/<meta name="theme-color" content="#000"[^>]*>/, ""), "theme-color"], | |
| 57 | ["color-scheme", GOOD_HTML.replace('<meta name="color-scheme" content="light dark">', ""), "color-scheme"], | |
| 58 | ["og:image", GOOD_HTML.replace(/<meta property="og:image"[^>]*>/, ""), "open-graph"], | |
| 59 | ]; | |
| 60 | for (const [name, html, check] of cases) expect([name, await checks(html)]).toEqual([name, [check]]); | |
| 61 | }); | |
| 62 | ||
| 63 | test("icons and the manifest must resolve", async () => { | |
| 64 | expect(await checks(undefined, undefined, { "/icon.svg": 404 })).toEqual(["head-links"]); | |
| 65 | expect(await checks(undefined, undefined, { "/m.json": 500 })).toEqual(["head-links"]); | |
| 66 | }); | |
| 67 | ||
| 68 | test("each header check yields exactly its finding", async () => { | |
| 69 | const h = (k: string, v: string) => ({ ...GOOD_HEADERS, [k]: v }); | |
| 70 | const cases: [string, Record<string, string>, string][] = [ | |
| 71 | ["csp missing", without("content-security-policy"), "csp"], | |
| 72 | ["unsafe-eval", h("content-security-policy", "script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"), "csp"], | |
| 73 | ["unsafe-inline script", h("content-security-policy", "script-src 'self' 'unsafe-inline'; frame-ancestors 'none'"), "csp"], | |
| 74 | ["frame-ancestors", h("content-security-policy", "script-src 'self'"), "frame-ancestors"], | |
| 75 | ["nosniff", without("x-content-type-options"), "nosniff"], | |
| 76 | ["referrer", without("referrer-policy"), "referrer-policy"], | |
| 77 | ["permissions", without("permissions-policy"), "permissions-policy"], | |
| 78 | ["xss", h("x-xss-protection", "1"), "x-xss-protection"], | |
| 79 | ["immutable html", h("cache-control", "public, max-age=1, immutable"), "cache-html"], | |
| 80 | ["etag", without("etag"), "etag"], | |
| 81 | ]; | |
| 82 | for (const [name, headers, check] of cases) expect([name, await checks(undefined, headers)]).toEqual([name, [check]]); | |
| 83 | }); | |
| 84 | ||
| 85 | test("style-src unsafe-inline is not a script finding", async () => { | |
| 86 | const csp = "script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"; | |
| 87 | expect(await checks(undefined, { ...GOOD_HEADERS, "content-security-policy": csp })).toEqual([]); | |
| 88 | }); | |
| 89 | ||
| 90 | test("HSTS is an edge finding on https only", async () => { | |
| 91 | const found = await webspec("https://x.test", ["/"], site(GOOD_HTML, without("strict-transport-security"))); | |
| 92 | expect(found.map((f) => [f.check, f.edge])).toEqual([["hsts", true]]); | |
| 93 | expect(await webspec("http://x.test", ["/"], site(GOOD_HTML, without("strict-transport-security")))).toEqual([]); | |
| 94 | }); | |
| 95 | ||
| 96 | test("an ETag that does not answer 304 is a finding", async () => { | |
| 97 | const f = async (input: string) => | |
| 98 | input.endsWith(".svg") || input.endsWith(".json") ? new Response("x") : new Response(GOOD_HTML, { headers: GOOD_HEADERS }); | |
| 99 | const found = await webspec("https://x.test", ["/"], f); | |
| 100 | expect(found.map((x) => x.check)).toEqual(["etag"]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("findings are plain data citing a section; failures to fetch are findings", async () => { | |
| 104 | const [f] = await webspec("https://x.test", ["/a"], site(GOOD_HTML.replace(' lang="en"', ""))); | |
| 105 | expect(f).toEqual({ path: "/a", check: "lang", message: expect.any(String), section: "https://specification.website/#lang" }); | |
| 106 | const down = await webspec("https://x.test", ["/"], async () => { | |
| 107 | throw new Error("boom"); | |
| 108 | }); | |
| 109 | expect(down.map((x) => x.check)).toEqual(["fetch"]); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a page that does not answer 2xx is a fetch finding", async () => { | |
| 113 | const f = async () => new Response("nope", { status: 500 }); | |
| 114 | const found = await webspec("https://x.test", ["/"], f); | |
| 115 | expect(found.map((x) => x.check)).toEqual(["fetch"]); | |
| 116 | }); | |
| 117 | ||
| 118 | test("default-src 'unsafe-inline' counts when there is no script-src", async () => { | |
| 119 | const csp = "default-src 'self' 'unsafe-inline'; frame-ancestors 'none'"; | |
| 120 | expect(await checks(undefined, { ...GOOD_HEADERS, "content-security-policy": csp })).toEqual(["csp"]); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the http-equiv charset form is accepted", async () => { | |
| 124 | const html = GOOD_HTML.replace('<meta charset="utf-8">', '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">'); | |
| 125 | expect(await checks(html)).toEqual([]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("apple-touch-icon must resolve, and an invalid href is a finding", async () => { | |
| 129 | const html = GOOD_HTML.replace("</head>", '<link rel="apple-touch-icon" href="/touch.png"></head>'); | |
| 130 | expect(await checks(html, undefined, { "/touch.png": 404 })).toEqual(["head-links"]); | |
| 131 | expect(await checks(html, undefined, { "/touch.png": 200 })).toEqual([]); | |
| 132 | const bad = GOOD_HTML.replace("</head>", '<link rel="icon" href="http://[bad"></head>'); | |
| 133 | expect(await checks(bad)).toEqual(["head-links"]); | |
| 134 | }); | |
| 135 | ||
| 136 | test("every fetch carries a timeout signal", async () => { | |
| 137 | const seen: boolean[] = []; | |
| 138 | const inner = site(); | |
| 139 | await webspec("https://x.test", ["/"], async (u, init) => { | |
| 140 | seen.push(init?.signal instanceof AbortSignal); | |
| 141 | return inner(u, init); | |
| 142 | }); | |
| 143 | expect(seen.length).toBeGreaterThan(3); | |
| 144 | expect(seen.every(Boolean)).toBe(true); | |
| 145 | }); |
new above
offline
Anchors are guesses (scout said so). Check them against the real page before anyone relies on the output.