import { type ChildProcess, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Mock child_process.spawn before importing the bridge module. // Each test gets its own mock process via `createMockProcess`. // --------------------------------------------------------------------------- vi.mock("node:child_process", () => ({ spawn: vi.fn(), })); const spawnMock = vi.mocked(spawn); // --------------------------------------------------------------------------- // Helpers: Build a fake ChildProcess with piped stdin / stdout / stderr // --------------------------------------------------------------------------- interface MockProcess extends EventEmitter { pid: number; stdin: Writable & { _written: string[] }; stdout: Readable; stderr: Readable; killed: boolean; kill: ReturnType; _pushStdout: (data: string) => void; _pushStderr: (data: string) => void; _emitClose: (code: number | null, signal?: string | null) => void; _emitError: (err: NodeJS.ErrnoException) => void; } function createMockProcess(pid = 12345): MockProcess { const proc = new EventEmitter() as MockProcess; proc.pid = pid; proc.killed = false; const stdinBuf: string[] = []; const writable = new Writable({ write(chunk, _enc, cb) { stdinBuf.push(chunk.toString()); cb(); }, }); (writable as Writable & { _written: string[] })._written = stdinBuf; proc.stdin = writable as Writable & { _written: string[] }; proc.stdout = new Readable({ read() {} }); proc.stderr = new Readable({ read() {} }); proc.kill = vi.fn(() => { proc.killed = true; return true; }); proc._pushStdout = (data: string) => proc.stdout.push(data); proc._pushStderr = (data: string) => proc.stderr.push(data); proc._emitClose = (code, signal = null) => proc.emit("close", code, signal); proc._emitError = (err) => proc.emit("error", err); return proc; } // --------------------------------------------------------------------------- // Module-level state reset between tests. // // bridge.ts stores dispatcher state in module-level variables. To isolate // tests we re-import the module fresh each time. // --------------------------------------------------------------------------- let bridge: typeof import("../../../packages/ai/src/bridge.js"); async function freshBridge() { vi.resetModules(); // Re-mock child_process after resetModules vi.doMock("node:child_process", () => ({ spawn: spawnMock, })); bridge = await import("../../../packages/ai/src/bridge.js"); } beforeEach(async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); spawnMock.mockReset(); await freshBridge(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); // --------------------------------------------------------------------------- // Helper: Spawn the dispatcher, send readiness signal, return the mock proc. // --------------------------------------------------------------------------- async function spawnReadyDispatcher(opts?: { gpu?: boolean }): Promise { const proc = createMockProcess(); spawnMock.mockReturnValue(proc as unknown as ChildProcess); const initPromise = bridge.initDispatcher(5_000); // Emit readiness signal on stderr proc._pushStderr(`${JSON.stringify({ ready: true, gpu: opts?.gpu ?? false })}\n`); const status = await initPromise; expect(status.ready).toBe(true); return proc; } // Helper: Disable the dispatcher permanently via ENOENT so tests can // exercise the per-request fallback path. async function disableDispatcher(): Promise { const badProc = createMockProcess(); spawnMock.mockReturnValueOnce(badProc as unknown as ChildProcess); const initPromise = bridge.initDispatcher(200); const enoent = new Error("not found") as NodeJS.ErrnoException; enoent.code = "ENOENT"; badProc._emitError(enoent); await initPromise; expect(bridge.getDispatcherStatus().failed).toBe(true); } // ═══════════════════════════════════════════════════════════════════════════ // Tests // ═══════════════════════════════════════════════════════════════════════════ describe("AI Bridge - parseStdoutJson", () => { it("extracts JSON object from the end of stdout", () => { const result = bridge.parseStdoutJson('Some text\n{"success":true,"width":100}'); expect(result).toEqual({ success: true, width: 100 }); }); it("handles stdout that is only JSON", () => { const result = bridge.parseStdoutJson('{"ok":1}'); expect(result).toEqual({ ok: 1 }); }); it("throws when no JSON object is present", () => { expect(() => bridge.parseStdoutJson("no json here")).toThrow( "No JSON response from Python script", ); }); it("throws on empty string", () => { expect(() => bridge.parseStdoutJson("")).toThrow("No JSON response from Python script"); }); it("handles nested JSON objects", () => { const input = '{"outer":{"inner":42},"list":[1,2]}'; const result = bridge.parseStdoutJson(input); expect(result).toEqual({ outer: { inner: 42 }, list: [1, 2] }); }); }); describe("AI Bridge - isGpuAvailable", () => { it("returns false when dispatcher has not started", () => { expect(bridge.isGpuAvailable()).toBe(false); }); it("returns true when dispatcher reports GPU", async () => { await spawnReadyDispatcher({ gpu: true }); expect(bridge.isGpuAvailable()).toBe(true); }); it("returns false when dispatcher reports no GPU", async () => { await spawnReadyDispatcher({ gpu: false }); expect(bridge.isGpuAvailable()).toBe(false); }); }); describe("AI Bridge - getDispatcherStatus", () => { it("reports not running initially", () => { const status = bridge.getDispatcherStatus(); expect(status.running).toBe(false); expect(status.ready).toBe(false); expect(status.failed).toBe(false); expect(status.gpu).toBe(false); expect(status.pid).toBeNull(); expect(status.consecutiveCrashes).toBe(0); }); it("reports running after init", async () => { const proc = await spawnReadyDispatcher({ gpu: true }); const status = bridge.getDispatcherStatus(); expect(status.running).toBe(true); expect(status.ready).toBe(true); expect(status.gpu).toBe(true); expect(status.pid).toBe(proc.pid); }); }); describe("AI Bridge - initDispatcher", () => { it("spawns python process with dispatcher.py", async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc as unknown as ChildProcess); const initPromise = bridge.initDispatcher(5_000); proc._pushStderr(`${JSON.stringify({ ready: true, gpu: false })}\n`); const result = await initPromise; expect(result).toEqual({ ready: true, gpu: false }); expect(spawnMock).toHaveBeenCalledTimes(1); const callArgs = spawnMock.mock.calls[0]; expect(callArgs[1]?.[0]).toContain("dispatcher.py"); }); it("returns ready:false on timeout", async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc as unknown as ChildProcess); const initPromise = bridge.initDispatcher(200); // Do NOT send the ready signal vi.advanceTimersByTime(300); const result = await initPromise; expect(result).toEqual({ ready: false, gpu: false }); }); it("is idempotent when already ready", async () => { await spawnReadyDispatcher(); const result = await bridge.initDispatcher(500); expect(result).toEqual({ ready: true, gpu: false }); // spawn should have been called only once total expect(spawnMock).toHaveBeenCalledTimes(1); }); it("returns ready:false when dispatcher failed permanently", async () => { await disableDispatcher(); const result = await bridge.initDispatcher(200); expect(result).toEqual({ ready: false, gpu: false }); expect(bridge.getDispatcherStatus().failed).toBe(true); }); }); describe("AI Bridge - shutdownDispatcher", () => { it("kills the dispatcher process", async () => { const proc = await spawnReadyDispatcher(); bridge.shutdownDispatcher(); expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); }); it("marks status as not running after shutdown", async () => { await spawnReadyDispatcher(); bridge.shutdownDispatcher(); const status = bridge.getDispatcherStatus(); expect(status.running).toBe(false); expect(status.ready).toBe(false); }); it("is safe to call when no dispatcher is running", () => { expect(() => bridge.shutdownDispatcher()).not.toThrow(); }); }); describe("AI Bridge - runPythonWithProgress (dispatcher path)", () => { it("sends JSON-lines request to dispatcher stdin", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("test_script.py", ["arg1", "arg2"]); // Read the request that was written to stdin expect(proc.stdin._written.length).toBe(1); const request = JSON.parse(proc.stdin._written[0].trim()); expect(request.script).toBe("test_script"); expect(request.args).toEqual(["arg1", "arg2"]); expect(request.id).toBeDefined(); // Send matching response on stdout proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: '{"ok":true}', exitCode: 0 })}\n`); const result = await resultPromise; expect(result.stdout).toBe('{"ok":true}'); }); it("strips .py from script name in dispatcher request", async () => { const proc = await spawnReadyDispatcher(); bridge.runPythonWithProgress("remove_bg.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); expect(request.script).toBe("remove_bg"); }); it("resolves with stdout and stderr", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("test.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); // Push a non-JSON stderr line (gets collected) proc._pushStderr("some warning\n"); // Now send the response proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "output", exitCode: 0 })}\n`); const result = await resultPromise; expect(result.stdout).toBe("output"); expect(result.stderr).toContain("some warning"); }); it("rejects on non-zero exit code", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("fail.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "", exitCode: 1 })}\n`); await expect(resultPromise).rejects.toThrow("Python script exited with code 1"); }); it("returns OOM message for exit code 137", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("oom.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "", exitCode: 137 })}\n`); await expect(resultPromise).rejects.toThrow("out of memory"); }); it("returns segfault message for exit code 139", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("crash.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "", exitCode: 139 })}\n`); await expect(resultPromise).rejects.toThrow("segmentation fault"); }); it("extracts error from JSON stdout on failure", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("fail.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); proc._pushStdout( `${JSON.stringify({ id: request.id, stdout: '{"error":"Model not found"}', exitCode: 1, })}\n`, ); await expect(resultPromise).rejects.toThrow("Model not found"); }); it("handles concurrent requests matched by id", async () => { const proc = await spawnReadyDispatcher(); const promise1 = bridge.runPythonWithProgress("script1.py", ["a"]); const promise2 = bridge.runPythonWithProgress("script2.py", ["b"]); const req1 = JSON.parse(proc.stdin._written[0].trim()); const req2 = JSON.parse(proc.stdin._written[1].trim()); // Respond to req2 first, then req1 (out of order) proc._pushStdout(`${JSON.stringify({ id: req2.id, stdout: "result-2", exitCode: 0 })}\n`); proc._pushStdout(`${JSON.stringify({ id: req1.id, stdout: "result-1", exitCode: 0 })}\n`); const [res1, res2] = await Promise.all([promise1, promise2]); expect(res1.stdout).toBe("result-1"); expect(res2.stdout).toBe("result-2"); }); it("routes progress events to the onProgress callback", async () => { const proc = await spawnReadyDispatcher(); const progressCalls: Array<{ percent: number; stage: string }> = []; const onProgress = (percent: number, stage: string) => { progressCalls.push({ percent, stage }); }; const resultPromise = bridge.runPythonWithProgress("slow.py", [], { onProgress }); const request = JSON.parse(proc.stdin._written[0].trim()); // Emit progress events on stderr proc._pushStderr(`${JSON.stringify({ progress: 25, stage: "Loading model" })}\n`); proc._pushStderr(`${JSON.stringify({ progress: 75, stage: "Processing" })}\n`); // Complete the request proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "done", exitCode: 0 })}\n`); await resultPromise; expect(progressCalls).toEqual([ { percent: 25, stage: "Loading model" }, { percent: 75, stage: "Processing" }, ]); }); it("rejects with timeout error and kills dispatcher", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("slow.py", [], { timeout: 500 }); // Advance past the timeout vi.advanceTimersByTime(600); await expect(resultPromise).rejects.toThrow("Python script timed out"); expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); }); it("uses PROCESSING_TIMEOUT_S env var when set", async () => { const savedEnv = process.env.PROCESSING_TIMEOUT_S; process.env.PROCESSING_TIMEOUT_S = "2"; try { await freshBridge(); const _proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("env_timeout.py", []); // 2s timeout from env var vi.advanceTimersByTime(2100); await expect(resultPromise).rejects.toThrow("Python script timed out"); } finally { if (savedEnv === undefined) { delete process.env.PROCESSING_TIMEOUT_S; } else { process.env.PROCESSING_TIMEOUT_S = savedEnv; } } }); it("clears timeout on successful response", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("fast.py", [], { timeout: 1000 }); const request = JSON.parse(proc.stdin._written[0].trim()); // Respond quickly proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "ok", exitCode: 0 })}\n`); const result = await resultPromise; expect(result.stdout).toBe("ok"); // Advance time past the would-be timeout -- should not throw vi.advanceTimersByTime(2000); }); }); describe("AI Bridge - runPythonWithProgress (per-request fallback)", () => { it("falls back to per-request mode when dispatcher is not available", async () => { await disableDispatcher(); // Now runPythonWithProgress should use per-request fallback const fallbackProc = createMockProcess(99999); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("fallback.py", ["x", "y"]); // Per-request mode spawns python with [scriptPath, ...args] expect(spawnMock.mock.calls.length).toBeGreaterThanOrEqual(2); const lastCall = spawnMock.mock.calls[spawnMock.mock.calls.length - 1]; expect(lastCall[1]?.[0]).toContain("fallback.py"); expect(lastCall[1]?.slice(1)).toEqual(["x", "y"]); // Simulate successful exit -- use nextTick to ensure listeners are attached await vi.advanceTimersByTimeAsync(0); fallbackProc._pushStdout('{"success":true}'); fallbackProc._emitClose(0); const result = await resultPromise; // Per-request mode trims stdout expect(result.stdout).toBe('{"success":true}'); }); it("per-request mode parses progress events on stderr", async () => { await disableDispatcher(); const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const progressCalls: Array<{ percent: number; stage: string }> = []; const resultPromise = bridge.runPythonWithProgress("progress.py", [], { onProgress: (p, s) => progressCalls.push({ percent: p, stage: s }), }); // Let listeners attach await vi.advanceTimersByTimeAsync(0); fallbackProc._pushStderr(`${JSON.stringify({ progress: 50, stage: "Half done" })}\n`); fallbackProc._pushStdout("output"); fallbackProc._emitClose(0); await resultPromise; expect(progressCalls).toEqual([{ percent: 50, stage: "Half done" }]); }); it("per-request mode rejects on timeout", async () => { await disableDispatcher(); const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("slow.py", [], { timeout: 400 }); // Advance past timeout vi.advanceTimersByTime(500); // The timeout handler calls child.kill("SIGTERM"), then the close event fires // with the timedOut flag set fallbackProc._emitClose(null, "SIGTERM"); await expect(resultPromise).rejects.toThrow("Python script timed out"); }); it("per-request mode rejects on non-zero exit", async () => { await disableDispatcher(); const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("bad.py", []); // Let listeners attach, then push data await vi.advanceTimersByTimeAsync(0); fallbackProc._pushStdout('{"error":"something broke"}'); fallbackProc._emitClose(1); await expect(resultPromise).rejects.toThrow("something broke"); }); it("per-request SIGKILL maps to OOM message", async () => { await disableDispatcher(); const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("oom.py", []); // Let listeners attach await vi.advanceTimersByTimeAsync(0); fallbackProc._emitClose(null, "SIGKILL"); await expect(resultPromise).rejects.toThrow("out of memory"); }); it("per-request SIGSEGV maps to segfault message", async () => { await disableDispatcher(); const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("seg.py", []); // Let listeners attach await vi.advanceTimersByTimeAsync(0); fallbackProc._emitClose(null, "SIGSEGV"); await expect(resultPromise).rejects.toThrow("segmentation fault"); }); it("per-request fallback tries system python3 on ENOENT", async () => { await disableDispatcher(); // First per-request spawn gets ENOENT (venv python not found), // then the fallback to "python3" succeeds. const failProc = createMockProcess(); const successProc = createMockProcess(); let perRequestCallCount = 0; spawnMock.mockImplementation(() => { perRequestCallCount++; if (perRequestCallCount === 1) { // First per-request attempt (venv python) return failProc as unknown as ChildProcess; } // The python3 fallback return successProc as unknown as ChildProcess; }); const resultPromise = bridge.runPythonWithProgress("test.py", []); // Let listeners attach to failProc await vi.advanceTimersByTimeAsync(0); // First attempt fails with ENOENT const enoent2 = new Error("ENOENT") as NodeJS.ErrnoException; enoent2.code = "ENOENT"; failProc._emitError(enoent2); // Let listeners attach to successProc await vi.advanceTimersByTimeAsync(0); // Fallback succeeds successProc._pushStdout("fallback ok"); successProc._emitClose(0); const result = await resultPromise; expect(result.stdout).toBe("fallback ok"); }); }); describe("AI Bridge - crash recovery", () => { it("rejects pending requests when dispatcher closes unexpectedly", async () => { const proc = await spawnReadyDispatcher(); // Set up the fallback proc that will also reject, so the promise settles const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("test.py", []); // Simulate unexpected close -- triggers "Python dispatcher exited unexpectedly" // which runPythonWithProgress catches and retries with per-request mode proc._emitClose(1); // Let the per-request fallback spawn await vi.advanceTimersByTimeAsync(0); // Complete the fallback fallbackProc._pushStdout("recovered"); fallbackProc._emitClose(0); const result = await resultPromise; // The retry path appends a note to stderr expect(result.stderr).toContain("retried after dispatcher crash"); }); it("increments crash count on non-zero close", async () => { const proc = await spawnReadyDispatcher(); proc._emitClose(1); const status = bridge.getDispatcherStatus(); expect(status.consecutiveCrashes).toBe(1); }); it("retries with per-request mode after dispatcher crash during request", async () => { const proc = await spawnReadyDispatcher(); // Set up a per-request fallback proc const fallbackProc = createMockProcess(); spawnMock.mockReturnValue(fallbackProc as unknown as ChildProcess); const resultPromise = bridge.runPythonWithProgress("test.py", ["arg"]); // Crash the dispatcher proc._emitClose(1); // The bridge retries with per-request mode on "Python dispatcher exited unexpectedly" // Wait for the fallback to be spawned await vi.advanceTimersByTimeAsync(100); fallbackProc._pushStdout("fallback result"); fallbackProc._emitClose(0); const result = await resultPromise; expect(result.stdout).toBe("fallback result"); expect(result.stderr).toContain("retried after dispatcher crash"); }); it("marks dispatcher as permanently failed after max consecutive crashes", async () => { // Crash the dispatcher 5 times within the crash window on a single bridge instance for (let i = 0; i < 5; i++) { const proc = createMockProcess(); spawnMock.mockReturnValue(proc as unknown as ChildProcess); bridge.initDispatcher(200); proc._emitClose(1); // Non-zero close triggers recordCrash // Advance past backoff (but stay within crash window) vi.advanceTimersByTime(10_000); } const status = bridge.getDispatcherStatus(); expect(status.failed).toBe(true); }); it("applies exponential backoff between crash restarts", async () => { const proc1 = createMockProcess(); spawnMock.mockReturnValue(proc1 as unknown as ChildProcess); const initPromise = bridge.initDispatcher(200); proc1._pushStderr(`${JSON.stringify({ ready: true, gpu: false })}\n`); await initPromise; // Crash once proc1._emitClose(1); // Immediately trying to use the dispatcher should get null (backoff) // The status should show consecutiveCrashes = 1 const status = bridge.getDispatcherStatus(); expect(status.consecutiveCrashes).toBe(1); expect(status.running).toBe(false); }); it("rejects all pending requests on dispatcher error", async () => { const proc = await spawnReadyDispatcher(); // We need fallback procs for the retry path. Make them also fail so // the promises reject rather than hanging. const fallback1 = createMockProcess(); const fallback2 = createMockProcess(); let fallbackIdx = 0; spawnMock.mockImplementation(() => { fallbackIdx++; return (fallbackIdx === 1 ? fallback1 : fallback2) as unknown as ChildProcess; }); const promise1 = bridge.runPythonWithProgress("a.py", []); const promise2 = bridge.runPythonWithProgress("b.py", []); // Emit error -- this rejects both pending requests with the error message. // But runPythonWithProgress only retries on "exited unexpectedly", not other errors. const err = new Error("connection lost"); proc._emitError(err); await expect(promise1).rejects.toThrow(); await expect(promise2).rejects.toThrow(); }); it("ENOENT error permanently disables dispatcher", async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc as unknown as ChildProcess); bridge.initDispatcher(200); const enoent = new Error("ENOENT") as NodeJS.ErrnoException; enoent.code = "ENOENT"; proc._emitError(enoent); expect(bridge.getDispatcherStatus().failed).toBe(true); }); }); describe("AI Bridge - progress event parsing", () => { it("routes JSON progress events from stderr to onProgress", async () => { const proc = await spawnReadyDispatcher(); const progressCalls: Array<{ percent: number; stage: string }> = []; const resultPromise = bridge.runPythonWithProgress("test.py", [], { onProgress: (p, s) => progressCalls.push({ percent: p, stage: s }), }); const request = JSON.parse(proc.stdin._written[0].trim()); proc._pushStderr(`${JSON.stringify({ progress: 10, stage: "Init" })}\n`); proc._pushStderr(`${JSON.stringify({ progress: 50, stage: "Processing" })}\n`); proc._pushStderr(`${JSON.stringify({ progress: 100, stage: "Done" })}\n`); proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "ok", exitCode: 0 })}\n`); await resultPromise; expect(progressCalls).toHaveLength(3); expect(progressCalls[0]).toEqual({ percent: 10, stage: "Init" }); expect(progressCalls[2]).toEqual({ percent: 100, stage: "Done" }); }); it("ignores non-JSON stderr lines (logs them, does not crash)", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("test.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); // Push plain text stderr - should not crash proc._pushStderr("UserWarning: some library warning\n"); proc._pushStderr("[python] Loading module...\n"); proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "ok", exitCode: 0 })}\n`); const result = await resultPromise; expect(result.stdout).toBe("ok"); }); it("readiness signal sets GPU status", async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc as unknown as ChildProcess); const initPromise = bridge.initDispatcher(5_000); proc._pushStderr(`${JSON.stringify({ ready: true, gpu: true })}\n`); const result = await initPromise; expect(result.gpu).toBe(true); expect(bridge.isGpuAvailable()).toBe(true); }); it("collects non-JSON stderr as error context for failed requests", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("fail.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); // Push diagnostic stderr before the error proc._pushStderr("RuntimeError: CUDA out of memory\n"); proc._pushStdout(`${JSON.stringify({ id: request.id, stdout: "", exitCode: 1 })}\n`); await expect(resultPromise).rejects.toThrow("CUDA out of memory"); }); }); describe("AI Bridge - stdout buffering (partial JSON lines)", () => { it("handles response split across multiple chunks", async () => { const proc = await spawnReadyDispatcher(); const resultPromise = bridge.runPythonWithProgress("test.py", []); const request = JSON.parse(proc.stdin._written[0].trim()); const fullResponse = JSON.stringify({ id: request.id, stdout: "chunked", exitCode: 0 }); // Split the response across two pushes (no newline until the end) const mid = Math.floor(fullResponse.length / 2); proc._pushStdout(fullResponse.slice(0, mid)); proc._pushStdout(`${fullResponse.slice(mid)}\n`); const result = await resultPromise; expect(result.stdout).toBe("chunked"); }); });