import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { spawnJSONLProcess, spawnProcess, type SubprocessOptions, } from "../src/subprocess"; import * as cp from "child_process"; import { EventEmitter } from "events"; import { Readable } from "stream"; vi.mock("child_process"); /** * Helper to collect all items from an async generator */ async function collectAsyncGenerator( generator: AsyncGenerator ): Promise { const results: T[] = []; for await (const item of generator) { results.push(item); } return results; } describe("subprocess.ts", () => { let consoleSpy: { log: ReturnType; error: ReturnType; }; beforeEach(() => { vi.clearAllMocks(); consoleSpy = { log: vi.spyOn(console, "log").mockImplementation(() => {}), error: vi.spyOn(console, "error").mockImplementation(() => {}), }; }); afterEach(() => { consoleSpy.log.mockRestore(); consoleSpy.error.mockRestore(); }); /** * Helper to create a mock ChildProcess with stdout/stderr streams */ function createMockProcess(config: { stdoutLines?: string[]; stderrLines?: string[]; exitCode?: number; error?: Error; delayMs?: number; }) { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; // Create readable streams for stdout and stderr const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); mockProcess.stdout = stdout; mockProcess.stderr = stderr; mockProcess.kill = vi.fn().mockReturnValue(true); // Use process.nextTick to ensure readline interface is set up first process.nextTick(() => { // Emit stderr lines immediately if (config.stderrLines) { for (const line of config.stderrLines) { stderr.emit("data", Buffer.from(line)); } } // Emit stdout lines with small delays to ensure readline processes them const emitLines = async () => { if (config.stdoutLines) { for (const line of config.stdoutLines) { stdout.push(line + "\n"); // Small delay to allow readline to process await new Promise((resolve) => setImmediate(resolve)); } } // Small delay before ending stream await new Promise((resolve) => setImmediate(resolve)); stdout.push(null); // End stdout // Small delay before exit await new Promise((resolve) => setTimeout(resolve, config.delayMs ?? 10) ); // Emit exit or error if (config.error) { mockProcess.emit("error", config.error); } else { mockProcess.emit("exit", config.exitCode ?? 0); } }; emitLines(); }); return mockProcess; } describe("spawnJSONLProcess", () => { const baseOptions: SubprocessOptions = { command: "test-command", args: ["arg1", "arg2"], cwd: "/test/dir", }; it("should yield parsed JSONL objects line by line", async () => { const mockProcess = createMockProcess({ stdoutLines: [ '{"type":"start","id":1}', '{"type":"progress","value":50}', '{"type":"complete","result":"success"}', ], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(3); expect(results[0]).toEqual({ type: "start", id: 1 }); expect(results[1]).toEqual({ type: "progress", value: 50 }); expect(results[2]).toEqual({ type: "complete", result: "success" }); }); it("should skip empty lines", async () => { const mockProcess = createMockProcess({ stdoutLines: [ '{"type":"first"}', "", " ", '{"type":"second"}', ], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[0]).toEqual({ type: "first" }); expect(results[1]).toEqual({ type: "second" }); }); it("should yield error for malformed JSON and continue processing", async () => { const mockProcess = createMockProcess({ stdoutLines: [ '{"type":"valid"}', "{invalid json}", '{"type":"also_valid"}', ], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(3); expect(results[0]).toEqual({ type: "valid" }); expect(results[1]).toMatchObject({ type: "error", error: expect.stringContaining("Failed to parse output"), }); expect(results[2]).toEqual({ type: "also_valid" }); }); it("should collect stderr output", async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"test"}'], stderrLines: ["Warning: something happened", "Error: critical issue"], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); await collectAsyncGenerator(generator); expect(consoleSpy.error).toHaveBeenCalledWith( expect.stringContaining("Warning: something happened") ); expect(consoleSpy.error).toHaveBeenCalledWith( expect.stringContaining("Error: critical issue") ); }); it("should yield error on non-zero exit code", async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"started"}'], stderrLines: ["Process failed with error"], exitCode: 1, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[0]).toEqual({ type: "started" }); expect(results[1]).toMatchObject({ type: "error", error: expect.stringContaining("Process failed with error"), }); }); it("should yield error with exit code when stderr is empty", async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"test"}'], exitCode: 127, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[1]).toMatchObject({ type: "error", error: "Process exited with code 127", }); }); it("should handle process spawn errors", async () => { const mockProcess = createMockProcess({ error: new Error("Command not found"), }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); // When process.on('error') fires, exitCode is null // The generator should handle this gracefully expect(results).toEqual([]); }); it("should kill process on AbortController signal", async () => { const abortController = new AbortController(); const mockProcess = createMockProcess({ stdoutLines: ['{"type":"start"}'], exitCode: 0, delayMs: 100, // Delay to allow abort }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess({ ...baseOptions, abortController, }); // Start consuming the generator const promise = collectAsyncGenerator(generator); // Abort after a short delay setTimeout(() => abortController.abort(), 20); await promise; expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM"); expect(consoleSpy.log).toHaveBeenCalledWith( expect.stringContaining("Abort signal received") ); }); it("should spawn process with correct arguments", async () => { const mockProcess = createMockProcess({ exitCode: 0 }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const options: SubprocessOptions = { command: "my-command", args: ["--flag", "value"], cwd: "/work/dir", env: { CUSTOM_VAR: "test" }, }; const generator = spawnJSONLProcess(options); await collectAsyncGenerator(generator); expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], { cwd: "/work/dir", env: expect.objectContaining({ CUSTOM_VAR: "test" }), stdio: ["ignore", "pipe", "pipe"], }); }); it("should merge env with process.env", async () => { const mockProcess = createMockProcess({ exitCode: 0 }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const options: SubprocessOptions = { command: "test", args: [], cwd: "/test", env: { CUSTOM: "value" }, }; const generator = spawnJSONLProcess(options); await collectAsyncGenerator(generator); expect(cp.spawn).toHaveBeenCalledWith( "test", [], expect.objectContaining({ env: expect.objectContaining({ CUSTOM: "value", // Should also include existing process.env NODE_ENV: process.env.NODE_ENV, }), }) ); }); it("should handle complex JSON objects", async () => { const complexObject = { type: "complex", nested: { deep: { value: [1, 2, 3] } }, array: [{ id: 1 }, { id: 2 }], string: 'with "quotes" and \\backslashes', }; const mockProcess = createMockProcess({ stdoutLines: [JSON.stringify(complexObject)], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(1); expect(results[0]).toEqual(complexObject); }); }); describe("spawnProcess", () => { const baseOptions: SubprocessOptions = { command: "test-command", args: ["arg1"], cwd: "/test", }; it("should collect stdout and stderr", async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); mockProcess.stdout = stdout; mockProcess.stderr = stderr; mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { stdout.push("line 1\n"); stdout.push("line 2\n"); stdout.push(null); stderr.push("error 1\n"); stderr.push("error 2\n"); stderr.push(null); mockProcess.emit("exit", 0); }, 10); const result = await spawnProcess(baseOptions); expect(result.stdout).toBe("line 1\nline 2\n"); expect(result.stderr).toBe("error 1\nerror 2\n"); expect(result.exitCode).toBe(0); }); it("should return correct exit code", async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.stdout.push(null); mockProcess.stderr.push(null); mockProcess.emit("exit", 42); }, 10); const result = await spawnProcess(baseOptions); expect(result.exitCode).toBe(42); }); it("should handle process errors", async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.emit("error", new Error("Spawn failed")); }, 10); await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed"); }); it("should handle AbortController signal", async () => { const abortController = new AbortController(); const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => abortController.abort(), 20); await expect( spawnProcess({ ...baseOptions, abortController }) ).rejects.toThrow("Process aborted"); expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM"); }); it("should spawn with correct options", async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.stdout.push(null); mockProcess.stderr.push(null); mockProcess.emit("exit", 0); }, 10); const options: SubprocessOptions = { command: "my-cmd", args: ["--verbose"], cwd: "/my/dir", env: { MY_VAR: "value" }, }; await spawnProcess(options); expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], { cwd: "/my/dir", env: expect.objectContaining({ MY_VAR: "value" }), stdio: ["ignore", "pipe", "pipe"], }); }); it("should handle empty stdout and stderr", async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.stdout.push(null); mockProcess.stderr.push(null); mockProcess.emit("exit", 0); }, 10); const result = await spawnProcess(baseOptions); expect(result.stdout).toBe(""); expect(result.stderr).toBe(""); expect(result.exitCode).toBe(0); }); }); });