Compare commits

...

7 Commits

10 changed files with 167 additions and 139 deletions

View File

@ -6,7 +6,7 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `bun test` — run unit tests
- `bunx tsc --noEmit` — type check
- `bun run src/cli/index.ts test [files...]` — run shout CLI
- `bun run src/cli/index.ts [files...]` — run shout CLI (`--port-from <n>` auto-assigns `$PORT`)
## Architecture
@ -28,6 +28,8 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `[N]` on last line of expected output = assert exit code N
- `[*]` = assert any non-zero exit code; default expects 0
- `#` after a command = comment (stripped); `#` in expected output is literal
- `@env KEY=VALUE` before first command = set environment variable
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file
- Each file runs in a fresh temp dir with a single `/bin/sh` session
## Style

127
SHOUT.md
View File

@ -1,127 +0,0 @@
# Shout — Proposed Improvements
Two additions to the shout test framework: automatic process cleanup and test isolation primitives.
## 1. Automatic Process Cleanup
### Problem
Any test that backgrounds a process (`&`) must manually clean it up. If a test fails or times out before reaching its cleanup command, the process is orphaned and holds its port indefinitely. Every test author has to remember the `trap` pattern:
```
$ my-server &; SVR=$!; trap "kill $SVR 2>/dev/null" EXIT; ...
```
This is error-prone and noisy.
### Proposal
Shout already owns the shell process (via `Bun.spawn`). After the shell exits, shout should kill the entire process group to reap any lingering children.
In `run.ts`, after the shell completes:
```ts
// Kill the shell's process group to clean up backgrounded children
try {
process.kill(-proc.pid, "SIGTERM")
} catch {}
```
The `-pid` syntax sends the signal to the entire process group. Since shout spawns the shell, the shell and all its children share a process group.
This requires no syntax changes, no test file modifications, and no action from test authors. Background a process, forget about it — shout cleans up.
For defense in depth, follow up with SIGKILL after a short grace period:
```ts
try {
process.kill(-proc.pid, "SIGTERM")
} catch {}
setTimeout(() => {
try { process.kill(-proc.pid, "SIGKILL") } catch {}
}, 500)
```
### Migration
Remove the manual `kill` / `trap` lines from existing test files. They become no-ops but add visual noise.
## 2. Test Isolation Primitives
### Problem
Every test file that needs a server repeats the same boilerplate:
```
$ PORT=19001 ... dev-server > /dev/null 2>&1 & SVR=$!; trap "kill $SVR 2>/dev/null" EXIT; i=0; while ! curl -sf http://localhost:19001/...; do ...; done; echo "ok"
ok
$ mkdir -p .config/dev && echo '{"server":"http://localhost:19001",...}' > .config/dev/config.json
```
Each test picks a hardcoded port. Adding a new test means manually checking which ports are taken. Parallel test runs risk port collisions.
### Proposal: `# setup` directive
A new directive that includes commands from a shared file before the test's own commands:
```
# setup tests/setup.shout
```
The setup file is a normal `.shout` file. Its commands are prepended to the test's script (same shell, same working directory, same environment). This is purely textual inclusion — no new execution model.
Example `tests/setup.shout`:
```
$ dev-server > /dev/null 2>&1 &
$ mkdir -p .config/dev && echo "{\"server\":\"http://localhost:$PORT\",\"token\":\"dev-token-1\"}" > .config/dev/config.json
$ i=0; while ! curl -sf http://localhost:$PORT/api/whoami -H "Authorization: Bearer dev-token-1" > /dev/null 2>&1; do i=$((i+1)); if [ $i -gt 30 ]; then echo "server failed"; exit 1; fi; sleep 0.2; done; echo "ok"
ok
```
Then a test file becomes:
```
# Phase 1 — Linear Timeline
# setup tests/setup.shout
$ dev init myapp
initialized repo myapp in ./myapp
$ cd myapp
...
```
### Proposal: `--port-from <N>` flag
A CLI flag that auto-assigns ports to test files:
```sh
shout --port-from 19000 tests/
```
Shout sets `$PORT` in each test file's environment, incrementing from the base. When `--parallel` is used, each file gets a unique port with no coordination needed.
Implementation in the runner:
```ts
let nextPort = options.portFrom
for (const file of files) {
const env = { ...baseEnv, PORT: String(nextPort++) }
await runFile(file, { ...options, env })
}
```
Test files reference `$PORT` instead of hardcoded values. Combined with `# setup`, the per-file boilerplate drops to one line.
### Proposal: `# env` directive
For cases simpler than `# setup` — setting environment variables without a separate file:
```
# env PORT=19001
# env NODE_ENV=production
```
These are injected into the shell environment before any commands run. Lighter than a setup file when all you need is a few variables.

View File

@ -1,11 +1,11 @@
#!/usr/bin/env bun
import { readdir, readFile, writeFile } from "node:fs/promises"
import { resolve, relative } from "node:path"
import { resolve, relative, dirname } from "node:path"
import { program } from "commander"
import ansis from "ansis"
import { parse } from "../parse.ts"
import { parse, type Command, type ShoutFile } from "../parse.ts"
import { runFile, cleanupTmpDir } from "../run.ts"
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
import type { TestResult } from "../format.ts"
@ -60,6 +60,7 @@ program
.option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val])
.option("--timeout <dur>", "Per-command timeout", "10s")
.option("-v, --verbose", "Print each command as it runs")
.option("--port-from <n>", "Auto-assign $PORT starting from <n>")
.option("--parallel", "Run files in parallel")
.action(async (fileArgs: string[], opts) => {
const timeoutMs = parseDuration(opts.timeout)
@ -74,14 +75,49 @@ program
const start = performance.now()
const results: TestResult[] = []
const cwd = process.cwd()
const portFrom = opts.portFrom ? parseInt(opts.portFrom, 10) : undefined
if (portFrom !== undefined && Number.isNaN(portFrom)) {
console.error("--port-from must be an integer")
process.exit(1)
}
let nextPort = portFrom
const runOne = async (filePath: string) => {
const runOne = async (filePath: string, port: number | undefined) => {
const content = await readFile(filePath, "utf-8")
const parsed = parse(relative(cwd, filePath), content)
const fileResult = await runFile(parsed, {
// Resolve directives in a single pass. Setup @env is collected separately
// so that the user file's @env always takes precedence.
const envVars: Record<string, string> = {}
const setupEnvVars: Record<string, string> = {}
const userEnvVars: Record<string, string> = {}
const setupCommands: Command[] = []
for (const d of parsed.directives) {
if (d.type === "setup") {
const setupPath = resolve(dirname(filePath), d.path)
const setupContent = await readFile(setupPath, "utf-8")
const setupParsed = parse(relative(cwd, setupPath), setupContent)
for (const sd of setupParsed.directives) {
if (sd.type === "setup") {
throw new Error(`${relative(cwd, setupPath)}: @setup not allowed in setup files`)
}
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
}
setupCommands.push(...setupParsed.commands)
} else if (d.type === "env") {
userEnvVars[d.key] = d.value
}
}
Object.assign(envVars, setupEnvVars, userEnvVars)
if (port !== undefined && !("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
envVars["PORT"] = String(port)
}
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
const fileResult = await runFile(merged, {
cleanEnv: opts.cleanEnv ?? false,
pathDirs: opts.path,
envVars,
timeout: timeoutMs,
verbose: opts.verbose ?? false,
onCommand: opts.verbose
@ -89,14 +125,39 @@ program
: undefined,
})
// Check setup commands for failures
for (let i = 0; i < setupCommands.length; i++) {
const r = fileResult.results[i]
const expected = setupCommands[i]!.exitCode
const ok = expected === null
? r?.exitCode === 0
: expected === "*"
? r?.exitCode !== 0
: r?.exitCode === expected
if (!ok) {
if (opts.keep) {
process.stderr.write(`${fileResult.tmpDir}\n`)
} else {
await cleanupTmpDir(fileResult.tmpDir)
}
return evaluateFile(
parsed.path,
[],
`setup command failed (exit ${r?.exitCode ?? "?"}): $ ${setupCommands[i]!.command}`,
)
}
}
const fileOwnResults = fileResult.results.slice(setupCommands.length)
const testResult = evaluateFile(
parsed.path,
fileResult.results,
fileOwnResults,
fileResult.error,
)
if (opts.update && fileResult.results.length > 0) {
const updated = rewriteFile(parsed, fileResult.results, content)
if (opts.update && fileOwnResults.length > 0) {
const updated = rewriteFile(parsed, fileOwnResults, content)
if (updated !== content) {
await writeFile(filePath, updated)
}
@ -126,7 +187,7 @@ program
}
if (opts.parallel) {
const all = await Promise.all(files.map(runOne))
const all = await Promise.all(files.map(f => runOne(f, nextPort !== undefined ? nextPort++ : undefined)))
for (const r of all) {
printDots(r)
results.push(r)
@ -134,7 +195,7 @@ program
process.stdout.write("\n")
} else {
for (const filePath of files) {
const r = await runOne(filePath)
const r = await runOne(filePath, nextPort !== undefined ? nextPort++ : undefined)
printDots(r)
results.push(r)
}

View File

@ -1,4 +1,4 @@
export type { Command, ShoutFile } from "./parse.ts"
export type { Command, Directive, ShoutFile } from "./parse.ts"
export type { CommandResult, FileResult } from "./run.ts"
export type { DiffLine } from "./match.ts"
export type { TestResult } from "./format.ts"

View File

@ -66,4 +66,53 @@ describe("parse", () => {
const b = parse("test.shout", "$ echo hi\nhi")
expect(a.commands[0]!.expected).toEqual(b.commands[0]!.expected)
})
test("@env directive", () => {
const result = parse("test.shout", "@env PORT=3000\n$ echo $PORT\n3000\n")
expect(result.directives).toEqual([
{ type: "env", key: "PORT", value: "3000", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("@env with value containing =", () => {
const result = parse("test.shout", "@env FOO=bar=baz\n$ echo $FOO\n")
expect(result.directives[0]).toEqual(
{ type: "env", key: "FOO", value: "bar=baz", line: 1 },
)
})
test("@setup directive", () => {
const result = parse("test.shout", "@setup shared/setup.shout\n$ echo hi\nhi\n")
expect(result.directives).toEqual([
{ type: "setup", path: "shared/setup.shout", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("multiple directives", () => {
const content = "@setup setup.shout\n@env PORT=3000\n@env NODE_ENV=test\n\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
expect(result.directives).toHaveLength(3)
expect(result.directives[0]!.type).toBe("setup")
expect(result.directives[1]).toEqual({ type: "env", key: "PORT", value: "3000", line: 2 })
expect(result.directives[2]).toEqual({ type: "env", key: "NODE_ENV", value: "test", line: 3 })
})
test("@ lines after first command are expected output", () => {
const result = parse("test.shout", "$ cat config\n@env PORT=3000\n")
expect(result.directives).toEqual([])
expect(result.commands[0]!.expected).toEqual(["@env PORT=3000"])
})
test("no directives returns empty array", () => {
const result = parse("test.shout", "$ echo hi\nhi\n")
expect(result.directives).toEqual([])
})
test("unknown directive throws", () => {
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
"test.shout:1: unknown directive: @evn PORT=3000",
)
})
})

View File

@ -6,9 +6,14 @@ export type Command = {
exitCode: number | "*" | null
}
export type Directive =
| { type: "setup"; path: string; line: number }
| { type: "env"; key: string; value: string; line: number }
export type ShoutFile = {
path: string
commands: Command[]
directives: Directive[]
}
function stripComment(line: string): string {
@ -58,12 +63,35 @@ export function parse(path: string, content: string): ShoutFile {
}
const commands: Command[] = []
const directives: Directive[] = []
let current: Command | null = null
let seenCommand = false
for (let i = 0; i < rawLines.length; i++) {
const line = rawLines[i]!
if (!seenCommand && line.startsWith("@")) {
if (line.startsWith("@setup ")) {
const setupPath = line.slice(7).trim()
if (!setupPath) {
throw new Error(`${path}:${i + 1}: @setup requires a file path`)
}
directives.push({ type: "setup", path: setupPath, line: i + 1 })
} else if (line.startsWith("@env ")) {
const rest = line.slice(5).trim()
const eq = rest.indexOf("=")
if (eq <= 0) {
throw new Error(`${path}:${i + 1}: malformed @env directive (expected KEY=VALUE): ${line}`)
}
directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 })
} else {
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
}
continue
}
if (line.startsWith("$ ")) {
seenCommand = true
if (current) {
const trimmed = trimTrailingEmpty(current.expected)
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
@ -92,5 +120,5 @@ export function parse(path: string, content: string): ShoutFile {
commands.push(current)
}
return { path, commands }
return { path, commands, directives }
}

View File

@ -20,6 +20,7 @@ export type FileResult = {
type RunOptions = {
cleanEnv: boolean
pathDirs?: string[]
envVars?: Record<string, string>
timeout: number
verbose: boolean
onCommand?: (cmd: Command) => void
@ -128,6 +129,10 @@ export async function runFile(
env["HOME"] = tmpDir
env["SHOUT_DIR"] = tmpDir
if (options.envVars) {
Object.assign(env, options.envVars)
}
if (options.pathDirs?.length) {
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
}

5
test/env.shout Normal file
View File

@ -0,0 +1,5 @@
@env GREETING=hello
@env TARGET=world
$ echo "$GREETING $TARGET"
hello world

1
test/setup-shared.shout Normal file
View File

@ -0,0 +1 @@
$ export READY=yes

4
test/setup-user.shout Normal file
View File

@ -0,0 +1,4 @@
@setup setup-shared.shout
$ echo $READY
yes