diff --git a/CLAUDE.md b/CLAUDE.md index c82e712..97ecc96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 7351c5a..8f03f05 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -111,6 +111,7 @@ program const setupEnvVars: Record = {} const userEnvVars: Record = {} 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, diff --git a/src/parse.test.ts b/src/parse.test.ts index 99f2bc3..f4941f8 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -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([]) diff --git a/src/parse.ts b/src/parse.ts index 8cd7d1e..e25ae74 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -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 } } diff --git a/test/teardown-setup.shout b/test/teardown-setup.shout new file mode 100644 index 0000000..a2b0c42 --- /dev/null +++ b/test/teardown-setup.shout @@ -0,0 +1,2 @@ +export READY=yes +@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.cleanup.db" diff --git a/test/teardown.shout b/test/teardown.shout new file mode 100644 index 0000000..6cda4c4 --- /dev/null +++ b/test/teardown.shout @@ -0,0 +1,4 @@ +@setup teardown-setup.shout + +$ touch marker.txt && ls marker.txt +marker.txt