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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2339-scripts-smoke-ts-url-checks-board-health → main 1 commit fork
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("&", "&"); | |
| 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 | }); |
new above
offline