From f2cf9ba0cb89db2be3ee92ce83b591c4d7191ea9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 01:26:01 +0200 Subject: [PATCH] fix: make the server tests hermetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They read from public/, which does not exist on a clean checkout — CI runs bun test before any build, so the four of them failed there while passing locally against stale build output. serve.ts now takes PUBLIC_DIR, and the tests serve a temporary tree they create themselves. They exercise serve.ts rather than the build, which is what they were always meant to do. Co-Authored-By: Claude Opus 5 (1M context) --- serve.ts | 3 ++- test/serve.test.ts | 46 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/serve.ts b/serve.ts index d778280..1c1a607 100644 --- a/serve.ts +++ b/serve.ts @@ -10,7 +10,8 @@ import { resolve } from "node:path"; const PORT = Number(process.env.PORT ?? 3000); -const ROOT = "public"; +/** Overridable so tests can point at a fixture tree instead of the build. */ +const ROOT = process.env.PUBLIC_DIR ?? "public"; const ROOT_DIR = resolve(ROOT); /** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */ diff --git a/test/serve.test.ts b/test/serve.test.ts index c84e3e7..f64320f 100644 --- a/test/serve.test.ts +++ b/test/serve.test.ts @@ -1,22 +1,42 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; /** - * The static server is small, but it reads from the filesystem based on a - * user-supplied path, so its confinement is worth pinning down. + * The static server reads from the filesystem based on a user-supplied path, so + * its confinement is worth pinning down. + * + * Served from a temporary tree rather than public/: these tests exercise + * serve.ts, not the build, and coupling them to build output means `bun test` + * fails on a clean checkout — which is exactly how CI found this. */ let proc: Bun.Subprocess; let base: string; +let root: string; beforeAll(async () => { - const port = 3200 + Math.floor(Math.random() * 300); + root = await mkdtemp(join(tmpdir(), "event-clock-serve-")); + await mkdir(join(root, "data"), { recursive: true }); + await writeFile( + join(root, "index.html"), + "shell", + ); + await writeFile(join(root, "sw.js"), "// worker"); + await writeFile( + join(root, "data", "events.v1.json"), + JSON.stringify({ schemaVersion: 1, generatedAt: "", events: [], sources: [] }), + ); + + const port = 3200 + Math.floor(Math.random() * 500); base = `http://127.0.0.1:${port}`; proc = Bun.spawn(["bun", "run", "serve.ts"], { - env: { ...process.env, PORT: String(port) }, + env: { ...process.env, PORT: String(port), PUBLIC_DIR: root }, stdout: "ignore", stderr: "ignore", }); - for (let i = 0; i < 50; i += 1) { + for (let i = 0; i < 60; i += 1) { try { await fetch(`${base}/api/health`); return; @@ -27,8 +47,9 @@ beforeAll(async () => { throw new Error("server did not start"); }); -afterAll(() => { +afterAll(async () => { proc.kill(); + await rm(root, { recursive: true, force: true }); }); describe("static server", () => { @@ -36,19 +57,19 @@ describe("static server", () => { expect((await fetch(`${base}/`)).status).toBe(200); const feed = await fetch(`${base}/data/events.v1.json`); expect(feed.status).toBe(200); - expect((await feed.json()).schemaVersion).toBe(1); + expect(((await feed.json()) as { schemaVersion: number }).schemaVersion).toBe(1); }); test("reports health", async () => { const res = await fetch(`${base}/api/health`); expect(res.status).toBe(200); - expect((await res.json()).status).toBe("ok"); + expect(((await res.json()) as { status: string }).status).toBe("ok"); }); test("falls back to the shell for unknown routes", async () => { const res = await fetch(`${base}/deep/link`); expect(res.status).toBe(200); - expect(await res.text()).toContain(""); + expect(await res.text()).toContain("shell"); }); test("404s missing data rather than serving the shell", async () => { @@ -57,17 +78,14 @@ describe("static server", () => { expect((await fetch(`${base}/data/nope.json`)).status).toBe(404); }); - test("never serves a file outside public/", async () => { + test("never serves a file outside the root", async () => { for (const path of [ "/..%2fpackage.json", "/..%2f..%2fetc/passwd", "/%2e%2e/package.json", "/%2e%2e%2f%2e%2e%2fpackage.json", ]) { - const res = await fetch(`${base}${path}`); - const body = await res.text(); - // Either refused, or normalised to something inside public/ — but never - // the repository file itself. + const body = await (await fetch(`${base}${path}`)).text(); expect(body).not.toContain('"name": "gacha-event-tracker"'); expect(body).not.toContain("root:x:0:0"); }