Merge branch 'add-first-proposal'

This commit is contained in:
Chris Wanstrath 2026-03-10 10:19:43 -07:00
commit 490414bd4a
3 changed files with 98 additions and 10 deletions

View File

@ -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)

82
src/run.test.ts Normal file
View File

@ -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<boolean> {
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)
}
})
})

View File

@ -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<typeof setTimeout>
const timeout = new Promise<never>((_, 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()
}