Simpler setup files

This commit is contained in:
Chris Wanstrath 2026-03-10 21:46:49 -07:00
parent 4373f0a285
commit c843ba7c22
7 changed files with 99 additions and 15 deletions

View File

@ -39,7 +39,8 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `#` 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
- `@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 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` - 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

View File

@ -5,7 +5,7 @@ import { resolve, relative, dirname } from "node:path"
import { program } from "commander" import { program } from "commander"
import ansis from "ansis" 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 { runFile, cleanupTmpDir } from "../run.ts"
import { evaluateFile, formatFailure, formatSummary } from "../format.ts" import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
import type { TestResult } from "../format.ts" import type { TestResult } from "../format.ts"
@ -115,11 +115,8 @@ program
if (d.type === "setup") { if (d.type === "setup") {
const setupPath = resolve(dirname(filePath), d.path) const setupPath = resolve(dirname(filePath), d.path)
const setupContent = await readFile(setupPath, "utf-8") 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) { 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 if (sd.type === "env") setupEnvVars[sd.key] = sd.value
} }
setupCommands.push(...setupParsed.commands) setupCommands.push(...setupParsed.commands)

View File

@ -3,7 +3,7 @@ export type { CommandResult, FileResult } from "./run.ts"
export type { DiffLine } from "./match.ts" export type { DiffLine } from "./match.ts"
export type { TestResult } from "./format.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 { runFile, cleanupTmpDir } from "./run.ts"
export { matchLine, matchOutput, diff } from "./match.ts" export { matchLine, matchOutput, diff } from "./match.ts"
export { evaluateFile, formatFailure, formatSummary } from "./format.ts" export { evaluateFile, formatFailure, formatSummary } from "./format.ts"

View File

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { parse } from "./parse.ts" import { parse, parseSetup } from "./parse.ts"
describe("parse", () => { describe("parse", () => {
test("simple command with output", () => { 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()
})
})

View File

@ -54,6 +54,54 @@ function trimTrailingEmpty(lines: string[]): string[] {
return lines.slice(0, end) 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 { export function parse(path: string, content: string): ShoutFile {
const rawLines = content.split("\n") 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 }) directives.push({ type: "setup", path: setupPath, line: i + 1 })
} else if (line.startsWith("@env ")) { } else if (line.startsWith("@env ")) {
const rest = line.slice(5).trim() const { key, value } = parseEnvDirective(path, line, i + 1)
const eq = rest.indexOf("=") directives.push({ type: "env", key, value, line: i + 1 })
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 })
} else { } else {
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
} }

View File

@ -7,6 +7,7 @@ import type { ShoutFile } from "./parse.ts"
function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile { function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile {
return { return {
path: "test.shout", path: "test.shout",
directives: [],
commands: commands.map((c, i) => ({ commands: commands.map((c, i) => ({
line: i + 1, line: i + 1,
raw: `$ ${c.command}`, raw: `$ ${c.command}`,

View File

@ -1 +1 @@
$ export READY=yes export READY=yes