// @vitest-environment node // `streamTail` pipes a `fetch()` response body through a `TextDecoderStream` // and a manual NDJSON-line splitter. We mock `fetch` with `vi.stubGlobal` // and drive a fake `ReadableStream` from the test, so no DOM/happy-dom // is needed — Node 22's globals (Response, ReadableStream, TextDecoderStream) // are sufficient. import { afterEach, describe, expect, it, vi } from "vitest"; import { streamTail, type TailEvent } from "./tail-stream"; /** * Build a controllable ReadableStream and a tiny driver. The * stream's controller is captured at construction so the test can decide * exactly when each NDJSON line is enqueued and when to close. The body * is wrapped in a `Response` (matching what `fetch()` would return). */ function makeStream() { const encoder = new TextEncoder(); let controllerRef: ReadableStreamDefaultController | undefined; const stream = new ReadableStream({ start(c) { controllerRef = c; }, }); return { response: new Response(stream, { status: 200, headers: { "content-type": "application/x-ndjson" }, }), push: (line: string) => { controllerRef?.enqueue(encoder.encode(line + "\n")); }, close: () => controllerRef?.close(), }; } /** * Drain an async iterable into an array. Useful for asserting the full * emission list without manually awaiting each `.next()`. */ async function collect(iter: AsyncIterableIterator): Promise { const out: T[] = []; for await (const v of iter) out.push(v); return out; } describe("streamTail", () => { afterEach(() => { vi.unstubAllGlobals(); }); it("test_parses_well_formed_ndjson_into_typed_events", async () => { const driver = makeStream(); const fetchMock = vi.fn().mockResolvedValue(driver.response); vi.stubGlobal("fetch", fetchMock); const gen = streamTail("claims"); driver.push('{"type":"item","data":{"id":"CLM-1"}}'); driver.push('{"type":"item","data":{"id":"CLM-2"}}'); driver.push('{"type":"item","data":{"id":"CLM-3"}}'); driver.close(); const first = await gen.next(); const second = await gen.next(); const third = await gen.next(); const tail = await gen.next(); expect(first.done).toBe(false); expect(second.done).toBe(false); expect(third.done).toBe(false); expect(tail.done).toBe(true); expect([first.value, second.value, third.value]).toEqual([ { type: "item", data: { id: "CLM-1" } }, { type: "item", data: { id: "CLM-2" } }, { type: "item", data: { id: "CLM-3" } }, ]); // The fetch call itself must target /api/claims/stream with the // NDJSON Accept header. expect(fetchMock).toHaveBeenCalledTimes(1); const [calledUrl, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(calledUrl).toBe("/api/claims/stream"); expect(calledInit.headers).toMatchObject({ Accept: "application/x-ndjson" }); }); it("test_yields_typed_error_on_error_line", async () => { const driver = makeStream(); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response)); const gen = streamTail("remittances"); driver.push('{"type":"error","data":{"message":"x"}}'); driver.close(); const events = await collect(gen); expect(events).toEqual([ { type: "error", data: { message: "x" } }, ]); }); it("test_handles_heartbeat_silently", async () => { // "Silently" here means: still yields the correctly-typed heartbeat // (no special filtering) — the consumer (useTailStream) decides what // to do with it. The lib just has to forward it. const driver = makeStream(); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response)); const gen = streamTail("activity"); driver.push('{"type":"heartbeat","data":{"ts":"2026-06-20T12:00:00Z"}}'); driver.close(); const events = await collect(gen); expect(events).toEqual([ { type: "heartbeat", data: { ts: "2026-06-20T12:00:00Z" } }, ]); }); it("test_abort_signal_cancels_mid_stream", async () => { const driver = makeStream(); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response)); const ac = new AbortController(); const gen = streamTail("claims", { signal: ac.signal }); // First event arrives normally. driver.push('{"type":"item","data":{"id":"CLM-1"}}'); const first = await gen.next(); expect(first.done).toBe(false); // Abort before any more events arrive, then close the upstream so // any pending read completes. The generator must exit cleanly // (no throw) and not yield further. ac.abort(); driver.close(); const second = await gen.next(); expect(second.done).toBe(true); }); it("test_malformed_line_skipped_next_valid_still_emitted", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const driver = makeStream(); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response)); const gen = streamTail("claims"); driver.push('{"type":"item","data":{"id":"CLM-1"}}'); driver.push("this is not json"); driver.push('{"type":"item","data":{"id":"CLM-2"}}'); driver.close(); const events = await collect(gen); expect(events).toEqual([ { type: "item", data: { id: "CLM-1" } }, { type: "item", data: { id: "CLM-2" } }, ]); // The malformed line must produce a console.warn so operators can spot // a buggy backend without crashing the whole tail. expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); }); });