// The log shipper's contract: what it recovers from a formatted line, that it tees rather than // swallows, that a POST failure neither loses lines nor spins, and that it stays bounded. import { afterEach, describe, expect, test } from "bun:test"; import { __test, installLogShipper } from "../src/log-ship.js"; const { toLines } = __test; const TOKEN = "ship-token"; interface Captured { entries: { ts_ms: number; level: string; source: string; msg: string }[]; } /** A host that records every batch, answering with whatever `status()` says. */ const mockHost = (status: () => number = () => 204) => { const batches: Captured[] = []; const auth: (string | null)[] = []; const server = Bun.serve({ port: 0, fetch: async (req) => { const url = new URL(req.url); if (url.pathname !== "/api/v1/plugins/logs") { return new Response("not found", { status: 404 }); } auth.push(req.headers.get("authorization")); batches.push((await req.json()) as Captured); const s = status(); return new Response(s === 204 ? null : "nope", { status: s }); }, }); return { batches, auth, url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true), }; }; /** * Run `body` with a shipper installed, always restoring the console. * * The console is swapped for a recorder BEFORE the shipper installs, so `seen` is what the shipper * teed through to "stdout" — and the suite stays readable, since a test that logs 2000 lines would * otherwise print all 2000. */ const withShipper = async ( url: string, body: ( s: ReturnType, seen: string[], ) => Promise, ): Promise => { const seen: string[] = []; const real = { log: console.log, warn: console.warn, error: console.error }; const record = (...a: unknown[]) => { seen.push(String(a[0])); }; console.log = record; console.warn = record; console.error = record; const shipper = installLogShipper({ connect: { url, token: TOKEN }, // Long enough that only explicit flushes fire — the tests drive the timing. intervalMs: 60_000, }); try { return await body(shipper, seen); } finally { shipper.stop(); console.log = real.log; console.warn = real.warn; console.error = real.error; } }; describe("parsing a formatted line", () => { test("recovers the plugin name and timestamp plugin-kit's format flattened", () => { const [line] = toLines( "log", ["2026-08-03T10:11:12.345Z [virtualhere] holding nothing"], ); expect(line?.source).toBe("virtualhere"); expect(line?.level).toBe("INFO"); expect(line?.msg).toBe("holding nothing"); expect(line?.ts_ms).toBe(Date.parse("2026-08-03T10:11:12.345Z")); }); test("an explicit level in the line beats the console method", () => { // plugin-kit renders Effect's level verbatim — "WARNING", not "WARN". The host coerces it. const [line] = toLines("log", [ "2026-08-03T10:11:12.345Z [virtualhere] WARNING: vhclient failed (ETIMEDOUT)", ]); expect(line?.level).toBe("WARNING"); expect(line?.msg).toBe("vhclient failed (ETIMEDOUT)"); }); test("the runner's own error lines keep their severity", () => { const [line] = toLines("error", [ "2026-08-03T10:11:12.345Z [virtualhere] failed: VhIpcError: no such binary", ]); expect(line?.source).toBe("virtualhere"); expect(line?.level).toBe("ERROR"); }); test("an unstamped call is kept, not dropped", () => { // A plugin reaching for bare console.error is exactly the case that must not be lost. const [line] = toLines("error", ["boom", { code: 7 }]); expect(line?.source).toBe("runner"); expect(line?.level).toBe("ERROR"); expect(line?.msg).toContain("boom"); expect(line?.msg).toContain("7"); }); test("a multi-line message becomes one entry per line", () => { const lines = toLines("log", [ "2026-08-03T10:11:12.345Z [x] failed\n at foo\n at bar", ]); expect(lines.map((l) => l.msg)).toEqual(["failed", " at foo", " at bar"]); // Every fragment keeps the original stamp, so the trace cannot interleave with other units. expect(new Set(lines.map((l) => l.ts_ms)).size).toBe(1); }); test("a pathological dump is capped and says so", () => { const lines = toLines("log", [ `2026-08-03T10:11:12.345Z [x] ${"line\n".repeat(200)}`, ]); expect(lines.length).toBeLessThanOrEqual(41); expect(lines.at(-1)?.msg).toContain("more line(s) suppressed"); }); }); describe("shipping", () => { let stopHost: (() => void) | undefined; afterEach(() => { stopHost?.(); stopHost = undefined; }); test("tees: stdout still gets the line, and the host gets it too", async () => { const host = mockHost(); stopHost = host.stop; const seen = await withShipper(host.url, async (shipper, seen) => { console.log("2026-08-03T10:11:12.345Z [virtualhere] hello"); await shipper.flush(); return seen; }); // The original console still ran — journald/foreground output is never traded away. expect(seen).toEqual(["2026-08-03T10:11:12.345Z [virtualhere] hello"]); expect(host.batches).toHaveLength(1); expect(host.batches[0]?.entries[0]).toMatchObject({ source: "virtualhere", level: "INFO", msg: "hello", }); expect(host.auth[0]).toBe(`Bearer ${TOKEN}`); }); test("a 5xx keeps the lines for the next flush", async () => { let status = 500; const host = mockHost(() => status); stopHost = host.stop; await withShipper(host.url, async (shipper) => { console.log("2026-08-03T10:11:12.345Z [x] keep me"); await shipper.flush(); expect(host.batches).toHaveLength(1); status = 204; await shipper.flush(); }); // Re-sent rather than dropped: the host being down is not the line's fault. expect(host.batches).toHaveLength(2); expect(host.batches[1]?.entries[0]?.msg).toBe("keep me"); }); test("a 4xx drops the batch instead of retrying forever", async () => { const host = mockHost(() => 400); stopHost = host.stop; await withShipper(host.url, async (shipper) => { console.log("2026-08-03T10:11:12.345Z [x] malformed"); await shipper.flush(); await shipper.flush(); }); // A shape the host rejects cannot be fixed by sending it again. expect(host.batches).toHaveLength(1); }); test("a line logged WHILE a POST is in flight is not lost", async () => { // A plugin logging during the few ms of a POST is the normal case under load, not an edge // one. An earlier version held a `shipping` flag across the whole `await fetch` and dropped // everything enqueued in that window — silently, which is the worst way to lose a log line. let release: (() => void) | undefined; const held = new Promise((r) => { release = r; }); let arrived: (() => void) | undefined; // Resolves once the server actually has the request — i.e. the shipper is genuinely mid-POST. // Logging merely "after calling flush()" proves nothing: flush yields at its own awaits long // before the fetch starts, so the line would land in the pre-send queue and the test would // pass against the broken code. const received = new Promise((r) => { arrived = r; }); const batches: Captured[] = []; const server = Bun.serve({ port: 0, fetch: async (req) => { batches.push((await req.json()) as Captured); arrived?.(); await held; // hold this POST open return new Response(null, { status: 204 }); }, }); stopHost = () => server.stop(true); await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => { console.log("2026-08-03T10:11:12.345Z [x] before"); const inFlight = shipper.flush(); await received; // The POST is open right now; this is the line that used to vanish. console.log("2026-08-03T10:11:12.345Z [x] during"); release?.(); await inFlight; await shipper.flush(); }); const all = batches.flatMap((b) => b.entries.map((e) => e.msg)); expect(all).toContain("before"); expect(all).toContain("during"); }); test("an explicit flush waits for an in-flight one instead of no-opping", async () => { // This is the shutdown path. The runner flushes once more after its units' finalizers have // run, and those last lines are the ones that say whether the shutdown WAS clean. If a // periodic flush happened to be mid-POST, an explicit flush that simply returned would // leave them unsent — and the window is widest exactly when the host is slow, which is when // the logs matter most. let release: (() => void) | undefined; const held = new Promise((r) => { release = r; }); let arrived: (() => void) | undefined; const received = new Promise((r) => { arrived = r; }); let first = true; const batches: Captured[] = []; const server = Bun.serve({ port: 0, fetch: async (req) => { batches.push((await req.json()) as Captured); if (first) { first = false; arrived?.(); await held; } return new Response(null, { status: 204 }); }, }); stopHost = () => server.stop(true); await withShipper(`http://127.0.0.1:${server.port}`, async (shipper) => { console.log("2026-08-03T10:11:12.345Z [x] first"); const slow = shipper.flush(); await received; console.log("2026-08-03T10:11:12.345Z [x] shutdown line"); release?.(); // The shutdown flush: must not return until the tail is actually sent. await shipper.flush(); await slow; }); const all = batches.flatMap((b) => b.entries.map((e) => e.msg)); expect(all).toContain("shutdown line"); }); test("overlapping flushes do not double-send", async () => { // The timer can fire while a slow POST is still open. Two concurrent flushes would splice // disjoint batches out of one queue and deliver them out of order. const host = mockHost(); stopHost = host.stop; await withShipper(host.url, async (shipper) => { console.log("2026-08-03T10:11:12.345Z [x] one"); await Promise.all([shipper.flush(), shipper.flush()]); }); expect(host.batches).toHaveLength(1); expect(host.batches[0]?.entries).toHaveLength(1); }); test("an unreachable host neither throws into the caller nor grows without bound", async () => { // Nothing listening on this port. await withShipper("http://127.0.0.1:1", async (shipper) => { for (let i = 0; i < 2_000; i++) { console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`); } // The whole point: console.log above must not have thrown, and flush must not reject. await shipper.flush(); }); }); test("overflow is announced, not silently swallowed", async () => { const host = mockHost(); stopHost = host.stop; await withShipper(host.url, async (shipper) => { // Overrun the 1000-line cap while the shipper has had no chance to drain. for (let i = 0; i < 1_200; i++) { console.log(`2026-08-03T10:11:12.345Z [x] line ${i}`); } await shipper.flush(); }); const first = host.batches[0]?.entries[0]; expect(first?.level).toBe("WARN"); expect(first?.msg).toContain("dropped"); // The tail is what survived — the oldest lines are the ones that went. expect(host.batches[0]?.entries[1]?.msg).toBe("line 200"); }); test("stop() puts the real console back", async () => { const host = mockHost(); stopHost = host.stop; const before = console.log; const shipper = installLogShipper({ connect: { url: host.url, token: TOKEN }, intervalMs: 60_000, }); expect(console.log).not.toBe(before); shipper.stop(); expect(console.log).toBe(before); }); });