import { type ChildProcess, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { Writable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", () => ({ spawn: vi.fn(), })); vi.mock("sharp", () => ({ default: vi.fn(), })); function createMockProcess(): { process: ChildProcess; stdin: Writable; stdout: EventEmitter; stderr: EventEmitter; emitEvent: (event: string, ...args: unknown[]) => void; stdinWrites: string[]; } { const stdinWrites: string[] = []; const stdin = new Writable({ write(chunk, _encoding, callback) { stdinWrites.push(chunk.toString()); callback(); }, }); const stdout = new EventEmitter(); const stderr = new EventEmitter(); const proc = new EventEmitter() as unknown as ChildProcess; Object.assign(proc, { stdin, stdout, stderr, pid: 12345, killed: false, kill: vi.fn(() => { (proc as { killed: boolean }).killed = true; return true; }), }); return { process: proc, stdin, stdout, stderr, emitEvent: (event: string, ...args: unknown[]) => proc.emit(event, ...args), stdinWrites, }; } describe("buildMinimalEnv - env passthrough", () => { let runPythonWithProgress: ( scriptName: string, args: string[], options?: { onProgress?: (p: number, s: string) => void; timeout?: number }, ) => Promise<{ stdout: string; stderr: string }>; beforeEach(async () => { vi.resetModules(); const mod = await import("../../../packages/ai/src/bridge.js"); runPythonWithProgress = mod.runPythonWithProgress; }); afterEach(() => { vi.restoreAllMocks(); delete process.env.SNAPOTTER_GPU; delete process.env.MODELS_PATH; delete process.env.MODELS_DIR; }); function getSpawnEnv(): Record | undefined { const allCalls = vi.mocked(spawn).mock.calls; const lastCall = allCalls[allCalls.length - 1]; return (lastCall?.[2] as { env?: Record })?.env; } it("passes SNAPOTTER_GPU to Python subprocess", async () => { process.env.SNAPOTTER_GPU = "1"; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env).toBeDefined(); expect(env?.SNAPOTTER_GPU).toBe("1"); }); it("passes MODELS_PATH to Python subprocess", async () => { process.env.MODELS_PATH = "/data/ai/models"; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env).toBeDefined(); expect(env?.MODELS_PATH).toBe("/data/ai/models"); }); it("does not pass MODELS_DIR (removed dead entry)", async () => { process.env.MODELS_DIR = "/some/path"; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env).toBeDefined(); expect(env?.MODELS_DIR).toBeUndefined(); }); it("does not include SNAPOTTER_GPU when not set in parent env", async () => { delete process.env.SNAPOTTER_GPU; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env).toBeDefined(); expect(env?.SNAPOTTER_GPU).toBeUndefined(); }); it("always includes PYTHONUNBUFFERED=1", async () => { const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env).toBeDefined(); expect(env?.PYTHONUNBUFFERED).toBe("1"); }); it("passes LD_LIBRARY_PATH when set", async () => { process.env.LD_LIBRARY_PATH = "/usr/local/nvidia/lib64"; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env?.LD_LIBRARY_PATH).toBe("/usr/local/nvidia/lib64"); delete process.env.LD_LIBRARY_PATH; }); it("passes CUDA_VISIBLE_DEVICES when set", async () => { process.env.CUDA_VISIBLE_DEVICES = "0,1"; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env?.CUDA_VISIBLE_DEVICES).toBe("0,1"); delete process.env.CUDA_VISIBLE_DEVICES; }); it("does not leak unrelated env vars to Python subprocess", async () => { process.env.SECRET_API_KEY = "should-not-leak"; process.env.DATABASE_URL = "sqlite://secret.db"; const mock = createMockProcess(); vi.mocked(spawn).mockReturnValue(mock.process); const promise = runPythonWithProgress("test.py", []); mock.stdout.emit("data", Buffer.from('{"ok": true}\n')); mock.emitEvent("close", 0, null); await promise; const env = getSpawnEnv(); expect(env?.SECRET_API_KEY).toBeUndefined(); expect(env?.DATABASE_URL).toBeUndefined(); delete process.env.SECRET_API_KEY; delete process.env.DATABASE_URL; }); });