Public: anyone can read this; the crew can change it.
scripts/smoke.ts waits for --version on /healthz and checks that /sw.js never answers a navigation with an error #2565
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2423-scripts-smoke-ts-waits-for-version-on-he → main 1 commit fork
2 files
changed +126
−6
scripts/smoke.ts +76 −5
| @@ -4,6 +4,11 @@ | ||
| 4 | 4 | * `/healthz` is fetched once per user agent and must answer 200; the |
| 5 | 5 | * stylesheet linked from `/` must be a non-empty `text/css` 200. Exit 1 and |
| 6 | 6 | * one line per failure (url, user agent, status, content type) otherwise. |
| 7 | * | |
| 8 | * `--version <v>` first polls `/healthz` until `X-Forge-Version` is `<v>`, for | |
| 9 | * at most `--wait <seconds>` (default 120), and fails naming the last version | |
| 10 | * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch | |
| 11 | * never answers with `Response.error()`. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | 14 | export const AGENTS: Record<string, string> = { |
| @@ -32,11 +37,56 @@ export function stylesheetHref(html: string): string | null { | ||
| 32 | 37 | return null; |
| 33 | 38 | } |
| 34 | 39 | |
| 40 | /** The part of a service worker that answers navigations, comments removed. */ | |
| 41 | export function navigationBranch(source: string): string | null { | |
| 42 | const at = source.search(/mode\s*===?\s*["']navigate["']/); | |
| 43 | if (at < 0) return null; | |
| 44 | const rest = source.slice(at); | |
| 45 | const end = rest.search(/\breturn\s*;/); | |
| 46 | return (end < 0 ? rest : rest.slice(0, end)).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); | |
| 47 | } | |
| 48 | ||
| 49 | export interface Options { | |
| 50 | /** Wait for `/healthz` to report this `X-Forge-Version`. */ | |
| 51 | version?: string | undefined; | |
| 52 | /** Longest wait for it, in milliseconds. */ | |
| 53 | waitMs?: number; | |
| 54 | /** Pause between polls, in milliseconds. */ | |
| 55 | pollMs?: number; | |
| 56 | } | |
| 57 | ||
| 58 | /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */ | |
| 59 | async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number): Promise<Failure | null> { | |
| 60 | const url = new URL("/healthz", root).href; | |
| 61 | const deadline = Date.now() + waitMs; | |
| 62 | let seen = "none"; | |
| 63 | let status: number | string = "error"; | |
| 64 | for (;;) { | |
| 65 | try { | |
| 66 | const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) }); | |
| 67 | await res.body?.cancel(); | |
| 68 | status = res.status; | |
| 69 | seen = res.headers.get("x-forge-version") ?? "none"; | |
| 70 | if (seen === version) return null; | |
| 71 | } catch (error) { | |
| 72 | status = "error"; | |
| 73 | seen = `none (${error})`; | |
| 74 | } | |
| 75 | if (Date.now() + pollMs > deadline) break; | |
| 76 | await Bun.sleep(pollMs); | |
| 77 | } | |
| 78 | return { url, agent: "-", status, contentType: "", why: `wanted X-Forge-Version ${version}, last saw ${seen}` }; | |
| 79 | } | |
| 80 | ||
| 35 | 81 | /** Runs every check against `base`; returns the failures (empty is green). */ |
| 36 | export async function smoke(base: string, token = ""): Promise<Failure[]> { | |
| 82 | export async function smoke(base: string, token = "", options: Options = {}): Promise<Failure[]> { | |
| 37 | 83 | const root = new URL(base); |
| 38 | 84 | if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)"); |
| 39 | 85 | const failures: Failure[] = []; |
| 86 | if (options.version) { | |
| 87 | const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000); | |
| 88 | if (late) return [late]; | |
| 89 | } | |
| 40 | 90 | |
| 41 | 91 | const get = async (path: string, agent: string, headers: Record<string, string> = {}) => { |
| 42 | 92 | const url = new URL(path, root).href; |
| @@ -79,16 +129,37 @@ export async function smoke(base: string, token = ""): Promise<Failure[]> { | ||
| 79 | 129 | const health = await get("/healthz", agent); |
| 80 | 130 | if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200"); |
| 81 | 131 | } |
| 132 | ||
| 133 | const sw = await get("/sw.js", "desktop"); | |
| 134 | if (sw) { | |
| 135 | const type = sw.res.headers.get("content-type") ?? ""; | |
| 136 | if (sw.res.status !== 200) fail(sw.url, "desktop", sw.res, "expected 200"); | |
| 137 | else if (!/^(text|application)\/(x-)?javascript\b/i.test(type)) fail(sw.url, "desktop", sw.res, "expected javascript"); | |
| 138 | else { | |
| 139 | const nav = navigationBranch(sw.body); | |
| 140 | if (nav === null) fail(sw.url, "desktop", sw.res, "no navigation branch"); | |
| 141 | else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()"); | |
| 142 | } | |
| 143 | } | |
| 82 | 144 | return failures; |
| 83 | 145 | } |
| 84 | 146 | |
| 85 | 147 | if (import.meta.main) { |
| 86 | const base = process.argv[2]; | |
| 87 | if (!base) { | |
| 88 | console.error("usage: bun scripts/smoke.ts <url>"); | |
| 148 | const args = process.argv.slice(2); | |
| 149 | const flag = (name: string) => { | |
| 150 | const i = args.indexOf(name); | |
| 151 | if (i < 0) return undefined; | |
| 152 | return args.splice(i, 2)[1]; | |
| 153 | }; | |
| 154 | const version = flag("--version"); | |
| 155 | const wait = flag("--wait"); | |
| 156 | const base = args[0]; | |
| 157 | const seconds = wait === undefined ? 120 : Number(wait); | |
| 158 | if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { | |
| 159 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>]"); | |
| 89 | 160 | process.exit(2); |
| 90 | 161 | } |
| 91 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? ""); | |
| 162 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 }); | |
| 92 | 163 | for (const f of failures) |
| 93 | 164 | console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`); |
| 94 | 165 | if (failures.length) process.exit(1); |
test/smoke.test.ts +50 −1
| @@ -4,7 +4,21 @@ import { smoke, stylesheetHref } from "../scripts/smoke.ts"; | ||
| 4 | 4 | let server: ReturnType<typeof Bun.serve> | undefined; |
| 5 | 5 | afterEach(() => server?.stop(true)); |
| 6 | 6 | |
| 7 | const GOOD_SW = `self.addEventListener('fetch', function (e) { | |
| 8 | if (e.request.mode === 'navigate') { | |
| 9 | // never Response.error() | |
| 10 | e.respondWith(fetch(e.request).catch(offline)); | |
| 11 | return; | |
| 12 | } | |
| 13 | e.respondWith(fetch(e.request).catch(function () { return Response.error(); })); | |
| 14 | });`; | |
| 15 | const BAD_SW = GOOD_SW.replace("catch(offline)", "catch(function () { return Response.error(); })"); | |
| 16 | let sw: { body: string; type: string; status: number } = { body: GOOD_SW, type: "text/javascript", status: 200 }; | |
| 17 | let version = () => "v1"; | |
| 18 | ||
| 7 | 19 | function stub(cssType: string, seen: string[] = []) { |
| 20 | sw = { body: GOOD_SW, type: "text/javascript", status: 200 }; | |
| 21 | version = () => "v1"; | |
| 8 | 22 | server = Bun.serve({ |
| 9 | 23 | port: 0, |
| 10 | 24 | hostname: "127.0.0.1", |
| @@ -17,7 +31,8 @@ function stub(cssType: string, seen: string[] = []) { | ||
| 17 | 31 | return req.headers.get("authorization") === "Bearer t" |
| 18 | 32 | ? new Response("board") |
| 19 | 33 | : new Response(null, { status: 302, headers: { location: "/signin" } }); |
| 20 | if (path === "/healthz") return new Response("ok"); | |
| 34 | if (path === "/healthz") return new Response("ok", { headers: { "x-forge-version": version() } }); | |
| 35 | if (path === "/sw.js") return new Response(sw.body, { status: sw.status, headers: { "content-type": sw.type } }); | |
| 21 | 36 | return new Response("no", { status: 404 }); |
| 22 | 37 | }, |
| 23 | 38 | }); |
| @@ -56,3 +71,37 @@ test("stylesheetHref finds the link", () => { | ||
| 56 | 71 | expect(stylesheetHref('<link rel="icon" href="/x"><link rel="stylesheet" href="/s.css">')).toBe("/s.css"); |
| 57 | 72 | expect(stylesheetHref("<p>none</p>")).toBeNull(); |
| 58 | 73 | }); |
| 74 | ||
| 75 | test("--version waits for a late version", async () => { | |
| 76 | const base = stub("text/css", []); | |
| 77 | let polls = 0; | |
| 78 | version = () => (++polls < 3 ? "old" : "v2"); | |
| 79 | expect(await smoke(base, "t", { version: "v2", waitMs: 5000, pollMs: 10 })).toEqual([]); | |
| 80 | expect(polls).toBeGreaterThanOrEqual(3); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a version that never arrives fails naming the last one seen", async () => { | |
| 84 | const base = stub("text/css"); | |
| 85 | const failures = await smoke(base, "t", { version: "v9", waitMs: 100, pollMs: 10 }); | |
| 86 | expect(failures.length).toBe(1); | |
| 87 | expect(failures[0]!.why).toContain("v9"); | |
| 88 | expect(failures[0]!.why).toContain("last saw v1"); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a Response.error() on a navigation, a non-JS or a missing sw.js fails", async () => { | |
| 92 | const base = stub("text/css"); | |
| 93 | sw.body = BAD_SW; | |
| 94 | expect((await smoke(base, "t")).map((f) => f.why)).toEqual([expect.stringContaining("Response.error()")]); | |
| 95 | sw = { body: GOOD_SW, type: "text/html", status: 200 }; | |
| 96 | expect((await smoke(base, "t")).map((f) => f.why)).toEqual(["expected javascript"]); | |
| 97 | sw = { body: "", type: "text/javascript", status: 404 }; | |
| 98 | expect((await smoke(base, "t")).map((f) => f.why)).toEqual(["expected 200"]); | |
| 99 | }); | |
| 100 | ||
| 101 | test("the CLI takes --version and --wait", async () => { | |
| 102 | const base = stub("text/css"); | |
| 103 | const run = (...a: string[]) => | |
| 104 | Bun.spawn(["bun", "scripts/smoke.ts", base, ...a], { env: { ...process.env, FORGE_TOKEN: "t" }, stdout: "pipe", stderr: "pipe" }).exited; | |
| 105 | expect(await run("--version", "v1")).toBe(0); | |
| 106 | expect(await run("--version", "zz", "--wait", "0")).toBe(1); | |
| 107 | }); |
new above
offline