Add @teardown directive support

This commit is contained in:
Chris Wanstrath 2026-03-12 20:48:51 -07:00
parent 191317ae22
commit e8bc4be382
6 changed files with 100 additions and 5 deletions

View File

@ -39,9 +39,13 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `$#` comment line = not executed, no output expected (e.g. `$# start the server`)
- `#` after a command = comment (stripped); `#` in expected output is literal
- `@env KEY=VALUE` before first command = set environment variable
- `@teardown <command>` before first command = run command after all test commands
- Runs regardless of pass/fail
- Teardown failures produce warnings but don't affect test results
- Can appear in both `.shout` files and setup files
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file
- Setup files use a plain format: each line is a command (no `$ ` prefix), `#` lines are comments, blank lines ignored
- Setup files can contain `@env` directives but not `@setup` (no nesting)
- Setup files can contain `@env` and `@teardown` directives but not `@setup` (no nesting)
- User file `@env` overrides setup file `@env`
- Setup command failures abort the test with an error
- Each file runs in a fresh temp dir with a single `/bin/sh` session

View File

@ -111,6 +111,7 @@ program
const setupEnvVars: Record<string, string> = {}
const userEnvVars: Record<string, string> = {}
const setupCommands: Command[] = []
const teardownCommands: Command[] = [...parsed.teardownCommands]
for (const d of parsed.directives) {
if (d.type === "setup") {
const setupPath = resolve(dirname(filePath), d.path)
@ -120,6 +121,7 @@ program
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
}
setupCommands.push(...setupParsed.commands)
teardownCommands.push(...setupParsed.teardownCommands)
} else if (d.type === "env") {
userEnvVars[d.key] = d.value
}
@ -128,7 +130,7 @@ program
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
envVars["PORT"] = String(port)
}
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] }
const fileResult = await runFile(merged, {
cleanEnv: opts.cleanEnv ?? false,
@ -166,7 +168,21 @@ program
}
}
const fileOwnResults = fileResult.results.slice(setupCommands.length)
const fileOwnResults = fileResult.results.slice(
setupCommands.length,
setupCommands.length + parsed.commands.length,
)
// Warn on teardown failures
const teardownResults = fileResult.results.slice(setupCommands.length + parsed.commands.length)
for (let i = 0; i < teardownResults.length; i++) {
const r = teardownResults[i]
if (r && r.exitCode !== 0) {
process.stderr.write(
ansis.yellow(`warning: teardown command failed (exit ${r.exitCode}): $ ${teardownCommands[i]!.command}\n`),
)
}
}
const testResult = evaluateFile(
parsed.path,

View File

@ -164,6 +164,22 @@ describe("parse", () => {
expect(result.directives).toEqual([])
})
test("@teardown in .shout file", () => {
const result = parse("test.shout", "@teardown rm -f /tmp/test.db\n$ echo hi\nhi\n")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
expect(result.commands).toHaveLength(1)
})
test("@teardown with @setup in .shout file", () => {
const content = "@setup setup.shout\n@teardown rm -f /tmp/test.db\n@env PORT=3000\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
expect(result.directives).toHaveLength(2)
expect(result.directives[0]!.type).toBe("setup")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("unknown directive throws", () => {
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
"test.shout:1: unknown directive: @evn PORT=3000",
@ -205,6 +221,32 @@ describe("parseSetup", () => {
)
})
test("@teardown commands in setup file", () => {
const result = parseSetup("setup.shout", "export FOO=bar\n@teardown rm -f /tmp/test.db\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("@teardown strips trailing comment", () => {
const result = parseSetup("setup.shout", "@teardown rm -f /tmp/test.db # cleanup\n")
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("@teardown with empty command throws", () => {
expect(() => parseSetup("setup.shout", "@teardown \n")).toThrow(
"setup.shout:1: @teardown requires a command",
)
})
test("multiple @teardown commands", () => {
const result = parseSetup("setup.shout", "@teardown rm -f a.db\n@teardown rm -f b.db\n")
expect(result.teardownCommands).toHaveLength(2)
expect(result.teardownCommands[0]!.command).toBe("rm -f a.db")
expect(result.teardownCommands[1]!.command).toBe("rm -f b.db")
})
test("commands have no expected output", () => {
const result = parseSetup("setup.shout", "echo hello\n")
expect(result.commands[0]!.expected).toEqual([])

View File

@ -14,6 +14,7 @@ export type ShoutFile = {
path: string
commands: Command[]
directives: Directive[]
teardownCommands: Command[]
}
function stripComment(line: string): string {
@ -76,6 +77,7 @@ export function parseSetup(path: string, content: string): ShoutFile {
}
const commands: Command[] = []
const teardownCommands: Command[] = []
const directives: Directive[] = []
for (let i = 0; i < rawLines.length; i++) {
@ -87,6 +89,18 @@ export function parseSetup(path: string, content: string): ShoutFile {
if (line.startsWith("@env ")) {
const { key, value } = parseEnvDirective(path, line, i + 1)
directives.push({ type: "env", key, value, line: i + 1 })
} else if (line.startsWith("@teardown ")) {
const cmd = line.slice(10)
if (!cmd.trim()) {
throw new Error(`${path}:${i + 1}: @teardown requires a command`)
}
teardownCommands.push({
line: i + 1,
raw: line,
command: stripComment(cmd),
expected: [],
exitCode: null,
})
} else if (line.startsWith("@setup ")) {
throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`)
} else {
@ -104,7 +118,7 @@ export function parseSetup(path: string, content: string): ShoutFile {
})
}
return { path, commands, directives }
return { path, commands, directives, teardownCommands }
}
export function parse(path: string, content: string): ShoutFile {
@ -116,6 +130,7 @@ export function parse(path: string, content: string): ShoutFile {
}
const commands: Command[] = []
const teardownCommands: Command[] = []
const directives: Directive[] = []
let current: Command | null = null
let seenCommand = false
@ -130,6 +145,18 @@ export function parse(path: string, content: string): ShoutFile {
throw new Error(`${path}:${i + 1}: @setup requires a file path`)
}
directives.push({ type: "setup", path: setupPath, line: i + 1 })
} else if (line.startsWith("@teardown ")) {
const cmd = line.slice(10)
if (!cmd.trim()) {
throw new Error(`${path}:${i + 1}: @teardown requires a command`)
}
teardownCommands.push({
line: i + 1,
raw: line,
command: stripComment(cmd),
expected: [],
exitCode: null,
})
} else if (line.startsWith("@env ")) {
const { key, value } = parseEnvDirective(path, line, i + 1)
directives.push({ type: "env", key, value, line: i + 1 })
@ -183,5 +210,5 @@ export function parse(path: string, content: string): ShoutFile {
commands.push(current)
}
return { path, commands, directives }
return { path, commands, directives, teardownCommands }
}

View File

@ -0,0 +1,2 @@
export READY=yes
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.cleanup.db"

4
test/teardown.shout Normal file
View File

@ -0,0 +1,4 @@
@setup teardown-setup.shout
$ touch marker.txt && ls marker.txt
marker.txt