diff --git a/CLAUDE.md b/CLAUDE.md index dd71324..d234de1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Transcript-based shell integration test runner. Bun + TypeScript. ## Architecture - `src/parse.ts` — parses `.shout` files into `ShoutFile` (list of `Command`) -- `src/run.ts` — executes commands via `Bun.spawn(["/bin/sh"])`, captures output with sentinels +- `src/run.ts` — executes commands via `Bun.spawn(["setsid", "/bin/sh"])`, captures output with sentinels - `src/match.ts` — wildcard-aware output matching and diff generation - `src/format.ts` — evaluates pass/fail, formats failures and summary - `src/update.ts` — rewrites `.shout` files with actual output (`--update` mode) diff --git a/src/run.test.ts b/src/run.test.ts new file mode 100644 index 0000000..e2ab64f --- /dev/null +++ b/src/run.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" + +import { runFile, cleanupTmpDir } from "./run.ts" +import type { ShoutFile } from "./parse.ts" + +function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile { + return { + path: "test.shout", + commands: commands.map((c, i) => ({ + line: i + 1, + raw: `$ ${c.command}`, + command: c.command, + expected: c.expected ?? [], + exitCode: null, + })), + } +} + +const defaultOpts = { + cleanEnv: false, + timeout: 5000, + verbose: false, +} + +async function isProcessRunning(pid: number): Promise { + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf-8") + // Find state after the comm field (which is wrapped in parens and may contain spaces) + const state = stat.charAt(stat.lastIndexOf(")") + 2) + return state !== "Z" && state !== "X" + } catch { + return false + } +} + +describe("runFile", () => { + test("basic command", async () => { + const file = makeFile([{ command: "echo hello" }]) + const result = await runFile(file, defaultOpts) + try { + expect(result.results[0]?.actual).toEqual(["hello"]) + expect(result.results[0]?.exitCode).toBe(0) + } finally { + await cleanupTmpDir(result.tmpDir) + } + }) + + test("cleans up backgrounded processes after exit", async () => { + const file = makeFile([ + { command: "sleep 300 >/dev/null 2>&1 & echo $!" }, + ]) + const result = await runFile(file, defaultOpts) + try { + const pid = parseInt(result.results[0]?.actual[0] ?? "", 10) + expect(pid).toBeGreaterThan(0) + await new Promise(r => setTimeout(r, 100)) + expect(await isProcessRunning(pid)).toBe(false) + } finally { + await cleanupTmpDir(result.tmpDir) + } + }) + + test("cleans up multiple backgrounded processes", async () => { + const file = makeFile([ + { command: "sleep 300 >/dev/null 2>&1 & P1=$!; sleep 300 >/dev/null 2>&1 & P2=$!; echo $P1 $P2" }, + ]) + const result = await runFile(file, defaultOpts) + try { + const pids = (result.results[0]?.actual[0] ?? "").split(" ").map(Number) + expect(pids).toHaveLength(2) + expect(pids[0]).toBeGreaterThan(0) + expect(pids[1]).toBeGreaterThan(0) + await new Promise(r => setTimeout(r, 100)) + for (const pid of pids) { + expect(await isProcessRunning(pid)).toBe(false) + } + } finally { + await cleanupTmpDir(result.tmpDir) + } + }) +}) diff --git a/src/run.ts b/src/run.ts index 589cf90..b26fd38 100644 --- a/src/run.ts +++ b/src/run.ts @@ -132,15 +132,15 @@ export async function runFile( env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "") } - try { - const proc = Bun.spawn(["/bin/sh"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - cwd: tmpDir, - env, - }) + const proc = Bun.spawn(["setsid", "/bin/sh"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + cwd: tmpDir, + env, + }) + try { proc.stdin.write(script) proc.stdin.end() @@ -174,6 +174,10 @@ export async function runFile( tmpDir, error: err instanceof Error ? err.message : String(err), } + } finally { + if (proc.pid) { + try { process.kill(-proc.pid, "SIGKILL") } catch {} + } } } @@ -184,8 +188,9 @@ async function readWithTimeout( const reader = stream.getReader() const chunks: Uint8Array[] = [] + let timerId: ReturnType const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs), + timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs), ) try { @@ -195,6 +200,7 @@ async function readWithTimeout( if (value) chunks.push(value) } } finally { + clearTimeout(timerId!) reader.releaseLock() }