import { afterAll, describe, expect, test } from "bun:test"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createSidecar, createTranscribeHandlers, parseLanguageAndTask, type Sidecar, type SidecarSegment, type SidecarTranscribeRequest, } from "../src/server/transcribe"; import { isTranscribeStreamLine, type TranscribeStreamLine } from "../src/lib/engine"; import type { SessionUser } from "../src/server/auth"; // M5 server engine: HTTP handlers against a fake sidecar, and the real // Bun.spawn supervisor against a protocol-faithful Python stand-in // (tests/fixtures/fake_sidecar.py) — no faster-whisper needed. The end-to-end // run with real inference is manual/opt-in: see tests/server-engine.md. const FIXTURE_SIDECAR = join(import.meta.dir, "fixtures/fake_sidecar.py"); const REAL_SIDECAR = join(import.meta.dir, "../server-py/sidecar.py"); const tempDirs: string[] = []; async function makeTempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), "transcribe-test-")); tempDirs.push(dir); return dir; } afterAll(async () => { await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const USER: SessionUser = { id: 1, username: "tester" }; const sessionFromCookie = (request: Request): SessionUser | null => request.headers.get("cookie") === "sid=ok" ? USER : null; interface FakeSidecar extends Sidecar { requests: SidecarTranscribeRequest[]; /** Bytes of each request's audio file, captured before cleanup. */ audioPayloads: Uint8Array[]; } function fakeSidecar( behavior: { available?: boolean; reason?: string; segments?: SidecarSegment[]; error?: string; waitForAbort?: boolean; } = {}, ): FakeSidecar { const segments = behavior.segments ?? [ { start: 0, end: 1.5, text: " Hello" }, { start: 1.5, end: 3, text: " world." }, ]; const sidecar: FakeSidecar = { requests: [], audioPayloads: [], async status() { return behavior.available === false ? { available: false, reason: behavior.reason ?? "nope" } : { available: true }; }, async transcribe(request, onSegment, signal) { sidecar.requests.push(request); sidecar.audioPayloads.push( new Uint8Array(await Bun.file(request.audioPath).arrayBuffer()), ); if (behavior.error) throw new Error(behavior.error); onSegment(segments[0]!); if (behavior.waitForAbort) { await new Promise((resolve) => { if (signal?.aborted) return resolve(); signal?.addEventListener("abort", () => resolve(), { once: true }); }); return { cancelled: true }; } for (const segment of segments.slice(1)) onSegment(segment); return { cancelled: false }; }, }; return sidecar; } function pcmBody(samples: number[]): Uint8Array { const audio = new Float32Array(samples); return new Uint8Array(audio.buffer as ArrayBuffer); } function postRequest( query: string, body: BodyInit | null, init: { cookie?: string; signal?: AbortSignal; contentLength?: boolean } = {}, ): Request { const headers: Record = { "Content-Type": "application/octet-stream", }; if (init.cookie !== undefined) headers.cookie = init.cookie; return new Request(`http://localhost/api/transcribe${query}`, { method: "POST", headers, body, signal: init.signal, }); } async function readStreamLines(response: Response): Promise { const text = await response.text(); return text .split("\n") .filter((line) => line.trim().length > 0) .map((line) => { const parsed: unknown = JSON.parse(line); if (!isTranscribeStreamLine(parsed)) throw new Error(`bad line: ${line}`); return parsed; }); } // --------------------------------------------------------------------------- // HTTP handlers (fake sidecar) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // parseLanguageAndTask — shared with the WS live route (src/server/live.ts, // src/index.ts) so it can't skip the validation the HTTP route enforces. // --------------------------------------------------------------------------- describe("parseLanguageAndTask", () => { test("defaults to transcribe with no language", () => { expect(parseLanguageAndTask(new URL("http://localhost/x"))).toEqual({ language: undefined, task: "transcribe", }); }); test("accepts a valid language and task", () => { expect(parseLanguageAndTask(new URL("http://localhost/x?language=da&task=translate"))).toEqual( { language: "da", task: "translate" }, ); }); test('"auto"/empty language means no language override', () => { expect(parseLanguageAndTask(new URL("http://localhost/x?language=auto"))).toEqual({ language: undefined, task: "transcribe", }); expect(parseLanguageAndTask(new URL("http://localhost/x?language="))).toEqual({ language: undefined, task: "transcribe", }); }); test("rejects an invalid language or task", () => { expect(parseLanguageAndTask(new URL("http://localhost/x?language=Danish!"))).toBe( "invalid language: Danish!", ); expect(parseLanguageAndTask(new URL("http://localhost/x?task=summarize"))).toBe( "invalid task: summarize", ); }); }); describe("GET /api/transcribe/status", () => { test("401 without a session", async () => { const handlers = createTranscribeHandlers({ sidecar: fakeSidecar(), getSessionUser: sessionFromCookie, }); const response = await handlers.status( new Request("http://localhost/api/transcribe/status"), ); expect(response.status).toBe(401); }); test("passes the sidecar's availability through", async () => { const handlers = createTranscribeHandlers({ sidecar: fakeSidecar({ available: false, reason: "python3 missing" }), getSessionUser: sessionFromCookie, }); const response = await handlers.status( new Request("http://localhost/api/transcribe/status", { headers: { cookie: "sid=ok" }, }), ); expect(response.status).toBe(200); expect(await response.json()).toEqual({ available: false, reason: "python3 missing", }); }); }); describe("POST /api/transcribe", () => { test("401 without a session — the server engine is logged-in only", async () => { const sidecar = fakeSidecar(); const handlers = createTranscribeHandlers({ sidecar, getSessionUser: sessionFromCookie, }); const response = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1])), ); expect(response.status).toBe(401); expect(sidecar.requests).toHaveLength(0); }); test("400 for missing/unknown model, bad language, bad task", async () => { const handlers = createTranscribeHandlers({ sidecar: fakeSidecar(), getSessionUser: sessionFromCookie, tempDir: await makeTempDir(), }); const cases = [ "", "?model=someone/other-model", "?model=onnx-community/whisper-tiny&language=Danish!", "?model=onnx-community/whisper-tiny&task=summarize", ]; for (const query of cases) { const response = await handlers.post( postRequest(query, pcmBody([0.1]), { cookie: "sid=ok" }), ); expect(response.status).toBe(400); } }); test("503 with the reason when the sidecar is unavailable", async () => { const sidecar = fakeSidecar({ available: false, reason: "faster-whisper is not installed" }); const handlers = createTranscribeHandlers({ sidecar, getSessionUser: sessionFromCookie, }); const response = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), { cookie: "sid=ok", }), ); expect(response.status).toBe(503); expect(await response.json()).toEqual({ error: "faster-whisper is not installed" }); expect(sidecar.requests).toHaveLength(0); }); test("413 over the upload limit; 400 for empty or non-float32 bodies", async () => { const tempDir = await makeTempDir(); const handlers = createTranscribeHandlers({ sidecar: fakeSidecar(), getSessionUser: sessionFromCookie, maxUploadBytes: 16, tempDir, }); const over = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([1, 2, 3, 4, 5]), { cookie: "sid=ok", }), ); expect(over.status).toBe(413); const empty = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", new Uint8Array(0), { cookie: "sid=ok", }), ); expect(empty.status).toBe(400); const misaligned = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", new Uint8Array(6), { cookie: "sid=ok", }), ); expect(misaligned.status).toBe(400); // Rejected uploads must not leave temp files behind. expect(await readdir(tempDir)).toEqual([]); }); test("streams segments then the result, maps the model, cleans up", async () => { const tempDir = await makeTempDir(); const sidecar = fakeSidecar(); const handlers = createTranscribeHandlers({ sidecar, getSessionUser: sessionFromCookie, tempDir, }); const audio = [0.25, -0.25, 0.5, -0.5]; const response = await handlers.post( postRequest( "?model=onnx-community/whisper-base&language=da&task=translate", pcmBody(audio), { cookie: "sid=ok" }, ), ); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("application/x-ndjson"); const lines = await readStreamLines(response); expect(lines).toEqual([ { type: "segment", text: " Hello", start: 0, end: 1.5 }, { type: "segment", text: " world.", start: 1.5, end: 3 }, { type: "result", text: "Hello world.", chunks: [ { text: " Hello", timestamp: [0, 1.5] }, { text: " world.", timestamp: [1.5, 3] }, ], }, ]); // Catalog id → faster-whisper name (D-013), options passed through. expect(sidecar.requests).toEqual([ expect.objectContaining({ model: "base", language: "da", task: "translate" }), ]); // The sidecar saw exactly the uploaded PCM bytes… expect([...new Float32Array(sidecar.audioPayloads[0]!.buffer.slice(0))]).toEqual( audio, ); // …and the temp file is gone once the stream finished. expect(await readdir(tempDir)).toEqual([]); }); test("sidecar errors travel in-stream after the 200 header", async () => { const tempDir = await makeTempDir(); const handlers = createTranscribeHandlers({ sidecar: fakeSidecar({ error: "model load failed" }), getSessionUser: sessionFromCookie, tempDir, }); const response = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), { cookie: "sid=ok", }), ); expect(response.status).toBe(200); const lines = await readStreamLines(response); expect(lines).toEqual([{ type: "error", message: "model load failed" }]); expect(await readdir(tempDir)).toEqual([]); }); test("an aborted request cancels the sidecar run", async () => { const tempDir = await makeTempDir(); const sidecar = fakeSidecar({ waitForAbort: true }); const handlers = createTranscribeHandlers({ sidecar, getSessionUser: sessionFromCookie, tempDir, }); const controller = new AbortController(); const response = await handlers.post( postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), { cookie: "sid=ok", signal: controller.signal, }), ); expect(response.status).toBe(200); controller.abort(); // Cancelled runs still terminate the stream with the partial result. const lines = await readStreamLines(response); expect(lines.at(-1)?.type).toBe("result"); expect(await readdir(tempDir)).toEqual([]); }); }); // --------------------------------------------------------------------------- // createSidecar — the real Bun.spawn supervisor against a Python child // --------------------------------------------------------------------------- describe("createSidecar (fake python sidecar)", () => { test("handshake, segment streaming, and request/option pass-through", async () => { const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]); expect(await sidecar.status()).toEqual({ available: true }); const audioPath = join(await makeTempDir(), "audio.f32"); await Bun.write(audioPath, pcmBody([0.1, 0.2, 0.3])); const segments: SidecarSegment[] = []; const outcome = await sidecar.transcribe( { model: "ok", audioPath, language: "da", task: "translate" }, (segment) => segments.push(segment), ); expect(outcome).toEqual({ cancelled: false }); expect(segments).toHaveLength(2); expect(segments[0]).toEqual({ start: 0, end: 1.5, text: " Hello" }); // The fixture echoes what it received (audio bytes, language, task). expect(segments[1]!.text).toContain("model=ok bytes=12 lang=da task=translate"); }); test("sidecar 'error' lines reject that request only", async () => { const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]); const audioPath = join(await makeTempDir(), "audio.f32"); await Bun.write(audioPath, pcmBody([0.1])); expect( sidecar.transcribe({ model: "boom", audioPath }, () => {}), ).rejects.toThrow("kaboom"); // The process survived; the next request works. const outcome = await sidecar.transcribe({ model: "ok", audioPath }, () => {}); expect(outcome).toEqual({ cancelled: false }); }); test("a crash rejects in-flight requests and the next request respawns", async () => { const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]); const audioPath = join(await makeTempDir(), "audio.f32"); await Bun.write(audioPath, pcmBody([0.1])); expect( sidecar.transcribe({ model: "die", audioPath }, () => {}), ).rejects.toThrow(/exited unexpectedly/); // Supervision: a fresh process serves the next request. const segments: SidecarSegment[] = []; const outcome = await sidecar.transcribe( { model: "ok", audioPath }, (segment) => segments.push(segment), ); expect(outcome).toEqual({ cancelled: false }); expect(segments).toHaveLength(2); }); test("abort sends a cancel and resolves {cancelled: true}", async () => { const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]); const audioPath = join(await makeTempDir(), "audio.f32"); await Bun.write(audioPath, pcmBody([0.1])); const controller = new AbortController(); const outcome = await sidecar.transcribe( { model: "wait-for-cancel", audioPath }, () => controller.abort(), // abort as soon as the first segment arrives controller.signal, ); expect(outcome).toEqual({ cancelled: true }); }); }); describe("createSidecar (degradation, D-013)", () => { test("the real sidecar without faster-whisper reports a permanent reason", async () => { // -S/-I keep site-packages off sys.path, so this behaves like a // Bun-only deployment even on machines that have faster-whisper. const sidecar = createSidecar(["python3", "-S", "-I", REAL_SIDECAR]); const first = await sidecar.status(); expect(first.available).toBe(false); expect(first.reason).toContain("faster-whisper is not installed"); // Remembered as permanent — no respawn storm, same answer immediately. const second = await sidecar.status(); expect(second).toEqual(first); }); test("a missing python binary reports unavailable", async () => { const sidecar = createSidecar(["definitely-not-a-python-binary"]); const status = await sidecar.status(); expect(status.available).toBe(false); }); }); // --------------------------------------------------------------------------- // Opt-in: real faster-whisper end to end (needs python3 + requirements.txt // installed and network/cached weights). See tests/server-engine.md. // FW_INTEGRATION=1 bun test tests/transcribe.test.ts // --------------------------------------------------------------------------- describe.skipIf(!process.env.FW_INTEGRATION)("real faster-whisper sidecar", () => { test( "transcribes PCM through the real sidecar", async () => { const sidecar = createSidecar(); const status = await sidecar.status(); expect(status).toEqual({ available: true }); // One second of a 440 Hz tone — not speech, but exercises model load, // numpy decode, and the full protocol round-trip. const samples = new Float32Array(16_000); for (let i = 0; i < samples.length; i++) { samples[i] = 0.1 * Math.sin((2 * Math.PI * 440 * i) / 16_000); } const audioPath = join(await makeTempDir(), "tone.f32"); await Bun.write(audioPath, new Uint8Array(samples.buffer)); const outcome = await sidecar.transcribe( { model: "tiny.en", audioPath }, () => {}, ); expect(outcome.cancelled).toBe(false); }, { timeout: 300_000 }, ); });