Compare commits

...

2 Commits

8 changed files with 144 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
@ -50,6 +54,17 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `$SHOUT_PROJECT_DIR` is set to `cwd` where `shout` was invoked
- stdout and stderr are merged (`exec 2>&1`)
## New feature checklist
1. `src/parse.ts` — update types (`Directive`, `ShoutFile`, `Command`) and both parsers (`parse` + `parseSetup`)
2. `src/parse.test.ts` — unit tests for parsing the new syntax in both `.shout` and setup file contexts
3. `src/cli/index.ts` — wire up the parsed result in `runOne` (directive resolution, command merging, result handling)
4. `test/*.shout` — integration test file exercising the feature end-to-end
5. `CLAUDE.md` — update `.shout file format` section
6. `README.md` — update Directives section
7. `web/index.html` — add or update a section on the website
8. Run `bun test` and `bun run src/cli/index.ts test test/` to verify
## Style
- Strict TypeScript, Bun runtime

View File

@ -80,6 +80,25 @@ Prepend commands (and `@env` directives) from another `.shout` file:
Setup commands run first and their failures abort the test. Setup files cannot themselves contain `@setup` — no nesting. If both the setup file and the user file define the same `@env`, the user file wins.
### `@teardown`
Run a cleanup command after all test commands, regardless of pass/fail:
```
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db"
$ create-db && run-tests
...
```
Teardown failures produce warnings but don't affect test results. You can also put `@teardown` in setup files:
```
# setup.shout
export DB_URL=sqlite:data/test.db
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db"
```
```
Usage: shout test [options] [files...]

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

View File

@ -204,6 +204,20 @@
<span class="output">OK</span></code></pre>
</section>
<section>
<h2>Setup &amp; teardown</h2>
<p>Use <code class="bright">@setup</code> to share commands across test files. Use <code class="bright">@teardown</code> to clean up after tests &mdash; it runs regardless of pass/fail.</p>
<pre><code><span class="comment"># setup.shout</span>
<span class="output">export DB_URL=sqlite:data/test.db</span>
<span class="bright">@teardown</span> <span class="cmd">rm -f "$SHOUT_PROJECT_DIR/data/test.db"</span></code></pre>
<pre><code><span class="bright">@setup</span> <span class="cmd">setup.shout</span>
<span class="bright">@teardown</span> <span class="cmd">rm -f /tmp/extra-cleanup</span>
<span class="prompt">$</span> <span class="cmd">create-db &amp;&amp; run-tests</span>
<span class="wildcard">...</span></code></pre>
<p><code>@teardown</code> can appear in both <code>.shout</code> files and setup files. Teardown failures produce warnings but don't affect test results.</p>
</section>
<section>
<h2>Run it</h2>
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span>