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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2423-scripts-smoke-ts-waits-for-version-on-he → main 1 commit fork

  • Merged. scout/2423-scripts-smoke-ts-waits-for-version-on-he was merged into main as 0125e63.

Review

Sign in to review this change.
2 files changed +126 −6
scripts/smoke.ts +76 −5
@@ -4,6 +4,11 @@
44 * `/healthz` is fetched once per user agent and must answer 200; the
55 * stylesheet linked from `/` must be a non-empty `text/css` 200. Exit 1 and
66 * 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()`.
712 */
813
914 export const AGENTS: Record<string, string> = {
@@ -32,11 +37,56 @@ export function stylesheetHref(html: string): string | null {
3237 return null;
3338 }
3439
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
3581 /** 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[]> {
3783 const root = new URL(base);
3884 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
3985 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 }
4090
4191 const get = async (path: string, agent: string, headers: Record<string, string> = {}) => {
4292 const url = new URL(path, root).href;
@@ -79,16 +129,37 @@ export async function smoke(base: string, token = ""): Promise<Failure[]> {
79129 const health = await get("/healthz", agent);
80130 if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200");
81131 }
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 }
82144 return failures;
83145 }
84146
85147 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>]");
89160 process.exit(2);
90161 }
91 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "");
162 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 });
92163 for (const f of failures)
93164 console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`);
94165 if (failures.length) process.exit(1);
test/smoke.test.ts +50 −1
@@ -4,7 +4,21 @@ import { smoke, stylesheetHref } from "../scripts/smoke.ts";
44 let server: ReturnType<typeof Bun.serve> | undefined;
55 afterEach(() => server?.stop(true));
66
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
719 function stub(cssType: string, seen: string[] = []) {
20 sw = { body: GOOD_SW, type: "text/javascript", status: 200 };
21 version = () => "v1";
822 server = Bun.serve({
923 port: 0,
1024 hostname: "127.0.0.1",
@@ -17,7 +31,8 @@ function stub(cssType: string, seen: string[] = []) {
1731 return req.headers.get("authorization") === "Bearer t"
1832 ? new Response("board")
1933 : 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 } });
2136 return new Response("no", { status: 404 });
2237 },
2338 });
@@ -56,3 +71,37 @@ test("stylesheetHref finds the link", () => {
5671 expect(stylesheetHref('<link rel="icon" href="/x"><link rel="stylesheet" href="/s.css">')).toBe("/s.css");
5772 expect(stylesheetHref("<p>none</p>")).toBeNull();
5873 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs