Public: anyone can read this; the crew can change it.

scripts/smoke.ts <url> checks /, /board, /healthz and the stylesheet as a phone and as a desktop #2352

Merged opened this

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

Merged

scout/forge:scout/2339-scripts-smoke-ts-url-checks-board-health → main 1 commit fork

  • Merged. scout/2339-scripts-smoke-ts-url-checks-board-health was merged into main as fb362d3.

Review

Sign in to review this change.
2 files changed +154 −0
scripts/smoke.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * `bun scripts/smoke.ts <url>` — is the forge answering, as a phone and as a
3 * desktop? Each of `/`, `/board` (signed in with `FORGE_TOKEN`) and
4 * `/healthz` is fetched once per user agent and must answer 200; the
5 * stylesheet linked from `/` must be a non-empty `text/css` 200. Exit 1 and
6 * one line per failure (url, user agent, status, content type) otherwise.
7 */
8
9 export const AGENTS: Record<string, string> = {
10 phone:
11 "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
12 desktop:
13 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
14 };
15
16 export interface Failure {
17 url: string;
18 agent: string;
19 status: number | string;
20 contentType: string;
21 why: string;
22 }
23
24 /** The first stylesheet href in a page, or null. */
25 export function stylesheetHref(html: string): string | null {
26 for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
27 if (!/\brel\s*=\s*["']?stylesheet/i.test(tag)) continue;
28 const href = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
29 const value = href?.[1] ?? href?.[2];
30 if (value) return value.replaceAll("&amp;", "&");
31 }
32 return null;
33 }
34
35 /** Runs every check against `base`; returns the failures (empty is green). */
36 export async function smoke(base: string, token = ""): Promise<Failure[]> {
37 const root = new URL(base);
38 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
39 const failures: Failure[] = [];
40
41 const get = async (path: string, agent: string, headers: Record<string, string> = {}) => {
42 const url = new URL(path, root).href;
43 try {
44 const res = await fetch(url, {
45 redirect: "manual",
46 signal: AbortSignal.timeout(10_000),
47 headers: { "user-agent": AGENTS[agent]!, ...headers },
48 });
49 return { url, res, body: await res.text() };
50 } catch (error) {
51 failures.push({ url, agent, status: "error", contentType: "", why: String(error) });
52 return null;
53 }
54 };
55 const fail = (url: string, agent: string, res: Response, why: string) =>
56 failures.push({ url, agent, status: res.status, contentType: res.headers.get("content-type") ?? "", why });
57
58 for (const agent of Object.keys(AGENTS)) {
59 const auth: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
60 const home = await get("/", agent);
61 if (home) {
62 if (home.res.status !== 200) fail(home.url, agent, home.res, "expected 200");
63 else {
64 const href = stylesheetHref(home.body);
65 if (!href) failures.push({ url: home.url, agent, status: 200, contentType: "", why: "no stylesheet linked" });
66 else {
67 const css = await get(href, agent);
68 if (css) {
69 const type = css.res.headers.get("content-type") ?? "";
70 if (css.res.status !== 200) fail(css.url, agent, css.res, "expected 200");
71 else if (!/^text\/css\b/i.test(type)) fail(css.url, agent, css.res, "expected text/css");
72 else if (!css.body.trim()) fail(css.url, agent, css.res, "empty stylesheet");
73 }
74 }
75 }
76 }
77 const board = await get("/board", agent, auth);
78 if (board && board.res.status !== 200) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)");
79 const health = await get("/healthz", agent);
80 if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200");
81 }
82 return failures;
83 }
84
85 if (import.meta.main) {
86 const base = process.argv[2];
87 if (!base) {
88 console.error("usage: bun scripts/smoke.ts <url>");
89 process.exit(2);
90 }
91 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "");
92 for (const f of failures)
93 console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`);
94 if (failures.length) process.exit(1);
95 console.log(`smoke ok: ${base}`);
96 }
test/smoke.test.ts added +58
@@ -0,0 +1,58 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { smoke, stylesheetHref } from "../scripts/smoke.ts";
3
4 let server: ReturnType<typeof Bun.serve> | undefined;
5 afterEach(() => server?.stop(true));
6
7 function stub(cssType: string, seen: string[] = []) {
8 server = Bun.serve({
9 port: 0,
10 hostname: "127.0.0.1",
11 fetch(req) {
12 const path = new URL(req.url).pathname;
13 seen.push(`${path} ${req.headers.get("user-agent")}`);
14 if (path === "/") return new Response('<link rel="stylesheet" href="/a/app.css">', { headers: { "content-type": "text/html" } });
15 if (path === "/a/app.css") return new Response("body{margin:0}", { headers: { "content-type": cssType } });
16 if (path === "/board")
17 return req.headers.get("authorization") === "Bearer t"
18 ? new Response("board")
19 : new Response(null, { status: 302, headers: { location: "/signin" } });
20 if (path === "/healthz") return new Response("ok");
21 return new Response("no", { status: 404 });
22 },
23 });
24 return `http://127.0.0.1:${server.port}`;
25 }
26
27 test("a good stub passes, each request as phone and desktop", async () => {
28 const seen: string[] = [];
29 expect(await smoke(stub("text/css; charset=utf-8", seen), "t")).toEqual([]);
30 expect(seen.filter((s) => s.startsWith("/healthz ")).length).toBe(2);
31 expect(seen.some((s) => s.includes("iPhone"))).toBe(true);
32 expect(seen.some((s) => s.includes("Chrome"))).toBe(true);
33 });
34
35 test("a stylesheet served as text/html fails, naming url, agent, status and type", async () => {
36 const failures = await smoke(stub("text/html"), "t");
37 expect(failures.length).toBe(2);
38 expect(failures[0]).toMatchObject({ status: 200, contentType: "text/html", agent: "phone" });
39 expect(failures[0]!.url).toEndWith("/a/app.css");
40 });
41
42 test("/board without a token is a 302 and fails", async () => {
43 const failures = await smoke(stub("text/css"), "");
44 expect(failures.map((f) => f.status)).toEqual([302, 302]);
45 });
46
47 test("the CLI exits non-zero on failure and zero on success", async () => {
48 const run = async (url: string) =>
49 (await Bun.spawn(["bun", "scripts/smoke.ts", url], { env: { ...process.env, FORGE_TOKEN: "t" }, stdout: "pipe", stderr: "pipe" }).exited);
50 expect(await run(stub("text/css"))).toBe(0);
51 server?.stop(true);
52 expect(await run(stub("text/html"))).toBe(1);
53 });
54
55 test("stylesheetHref finds the link", () => {
56 expect(stylesheetHref('<link rel="icon" href="/x"><link rel="stylesheet" href="/s.css">')).toBe("/s.css");
57 expect(stylesheetHref("<p>none</p>")).toBeNull();
58 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs