From c843ba7c2272f68762e3a994e59cb61deeccc824 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 10 Mar 2026 21:46:49 -0700 Subject: [PATCH] Simpler setup files --- CLAUDE.md | 3 ++- src/cli/index.ts | 7 ++---- src/index.ts | 2 +- src/parse.test.ts | 43 ++++++++++++++++++++++++++++++- src/parse.ts | 56 ++++++++++++++++++++++++++++++++++++----- src/run.test.ts | 1 + test/setup-shared.shout | 2 +- 7 files changed, 99 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c3f06e..81216eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,8 @@ Transcript-based shell integration test runner. Bun + TypeScript. - `#` after a command = comment (stripped); `#` in expected output is literal - `@env KEY=VALUE` before first command = set environment variable - `@setup path.shout` before first command = prepend commands (and `@env`) from another file - - Setup files cannot themselves contain `@setup` (no nesting) + - 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) - 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 5870f11..2c3a72a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,7 +5,7 @@ import { resolve, relative, dirname } from "node:path" import { program } from "commander" import ansis from "ansis" -import { parse, type Command, type ShoutFile } from "../parse.ts" +import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts" import { runFile, cleanupTmpDir } from "../run.ts" import { evaluateFile, formatFailure, formatSummary } from "../format.ts" import type { TestResult } from "../format.ts" @@ -115,11 +115,8 @@ program if (d.type === "setup") { const setupPath = resolve(dirname(filePath), d.path) const setupContent = await readFile(setupPath, "utf-8") - const setupParsed = parse(relative(cwd, setupPath), setupContent) + const setupParsed = parseSetup(relative(cwd, setupPath), setupContent) for (const sd of setupParsed.directives) { - if (sd.type === "setup") { - throw new Error(`${relative(cwd, setupPath)}: @setup not allowed in setup files`) - } if (sd.type === "env") setupEnvVars[sd.key] = sd.value } setupCommands.push(...setupParsed.commands) diff --git a/src/index.ts b/src/index.ts index 29281a8..1796e60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export type { CommandResult, FileResult } from "./run.ts" export type { DiffLine } from "./match.ts" export type { TestResult } from "./format.ts" -export { parse } from "./parse.ts" +export { parse, parseSetup } from "./parse.ts" export { runFile, cleanupTmpDir } from "./run.ts" export { matchLine, matchOutput, diff } from "./match.ts" export { evaluateFile, formatFailure, formatSummary } from "./format.ts" diff --git a/src/parse.test.ts b/src/parse.test.ts index 70a28f7..fae3931 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { parse } from "./parse.ts" +import { parse, parseSetup } from "./parse.ts" describe("parse", () => { test("simple command with output", () => { @@ -116,3 +116,44 @@ describe("parse", () => { ) }) }) + +describe("parseSetup", () => { + test("plain commands without $ prefix", () => { + const result = parseSetup("setup.shout", "export FOO=bar\necho hello\n") + expect(result.commands).toHaveLength(2) + expect(result.commands[0]!.command).toBe("export FOO=bar") + expect(result.commands[1]!.command).toBe("echo hello") + }) + + test("@env directives", () => { + const result = parseSetup("setup.shout", "@env PORT=3000\nexport FOO=bar\n") + expect(result.directives).toEqual([ + { type: "env", key: "PORT", value: "3000", line: 1 }, + ]) + expect(result.commands).toHaveLength(1) + }) + + test("blank lines and comments are ignored", () => { + const result = parseSetup("setup.shout", "# set up env\nexport FOO=bar\n\nexport BAZ=qux\n") + expect(result.commands).toHaveLength(2) + expect(result.commands[0]!.command).toBe("export FOO=bar") + expect(result.commands[1]!.command).toBe("export BAZ=qux") + }) + + test("strips trailing comments from commands", () => { + const result = parseSetup("setup.shout", "export FOO=bar # set foo\n") + expect(result.commands[0]!.command).toBe("export FOO=bar") + }) + + test("@setup in setup file throws", () => { + expect(() => parseSetup("setup.shout", "@setup other.shout\n")).toThrow( + "setup.shout:1: @setup not allowed in setup files", + ) + }) + + test("commands have no expected output", () => { + const result = parseSetup("setup.shout", "echo hello\n") + expect(result.commands[0]!.expected).toEqual([]) + expect(result.commands[0]!.exitCode).toBeNull() + }) +}) diff --git a/src/parse.ts b/src/parse.ts index 94cb20c..490b9f6 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -54,6 +54,54 @@ function trimTrailingEmpty(lines: string[]): string[] { return lines.slice(0, end) } +function parseEnvDirective(path: string, line: string, lineNum: number): { key: string; value: string } { + const rest = line.slice(5).trim() + const eq = rest.indexOf("=") + if (eq <= 0) { + throw new Error(`${path}:${lineNum}: malformed @env directive (expected KEY=VALUE): ${line}`) + } + return { key: rest.slice(0, eq), value: rest.slice(eq + 1) } +} + +export function parseSetup(path: string, content: string): ShoutFile { + const rawLines = content.split("\n") + + if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") { + rawLines.pop() + } + + const commands: Command[] = [] + const directives: Directive[] = [] + + for (let i = 0; i < rawLines.length; i++) { + const line = rawLines[i]! + + if (line === "" || line.startsWith("#")) continue + + if (line.startsWith("@")) { + 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("@setup ")) { + throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`) + } else { + throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) + } + continue + } + + commands.push({ + line: i + 1, + raw: line, + command: stripComment(line), + expected: [], + exitCode: null, + }) + } + + return { path, commands, directives } +} + export function parse(path: string, content: string): ShoutFile { const rawLines = content.split("\n") @@ -78,12 +126,8 @@ export function parse(path: string, content: string): ShoutFile { } directives.push({ type: "setup", path: setupPath, line: i + 1 }) } else if (line.startsWith("@env ")) { - const rest = line.slice(5).trim() - const eq = rest.indexOf("=") - if (eq <= 0) { - throw new Error(`${path}:${i + 1}: malformed @env directive (expected KEY=VALUE): ${line}`) - } - directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 }) + const { key, value } = parseEnvDirective(path, line, i + 1) + directives.push({ type: "env", key, value, line: i + 1 }) } else { throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) } diff --git a/src/run.test.ts b/src/run.test.ts index e2ab64f..a06f994 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -7,6 +7,7 @@ import type { ShoutFile } from "./parse.ts" function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile { return { path: "test.shout", + directives: [], commands: commands.map((c, i) => ({ line: i + 1, raw: `$ ${c.command}`, diff --git a/test/setup-shared.shout b/test/setup-shared.shout index 7ad3eba..5f9c20b 100644 --- a/test/setup-shared.shout +++ b/test/setup-shared.shout @@ -1 +1 @@ -$ export READY=yes +export READY=yes