Add @def directive for command macro substitution
Macros let setup files define reusable command shorthands that test files can invoke by name or override with their own definition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
371627beeb
commit
ce1503a9d4
|
|
@ -50,6 +50,11 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
||||||
- Setup files can contain `@env` and `@teardown` 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`
|
- 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
|
||||||
|
- `@def name body` before first command = define a macro
|
||||||
|
- If a command matches `name` exactly, `body` is substituted before execution
|
||||||
|
- Backslash `\` at end of line continues the body onto the next line
|
||||||
|
- Allowed in both `.shout` files and setup files
|
||||||
|
- User file `@def` overrides setup file `@def` with the same name
|
||||||
- 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
|
||||||
- `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically
|
- `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically
|
||||||
- `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file
|
- `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -99,6 +99,29 @@ export DB_URL=sqlite:data/test.db
|
||||||
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db"
|
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `@def`
|
||||||
|
|
||||||
|
Define a macro that substitutes a command by name:
|
||||||
|
|
||||||
|
```
|
||||||
|
@def greet echo "hello world"
|
||||||
|
|
||||||
|
$ greet
|
||||||
|
hello world
|
||||||
|
```
|
||||||
|
|
||||||
|
Use backslash `\` for multi-line bodies:
|
||||||
|
|
||||||
|
```
|
||||||
|
@def serve \
|
||||||
|
python3 -m http.server $PORT & \
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
$ serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Macros defined in setup files are inherited. A user file `@def` with the same name overrides the setup version.
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage: shout test [options] [files...]
|
Usage: shout test [options] [files...]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,8 @@ program
|
||||||
const envVars: Record<string, string> = {}
|
const envVars: Record<string, string> = {}
|
||||||
const setupEnvVars: Record<string, string> = {}
|
const setupEnvVars: Record<string, string> = {}
|
||||||
const userEnvVars: Record<string, string> = {}
|
const userEnvVars: Record<string, string> = {}
|
||||||
|
const setupMacros: Record<string, string> = {}
|
||||||
|
const userMacros: Record<string, string> = {}
|
||||||
const setupCommands: Command[] = []
|
const setupCommands: Command[] = []
|
||||||
const teardownCommands: Command[] = [...parsed.teardownCommands]
|
const teardownCommands: Command[] = [...parsed.teardownCommands]
|
||||||
for (const d of parsed.directives) {
|
for (const d of parsed.directives) {
|
||||||
|
|
@ -126,18 +128,33 @@ program
|
||||||
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
|
const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
|
||||||
for (const sd of setupParsed.directives) {
|
for (const sd of setupParsed.directives) {
|
||||||
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
|
||||||
|
else if (sd.type === "def") setupMacros[sd.name] = sd.body
|
||||||
}
|
}
|
||||||
setupCommands.push(...setupParsed.commands)
|
setupCommands.push(...setupParsed.commands)
|
||||||
teardownCommands.push(...setupParsed.teardownCommands)
|
teardownCommands.push(...setupParsed.teardownCommands)
|
||||||
} else if (d.type === "env") {
|
} else if (d.type === "env") {
|
||||||
userEnvVars[d.key] = d.value
|
userEnvVars[d.key] = d.value
|
||||||
|
} else if (d.type === "def") {
|
||||||
|
userMacros[d.name] = d.body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Object.assign(envVars, setupEnvVars, userEnvVars)
|
Object.assign(envVars, setupEnvVars, userEnvVars)
|
||||||
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 macros: Record<string, string> = { ...setupMacros, ...userMacros }
|
||||||
|
const expandMacro = (cmd: Command): Command => {
|
||||||
|
const body = macros[cmd.command]
|
||||||
|
return body !== undefined ? { ...cmd, command: body } : cmd
|
||||||
|
}
|
||||||
|
const merged: ShoutFile = {
|
||||||
|
...parsed,
|
||||||
|
commands: [
|
||||||
|
...setupCommands.map(expandMacro),
|
||||||
|
...parsed.commands.map(expandMacro),
|
||||||
|
...teardownCommands.map(expandMacro),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
const setupLen = setupCommands.length
|
const setupLen = setupCommands.length
|
||||||
const userLen = parsed.commands.length
|
const userLen = parsed.commands.length
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,44 @@ describe("parse", () => {
|
||||||
"test.shout:1: unknown directive: @evn PORT=3000",
|
"test.shout:1: unknown directive: @evn PORT=3000",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("@def simple macro", () => {
|
||||||
|
const result = parse("test.shout", "@def greet echo hello\n$ greet\nhello\n")
|
||||||
|
expect(result.directives).toEqual([
|
||||||
|
{ type: "def", name: "greet", body: "echo hello", line: 1 },
|
||||||
|
])
|
||||||
|
expect(result.commands).toHaveLength(1)
|
||||||
|
expect(result.commands[0]!.command).toBe("greet")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@def with backslash continuation", () => {
|
||||||
|
const content = "@def multi echo one; \\\n echo two\n$ multi\none\ntwo\n"
|
||||||
|
const result = parse("test.shout", content)
|
||||||
|
expect(result.directives).toEqual([
|
||||||
|
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
|
||||||
|
])
|
||||||
|
expect(result.commands).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@def multiple macros", () => {
|
||||||
|
const content = "@def foo echo foo\n@def bar echo bar\n$ foo\nfoo\n"
|
||||||
|
const result = parse("test.shout", content)
|
||||||
|
expect(result.directives).toHaveLength(2)
|
||||||
|
expect(result.directives[0]).toEqual({ type: "def", name: "foo", body: "echo foo", line: 1 })
|
||||||
|
expect(result.directives[1]).toEqual({ type: "def", name: "bar", body: "echo bar", line: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@def without body throws", () => {
|
||||||
|
expect(() => parse("test.shout", "@def greet\n$ echo hi\n")).toThrow(
|
||||||
|
"test.shout:1: @def requires a name and body",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@def after first command is expected output", () => {
|
||||||
|
const result = parse("test.shout", "$ cat file\n@def foo bar\n")
|
||||||
|
expect(result.directives).toEqual([])
|
||||||
|
expect(result.commands[0]!.expected).toEqual(["@def foo bar"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("parseSetup", () => {
|
describe("parseSetup", () => {
|
||||||
|
|
@ -252,4 +290,19 @@ describe("parseSetup", () => {
|
||||||
expect(result.commands[0]!.expected).toEqual([])
|
expect(result.commands[0]!.expected).toEqual([])
|
||||||
expect(result.commands[0]!.exitCode).toBeNull()
|
expect(result.commands[0]!.exitCode).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("@def in setup file", () => {
|
||||||
|
const result = parseSetup("setup.shout", "@def greet echo hello\nexport FOO=bar\n")
|
||||||
|
expect(result.directives).toEqual([
|
||||||
|
{ type: "def", name: "greet", body: "echo hello", line: 1 },
|
||||||
|
])
|
||||||
|
expect(result.commands).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("@def with backslash continuation in setup file", () => {
|
||||||
|
const result = parseSetup("setup.shout", "@def multi echo one; \\\n echo two\n")
|
||||||
|
expect(result.directives).toEqual([
|
||||||
|
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
35
src/parse.ts
35
src/parse.ts
|
|
@ -11,6 +11,7 @@ export type Command = {
|
||||||
export type Directive =
|
export type Directive =
|
||||||
| { type: "setup"; path: string; line: number }
|
| { type: "setup"; path: string; line: number }
|
||||||
| { type: "env"; key: string; value: string; line: number }
|
| { type: "env"; key: string; value: string; line: number }
|
||||||
|
| { type: "def"; name: string; body: string; line: number }
|
||||||
|
|
||||||
export type ShoutFile = {
|
export type ShoutFile = {
|
||||||
path: string
|
path: string
|
||||||
|
|
@ -65,6 +66,32 @@ function parseEnvDirective(path: string, line: string, lineNum: number): { key:
|
||||||
return { key: rest.slice(0, eq), value: rest.slice(eq + 1) }
|
return { key: rest.slice(0, eq), value: rest.slice(eq + 1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDefDirective(
|
||||||
|
path: string,
|
||||||
|
rawLines: string[],
|
||||||
|
i: number,
|
||||||
|
): { name: string; body: string; linesConsumed: number } {
|
||||||
|
const rest = rawLines[i]!.slice(5).trim() // strip "@def "
|
||||||
|
const spaceIdx = rest.indexOf(" ")
|
||||||
|
if (spaceIdx < 0) {
|
||||||
|
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
||||||
|
}
|
||||||
|
const name = rest.slice(0, spaceIdx)
|
||||||
|
let body = rest.slice(spaceIdx + 1)
|
||||||
|
let extra = 0
|
||||||
|
|
||||||
|
while (body.endsWith("\\") && i + extra + 1 < rawLines.length) {
|
||||||
|
body = body.slice(0, -1).trimEnd() + "\n" + rawLines[i + extra + 1]!.trim()
|
||||||
|
extra++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.trim()) {
|
||||||
|
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, body, linesConsumed: extra }
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSetup(path: string, content: string): ShoutFile {
|
export function parseSetup(path: string, content: string): ShoutFile {
|
||||||
const rawLines = content.split("\n")
|
const rawLines = content.split("\n")
|
||||||
|
|
||||||
|
|
@ -97,6 +124,10 @@ export function parseSetup(path: string, content: string): ShoutFile {
|
||||||
expected: [],
|
expected: [],
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
})
|
})
|
||||||
|
} else if (line.startsWith("@def ")) {
|
||||||
|
const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i)
|
||||||
|
directives.push({ type: "def", name, body, line: i + 1 })
|
||||||
|
i += linesConsumed
|
||||||
} 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 {
|
||||||
|
|
@ -156,6 +187,10 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
} 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 })
|
||||||
|
} else if (line.startsWith("@def ")) {
|
||||||
|
const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i)
|
||||||
|
directives.push({ type: "def", name, body, line: i + 1 })
|
||||||
|
i += linesConsumed
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
|
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
test/def-override.shout
Normal file
5
test/def-override.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@setup def-shared.shout
|
||||||
|
@def serve echo "overridden"
|
||||||
|
|
||||||
|
$ serve
|
||||||
|
overridden
|
||||||
4
test/def-setup.shout
Normal file
4
test/def-setup.shout
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@setup def-shared.shout
|
||||||
|
|
||||||
|
$ serve
|
||||||
|
server started
|
||||||
1
test/def-shared.shout
Normal file
1
test/def-shared.shout
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@def serve echo "server started"
|
||||||
13
test/def.shout
Normal file
13
test/def.shout
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
@def greet echo "hello world"
|
||||||
|
@def multi echo "line one"; \
|
||||||
|
echo "line two"
|
||||||
|
|
||||||
|
$ greet
|
||||||
|
hello world
|
||||||
|
|
||||||
|
$ multi
|
||||||
|
line one
|
||||||
|
line two
|
||||||
|
|
||||||
|
$ echo "not a macro"
|
||||||
|
not a macro
|
||||||
|
|
@ -218,6 +218,16 @@
|
||||||
<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>
|
<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>Macros</h2>
|
||||||
|
<p>Use <code class="bright">@def</code> to define reusable command macros.</p>
|
||||||
|
<pre><code><span class="bright">@def</span> <span class="cmd">greet echo "hello world"</span>
|
||||||
|
|
||||||
|
<span class="prompt">$</span> <span class="cmd">greet</span>
|
||||||
|
<span class="output">hello world</span></code></pre>
|
||||||
|
<p>If a command matches a macro name exactly, the body is substituted. Use <code>\</code> for multi-line bodies. Macros from setup files are inherited; user-file macros override them.</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