diff --git a/CLAUDE.md b/CLAUDE.md index f4d8c1c..ba665c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) - User file `@env` overrides setup file `@env` - 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 - `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically - `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file diff --git a/README.md b/README.md index 6ab7326..019261c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,29 @@ export DB_URL=sqlite: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...] diff --git a/src/cli/index.ts b/src/cli/index.ts index 8d6c963..a682613 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -117,6 +117,8 @@ program const envVars: Record = {} const setupEnvVars: Record = {} const userEnvVars: Record = {} + const setupMacros: Record = {} + const userMacros: Record = {} const setupCommands: Command[] = [] const teardownCommands: Command[] = [...parsed.teardownCommands] for (const d of parsed.directives) { @@ -126,18 +128,33 @@ program const setupParsed = parseSetup(relative(cwd, setupPath), setupContent) for (const sd of setupParsed.directives) { if (sd.type === "env") setupEnvVars[sd.key] = sd.value + else if (sd.type === "def") setupMacros[sd.name] = sd.body } setupCommands.push(...setupParsed.commands) teardownCommands.push(...setupParsed.teardownCommands) } else if (d.type === "env") { userEnvVars[d.key] = d.value + } else if (d.type === "def") { + userMacros[d.name] = d.body } } Object.assign(envVars, setupEnvVars, userEnvVars) if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) { envVars["PORT"] = String(port) } - const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] } + const macros: Record = { ...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 userLen = parsed.commands.length diff --git a/src/parse.test.ts b/src/parse.test.ts index f4941f8..0fa7c98 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -185,6 +185,44 @@ describe("parse", () => { "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", () => { @@ -252,4 +290,19 @@ describe("parseSetup", () => { expect(result.commands[0]!.expected).toEqual([]) 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 }, + ]) + }) }) diff --git a/src/parse.ts b/src/parse.ts index b5709bc..413588e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -11,6 +11,7 @@ export type Command = { export type Directive = | { type: "setup"; path: string; line: number } | { type: "env"; key: string; value: string; line: number } + | { type: "def"; name: string; body: string; line: number } export type ShoutFile = { 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) } } +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 { const rawLines = content.split("\n") @@ -97,6 +124,10 @@ export function parseSetup(path: string, content: string): ShoutFile { expected: [], 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 ")) { throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`) } else { @@ -156,6 +187,10 @@ export function parse(path: string, content: string): ShoutFile { } else 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("@def ")) { + const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i) + directives.push({ type: "def", name, body, line: i + 1 }) + i += linesConsumed } else { throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) } diff --git a/test/def-override.shout b/test/def-override.shout new file mode 100644 index 0000000..dcf4ca1 --- /dev/null +++ b/test/def-override.shout @@ -0,0 +1,5 @@ +@setup def-shared.shout +@def serve echo "overridden" + +$ serve +overridden diff --git a/test/def-setup.shout b/test/def-setup.shout new file mode 100644 index 0000000..227c2cc --- /dev/null +++ b/test/def-setup.shout @@ -0,0 +1,4 @@ +@setup def-shared.shout + +$ serve +server started diff --git a/test/def-shared.shout b/test/def-shared.shout new file mode 100644 index 0000000..c0e12b2 --- /dev/null +++ b/test/def-shared.shout @@ -0,0 +1 @@ +@def serve echo "server started" diff --git a/test/def.shout b/test/def.shout new file mode 100644 index 0000000..08324d5 --- /dev/null +++ b/test/def.shout @@ -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 diff --git a/web/index.html b/web/index.html index 8fa1ab2..e7761f6 100644 --- a/web/index.html +++ b/web/index.html @@ -218,6 +218,16 @@

@teardown can appear in both .shout files and setup files. Teardown failures produce warnings but don't affect test results.

+
+

Macros

+

Use @def to define reusable command macros.

+
@def greet echo "hello world"
+
+$ greet
+hello world
+

If a command matches a macro name exactly, the body is substituted. Use \ for multi-line bodies. Macros from setup files are inherited; user-file macros override them.

+
+

Run it

$ shout test