import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { Writable } from "node:stream"; import { test } from "vitest"; import { spawnBufferedChild, spawnStreamingChild } from "./child-process.js"; function collectSink(): { stream: Writable; chunks: string[] } { const chunks: string[] = []; const stream = new Writable({ write(chunk, _enc, cb) { chunks.push(String(chunk)); cb(); }, }); return { stream, chunks }; } /** Resolve once the collected chunks match `pattern`, with a generous timeout * so we never hang forever if the child never emits. */ function waitForOutput(chunks: string[], pattern: RegExp, timeoutMs = 2000): Promise { return new Promise((resolve, reject) => { const start = Date.now(); const tick = () => { if (pattern.test(chunks.join(""))) return resolve(); if (Date.now() - start > timeoutMs) return reject(new Error(`timed out waiting for ${pattern}`)); setTimeout(tick, 5); }; tick(); }); } test("spawnBufferedChild captures stdout", () => { const r = spawnBufferedChild(process.execPath, ["-e", "console.log('buf')"], {}); assert.equal(r.status, 0); assert.match(r.stdout, /buf/); }); test("spawnStreamingChild reports signal when child is killed", async () => { const { stream } = collectSink(); const r = await spawnStreamingChild( process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"], { stdoutSink: stream, stderrSink: stream }, ); assert.equal(r.status, null); assert.equal(r.signal, "SIGTERM"); }); test("spawnStreamingChild forwards chunks and collects full stdout", async () => { const script = [ "console.log('line1');", "setTimeout(() => console.log('line2'), 30);", ].join(""); const { stream, chunks } = collectSink(); const r = await spawnStreamingChild(process.execPath, ["-e", script], { stdoutSink: stream, stderrSink: stream, }); assert.equal(r.status, 0); assert.match(r.stdout, /line1/); assert.match(r.stdout, /line2/); const forwarded = chunks.join(""); assert.match(forwarded, /line1/); assert.match(forwarded, /line2/); }); test("spawnStreamingChild keeps captured stdout bounded while forwarding all output", async () => { const script = "process.stdout.write('abcde'); process.stdout.write('fghij');"; const { stream, chunks } = collectSink(); const r = await spawnStreamingChild(process.execPath, ["-e", script], { stdoutSink: stream, stderrSink: stream, maxCaptureChars: 6, }); assert.equal(r.status, 0); assert.equal(r.stdout, "efghij"); assert.equal(chunks.join(""), "abcdefghij"); }); test("spawnStreamingChild terminates an opted-in process group when aborted", async () => { const controller = new AbortController(); const { stream } = collectSink(); const result = spawnStreamingChild(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdoutSink: stream, stderrSink: stream, signal: controller.signal, processGroup: true, }); setTimeout(() => controller.abort(), 30); const child = await result; assert.equal(child.signal, "SIGTERM"); }); test("spawnStreamingChild escalates ignored SIGTERM to SIGKILL after the abort grace period", async () => { const controller = new AbortController(); const { stream, chunks } = collectSink(); // Register the ignore handler first, then stay alive until SIGKILL. const script = [ "process.on('SIGTERM', () => { process.stdout.write('ignored\\n'); });", "process.stdout.write('ready\\n');", "setInterval(() => {}, 1000);", ].join(""); const result = spawnStreamingChild(process.execPath, ["-e", script], { stdoutSink: stream, stderrSink: stream, signal: controller.signal, processGroup: false, abortKillGraceMs: 50, }); // Wait until the child has actually registered its SIGTERM handler and // signalled readiness, rather than a fixed delay — under parallel load the // child can take longer to start, and aborting before the handler is // installed would let SIGTERM kill it (observed as SIGTERM instead of SIGKILL). await waitForOutput(chunks, /ready/); controller.abort(); const child = await result; assert.equal(child.signal, "SIGKILL"); assert.match(chunks.join("") + child.stdout, /ready/); }); // --------------------------------------------------------------------------- // logFilePath tee (real-time observability) // --------------------------------------------------------------------------- test("logFilePath: stdout is readable from the log file before the child exits", async () => { const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-tee-test-"); try { const logFile = path.join(tmpDir, "exec.log"); const { stream } = collectSink(); const script = [ "process.stdout.write('pre-delay\\n');", "setTimeout(() => { process.stdout.write('post-delay\\n'); }, 200);", ].join(""); const childPromise = spawnStreamingChild(process.execPath, ["-e", script], { stdoutSink: stream, stderrSink: stream, logFilePath: logFile, }); // Before the child finishes, the log file should already have 'pre-delay' await new Promise((resolve, reject) => { const check = () => { try { if (fs.existsSync(logFile) && fs.readFileSync(logFile, "utf8").includes("pre-delay")) return resolve(); setTimeout(check, 10); } catch { setTimeout(check, 10); } }; setTimeout(() => reject(new Error("timed out waiting for pre-delay in log")), 2000); check(); }); await childPromise; // After completion, the log file contains all output const fullLog = fs.readFileSync(logFile, "utf8"); assert.match(fullLog, /pre-delay/); assert.match(fullLog, /post-delay/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); test("logFilePath: stderr tee is also captured in the log file", async () => { const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-tee-test-"); try { const logFile = path.join(tmpDir, "exec.log"); const { stream } = collectSink(); const r = await spawnStreamingChild( process.execPath, ["-e", "process.stderr.write('stderr-msg\\n'); process.stdout.write('stdout-msg\\n');"], { stdoutSink: stream, stderrSink: stream, logFilePath: logFile }, ); assert.equal(r.status, 0); const logContent = fs.readFileSync(logFile, "utf8"); assert.match(logContent, /stderr-msg/); assert.match(logContent, /stdout-msg/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); test("logFilePath: Chinese UTF-8 is preserved correctly", async () => { const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-utf8-test-"); try { const logFile = path.join(tmpDir, "exec.log"); const { stream } = collectSink(); const r = await spawnStreamingChild( process.execPath, ["-e", "console.log('你好世界'); console.log('日本語'); console.log('🚀');"], { stdoutSink: stream, stderrSink: stream, logFilePath: logFile }, ); assert.equal(r.status, 0); const logContent = fs.readFileSync(logFile, "utf8"); assert.match(logContent, /你好世界/); assert.match(logContent, /日本語/); assert.match(logContent, /🚀/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); test("logFilePath: onProgress reports pid and bytesLogged", async () => { const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-progress-test-"); try { const logFile = path.join(tmpDir, "exec.log"); const { stream } = collectSink(); const progressUpdates: Array<{ pid?: number; bytesLogged: number }> = []; const r = await spawnStreamingChild( process.execPath, ["-e", "process.stdout.write('hello world');"], { stdoutSink: stream, stderrSink: stream, logFilePath: logFile, onProgress: (info) => { progressUpdates.push(info); }, }, ); assert.equal(r.status, 0); assert.ok(progressUpdates.length >= 1); assert.ok(progressUpdates.some((p) => p.bytesLogged > 0)); if (progressUpdates[0].pid) assert.ok(progressUpdates[0].pid! > 0); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); test("logFilePath: write stream error resolves spawnStreamingChild with result.error", async () => { const tmpDir = fs.mkdtempSync("/tmp/agent-workflow-log-err-test-"); try { // Parent does not exist → ENOENT on first write const badLogFile = path.join(tmpDir, "nonexistent", "exec.log"); const { stream } = collectSink(); const r = await spawnStreamingChild( process.execPath, ["-e", "console.log('hello');"], { stdoutSink: stream, stderrSink: stream, logFilePath: badLogFile, }, ); assert.equal(r.status, 0); assert.ok(r.error !== undefined, "expected result.error from log stream failure"); assert.match(String(r.error), /ENOENT|no such file|open/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } });