Compare commits
No commits in common. "3d9cbfba1bfe82fa2e0f5cd634590fa5c0ad0343" and "191317ae2256a4d3c24a90e028595672cb884ab8" have entirely different histories.
3d9cbfba1b
...
191317ae22
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -39,13 +39,9 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
||||||
- `$#` comment line = not executed, no output expected (e.g. `$# start the server`)
|
- `$#` comment line = not executed, no output expected (e.g. `$# start the server`)
|
||||||
- `#` after a command = comment (stripped); `#` in expected output is literal
|
- `#` after a command = comment (stripped); `#` in expected output is literal
|
||||||
- `@env KEY=VALUE` before first command = set environment variable
|
- `@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 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 use a plain format: each line is a command (no `$ ` prefix), `#` lines are comments, blank lines ignored
|
||||||
- Setup files can contain `@env` and `@teardown` directives but not `@setup` (no nesting)
|
- Setup files can contain `@env` directives but not `@setup` (no nesting)
|
||||||
- User file `@env` overrides setup file `@env`
|
- User file `@env` overrides setup file `@env`
|
||||||
- Setup command failures abort the test with an error
|
- Setup command failures abort the test with an error
|
||||||
- Each file runs in a fresh temp dir with a single `/bin/sh` session
|
- Each file runs in a fresh temp dir with a single `/bin/sh` session
|
||||||
|
|
@ -54,17 +50,6 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
||||||
- `$SHOUT_PROJECT_DIR` is set to `cwd` where `shout` was invoked
|
- `$SHOUT_PROJECT_DIR` is set to `cwd` where `shout` was invoked
|
||||||
- stdout and stderr are merged (`exec 2>&1`)
|
- 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
|
## Style
|
||||||
|
|
||||||
- Strict TypeScript, Bun runtime
|
- Strict TypeScript, Bun runtime
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -80,25 +80,6 @@ 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.
|
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...]
|
Usage: shout test [options] [files...]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,6 @@ program
|
||||||
const setupEnvVars: Record<string, string> = {}
|
const setupEnvVars: Record<string, string> = {}
|
||||||
const userEnvVars: Record<string, string> = {}
|
const userEnvVars: Record<string, string> = {}
|
||||||
const setupCommands: Command[] = []
|
const setupCommands: Command[] = []
|
||||||
const teardownCommands: Command[] = [...parsed.teardownCommands]
|
|
||||||
for (const d of parsed.directives) {
|
for (const d of parsed.directives) {
|
||||||
if (d.type === "setup") {
|
if (d.type === "setup") {
|
||||||
const setupPath = resolve(dirname(filePath), d.path)
|
const setupPath = resolve(dirname(filePath), d.path)
|
||||||
|
|
@ -121,7 +120,6 @@ program
|
||||||
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
||||||
}
|
}
|
||||||
setupCommands.push(...setupParsed.commands)
|
setupCommands.push(...setupParsed.commands)
|
||||||
teardownCommands.push(...setupParsed.teardownCommands)
|
|
||||||
} else if (d.type === "env") {
|
} else if (d.type === "env") {
|
||||||
userEnvVars[d.key] = d.value
|
userEnvVars[d.key] = d.value
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +128,7 @@ program
|
||||||
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
|
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
|
||||||
envVars["PORT"] = String(port)
|
envVars["PORT"] = String(port)
|
||||||
}
|
}
|
||||||
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] }
|
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
|
||||||
|
|
||||||
const fileResult = await runFile(merged, {
|
const fileResult = await runFile(merged, {
|
||||||
cleanEnv: opts.cleanEnv ?? false,
|
cleanEnv: opts.cleanEnv ?? false,
|
||||||
|
|
@ -168,21 +166,7 @@ program
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileOwnResults = fileResult.results.slice(
|
const fileOwnResults = fileResult.results.slice(setupCommands.length)
|
||||||
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(
|
const testResult = evaluateFile(
|
||||||
parsed.path,
|
parsed.path,
|
||||||
|
|
|
||||||
|
|
@ -164,22 +164,6 @@ describe("parse", () => {
|
||||||
expect(result.directives).toEqual([])
|
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", () => {
|
test("unknown directive throws", () => {
|
||||||
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
|
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
|
||||||
"test.shout:1: unknown directive: @evn PORT=3000",
|
"test.shout:1: unknown directive: @evn PORT=3000",
|
||||||
|
|
@ -221,32 +205,6 @@ 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", () => {
|
test("commands have no expected output", () => {
|
||||||
const result = parseSetup("setup.shout", "echo hello\n")
|
const result = parseSetup("setup.shout", "echo hello\n")
|
||||||
expect(result.commands[0]!.expected).toEqual([])
|
expect(result.commands[0]!.expected).toEqual([])
|
||||||
|
|
|
||||||
31
src/parse.ts
31
src/parse.ts
|
|
@ -14,7 +14,6 @@ export type ShoutFile = {
|
||||||
path: string
|
path: string
|
||||||
commands: Command[]
|
commands: Command[]
|
||||||
directives: Directive[]
|
directives: Directive[]
|
||||||
teardownCommands: Command[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripComment(line: string): string {
|
function stripComment(line: string): string {
|
||||||
|
|
@ -77,7 +76,6 @@ export function parseSetup(path: string, content: string): ShoutFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands: Command[] = []
|
const commands: Command[] = []
|
||||||
const teardownCommands: Command[] = []
|
|
||||||
const directives: Directive[] = []
|
const directives: Directive[] = []
|
||||||
|
|
||||||
for (let i = 0; i < rawLines.length; i++) {
|
for (let i = 0; i < rawLines.length; i++) {
|
||||||
|
|
@ -89,18 +87,6 @@ export function parseSetup(path: string, content: string): ShoutFile {
|
||||||
if (line.startsWith("@env ")) {
|
if (line.startsWith("@env ")) {
|
||||||
const { key, value } = parseEnvDirective(path, line, i + 1)
|
const { key, value } = parseEnvDirective(path, line, i + 1)
|
||||||
directives.push({ type: "env", key, value, 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 ")) {
|
} else if (line.startsWith("@setup ")) {
|
||||||
throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`)
|
throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -118,7 +104,7 @@ export function parseSetup(path: string, content: string): ShoutFile {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path, commands, directives, teardownCommands }
|
return { path, commands, directives }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parse(path: string, content: string): ShoutFile {
|
export function parse(path: string, content: string): ShoutFile {
|
||||||
|
|
@ -130,7 +116,6 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands: Command[] = []
|
const commands: Command[] = []
|
||||||
const teardownCommands: Command[] = []
|
|
||||||
const directives: Directive[] = []
|
const directives: Directive[] = []
|
||||||
let current: Command | null = null
|
let current: Command | null = null
|
||||||
let seenCommand = false
|
let seenCommand = false
|
||||||
|
|
@ -145,18 +130,6 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
throw new Error(`${path}:${i + 1}: @setup requires a file path`)
|
throw new Error(`${path}:${i + 1}: @setup requires a file path`)
|
||||||
}
|
}
|
||||||
directives.push({ type: "setup", path: setupPath, line: i + 1 })
|
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 ")) {
|
} else if (line.startsWith("@env ")) {
|
||||||
const { key, value } = parseEnvDirective(path, line, i + 1)
|
const { key, value } = parseEnvDirective(path, line, i + 1)
|
||||||
directives.push({ type: "env", key, value, line: i + 1 })
|
directives.push({ type: "env", key, value, line: i + 1 })
|
||||||
|
|
@ -210,5 +183,5 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
commands.push(current)
|
commands.push(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path, commands, directives, teardownCommands }
|
return { path, commands, directives }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export READY=yes
|
|
||||||
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.cleanup.db"
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
@setup teardown-setup.shout
|
|
||||||
|
|
||||||
$ touch marker.txt && ls marker.txt
|
|
||||||
marker.txt
|
|
||||||
|
|
@ -204,20 +204,6 @@
|
||||||
<span class="output">OK</span></code></pre>
|
<span class="output">OK</span></code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Setup & 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 — 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 && 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>
|
<section>
|
||||||
<h2>Run it</h2>
|
<h2>Run it</h2>
|
||||||
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span>
|
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user