import { describe, expect, test } from "bun:test"; import { createLogService, encodeLogEvent, KubernetesLogError, MANAGED_BY_SELECTOR, type ContainerLogRequest, type KubernetesDeployment, type KubernetesLogsBackend, type KubernetesPod, type KubernetesService, type LogEvent, } from "../../server/log-service"; class FakeBackend implements KubernetesLogsBackend { deployments: KubernetesDeployment[] = []; service: KubernetesService | undefined; pods: KubernetesPod[] = []; reads = new Map(); streams = new Map< string, (signal: AbortSignal) => AsyncIterable >(); deploymentCalls: Array>> = []; podSelectors: Array>> = []; requests: ContainerLogRequest[] = []; async listDeployments( _namespace: string, labels: Readonly>, ) { this.deploymentCalls.push(labels); return this.deployments; } async getService(_namespace: string, _name: string) { return this.service; } async listPods( _namespace: string, selector: Readonly>, ) { this.podSelectors.push(selector); return this.pods; } async readContainerLogs(request: ContainerLogRequest) { this.requests.push(request); const value = this.reads.get(`${request.pod}/${request.container}`) ?? ""; if (value instanceof Error) throw value; return value; } streamContainerLogs(request: ContainerLogRequest, signal: AbortSignal) { this.requests.push(request); const stream = this.streams.get(`${request.pod}/${request.container}`); if (stream) return stream(signal); return openStream(signal); } } async function* openStream(signal: AbortSignal): AsyncGenerator { await untilAbort(signal); yield* [] as string[]; } function untilAbort(signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(); return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }), ); } async function* chunks(...values: Array) { for (const value of values) yield value; } async function nextOfType( iterator: AsyncIterator, type: T, limit = 30, ): Promise> { for (let index = 0; index < limit; index += 1) { const result = await iterator.next(); if (result.done) throw new Error(`Stream ended before ${type}`); if (result.value.type === type) return result.value as Extract; } throw new Error(`No ${type} event received`); } function setup() { const backend = new FakeBackend(); backend.deployments = [{ name: "web", selector: { app: "web" } }]; backend.pods = [ { name: "web-abc", uid: "uid-1", phase: "Running", containers: ["web", "sidecar"], }, ]; const service = createLogService( backend, () => new Date("2026-09-02T12:00:00.000Z"), ); return { backend, service }; } describe("LogService", () => { test("enumerates every container in managed deployment pods", async () => { const { backend, service } = setup(); expect( await service.listContainers({ namespace: "demo", target: { kind: "managed-deployments" }, }), ).toEqual([ { namespace: "demo", targetKind: "deployment", targetName: "web", pod: "web-abc", podUid: "uid-1", container: "web", }, { namespace: "demo", targetKind: "deployment", targetName: "web", pod: "web-abc", podUid: "uid-1", container: "sidecar", }, ]); expect(backend.deploymentCalls).toEqual([MANAGED_BY_SELECTOR]); expect(backend.podSelectors).toEqual([{ app: "web" }]); }); test("uses a named service selector without listing deployments", async () => { const { backend, service } = setup(); backend.service = { name: "frontend", selector: { role: "frontend" } }; const result = await service.listContainers({ namespace: "demo", target: { kind: "service", name: "frontend" }, }); expect(result[0]).toMatchObject({ targetKind: "service", targetName: "frontend", }); expect(backend.deploymentCalls).toHaveLength(0); expect(backend.podSelectors).toEqual([{ role: "frontend" }]); }); test("rejects missing and selectorless services", async () => { const { backend, service } = setup(); const options = { namespace: "demo", target: { kind: "service" as const, name: "missing" }, }; await expect(service.listContainers(options)).rejects.toMatchObject({ retryable: false, }); backend.service = { name: "missing", selector: {} }; await expect(service.listContainers(options)).rejects.toThrow( "no pod selector", ); }); test("collects timestamped structured lines and passes log options", async () => { const { backend, service } = setup(); backend.reads.set( "web-abc/web", "2026-09-02T11:59:00.123456Z first\nsecond\n", ); backend.reads.set("web-abc/sidecar", "side\n"); const events = await service.collect({ namespace: "demo", target: { kind: "managed-deployments" }, tailLines: 25, sinceTime: new Date("2026-09-02T11:00:00Z"), timestamps: true, }); expect(events).toHaveLength(3); expect(events[0]).toMatchObject({ type: "log", timestamp: "2026-09-02T12:00:00.000Z", logTimestamp: "2026-09-02T11:59:00.123456Z", message: "first", pod: "web-abc", container: "web", }); expect(events[1]).toMatchObject({ message: "second" }); expect(backend.requests[0]).toEqual({ namespace: "demo", pod: "web-abc", container: "web", tailLines: 25, sinceSeconds: undefined, sinceTime: "2026-09-02T11:00:00.000Z", timestamps: true, }); expect(JSON.parse(encodeLogEvent(events[0]!))).toEqual(events[0]); }); test("returns per-container safe errors and continues collection", async () => { const { backend, service } = setup(); backend.reads.set( "web-abc/web", new KubernetesLogError("container unavailable", false), ); backend.reads.set("web-abc/sidecar", "healthy"); const events = await service.collect({ namespace: "demo", target: { kind: "managed-deployments" }, }); expect(events[0]).toEqual({ type: "error", timestamp: "2026-09-02T12:00:00.000Z", namespace: "demo", pod: "web-abc", container: "web", message: "container unavailable", retryable: false, }); expect(events[1]).toMatchObject({ type: "log", message: "healthy" }); expect(JSON.stringify(events)).not.toContain("env"); expect(JSON.stringify(events)).not.toContain("Secret"); }); test("validates tail and since options before Kubernetes calls", async () => { const { backend, service } = setup(); await expect( service.collect({ namespace: "demo", target: { kind: "managed-deployments" }, tailLines: -1, }), ).rejects.toThrow("tailLines"); await expect( service.collect({ namespace: "demo", target: { kind: "managed-deployments" }, sinceSeconds: 5, sinceTime: "2026-09-02T00:00:00Z", }), ).rejects.toThrow("mutually exclusive"); expect(backend.deploymentCalls).toHaveLength(0); }); test("follows chunked lines and stops active streams on abort", async () => { const { backend, service } = setup(); let cancelled = false; backend.streams.set("web-abc/web", (signal) => (async function* () { try { yield new TextEncoder().encode("hel"); yield "lo\npartial"; await untilAbort(signal); } finally { cancelled = true; } })(), ); const controller = new AbortController(); const iterator = service.follow({ namespace: "demo", target: { kind: "managed-deployments" }, signal: controller.signal, discoveryIntervalMs: 5, heartbeatIntervalMs: 1_000, }); expect(await nextOfType(iterator, "log")).toMatchObject({ message: "hello", }); controller.abort(); expect((await iterator.next()).done).toBe(true); expect(cancelled).toBe(true); }); test("discards queued lines immediately on external abort", async () => { const { backend, service } = setup(); backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] }; backend.streams.set("web-abc/web", () => chunks("one\ntwo\nthree\n")); const controller = new AbortController(); const iterator = service.follow({ namespace: "demo", target: { kind: "managed-deployments" }, signal: controller.signal, queueCapacity: 5, discoveryIntervalMs: 1_000, heartbeatIntervalMs: 1_000, }); expect(await nextOfType(iterator, "log")).toMatchObject({ message: "one" }); await Bun.sleep(1); controller.abort(); expect((await iterator.next()).done).toBe(true); }); test("discovers replacement pod UIDs while following", async () => { const { backend, service } = setup(); backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] }; backend.streams.set("web-abc/web", () => chunks("old\n")); const controller = new AbortController(); const iterator = service.follow({ namespace: "demo", target: { kind: "managed-deployments" }, signal: controller.signal, discoveryIntervalMs: 5, heartbeatIntervalMs: 1_000, }); expect(await nextOfType(iterator, "log")).toMatchObject({ message: "old", podUid: "uid-1", }); backend.pods = [ { name: "web-new", uid: "uid-2", containers: ["web"], phase: "Running" }, ]; backend.streams.set("web-new/web", () => chunks("new\n")); expect(await nextOfType(iterator, "log")).toMatchObject({ message: "new", pod: "web-new", podUid: "uid-2", }); controller.abort(); await iterator.next(); }); test("emits heartbeats and retryable stream errors before retrying", async () => { const { backend, service } = setup(); backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] }; let attempts = 0; backend.streams.set("web-abc/web", () => (async function* () { attempts += 1; if (attempts === 1) throw new Error("temporary disconnect"); yield "recovered\n"; })(), ); const controller = new AbortController(); const iterator = service.follow({ namespace: "demo", target: { kind: "managed-deployments" }, signal: controller.signal, discoveryIntervalMs: 5, heartbeatIntervalMs: 2, retryIntervalMs: 7, }); expect(await nextOfType(iterator, "error")).toMatchObject({ message: "temporary disconnect", retryable: true, retryAfterMs: 7, pod: "web-abc", }); expect(await nextOfType(iterator, "heartbeat")).toMatchObject({ timestamp: "2026-09-02T12:00:00.000Z", }); expect(await nextOfType(iterator, "log")).toMatchObject({ message: "recovered", }); controller.abort(); await iterator.next(); expect(attempts).toBe(2); }); test("emits one terminal target error and closes follow mode", async () => { const { service } = setup(); const iterator = service.follow({ namespace: "demo", target: { kind: "service", name: "missing" }, signal: new AbortController().signal, discoveryIntervalMs: 5, heartbeatIntervalMs: 5, }); expect(await nextOfType(iterator, "error")).toMatchObject({ message: "Service missing was not found", retryable: false, }); expect((await iterator.next()).done).toBe(true); }); test("applies bounded-queue backpressure to fast producers", async () => { const { backend, service } = setup(); backend.pods[0] = { ...backend.pods[0]!, containers: ["web"] }; let produced = 0; backend.streams.set("web-abc/web", () => (async function* () { for (let index = 0; index < 20; index += 1) { produced += 1; yield `${index}\n`; } })(), ); const controller = new AbortController(); const iterator = service.follow({ namespace: "demo", target: { kind: "managed-deployments" }, signal: controller.signal, queueCapacity: 1, discoveryIntervalMs: 1_000, heartbeatIntervalMs: 1_000, }); expect((await iterator.next()).value).toMatchObject({ message: "0" }); await Bun.sleep(10); expect(produced).toBeLessThanOrEqual(3); controller.abort(); await iterator.return?.(undefined); }); });