diff --git a/CLAUDE.md b/CLAUDE.md index d234de1..d5c14a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Transcript-based shell integration test runner. Bun + TypeScript. - `bun test` — run unit tests - `bunx tsc --noEmit` — type check -- `bun run src/cli/index.ts test [files...]` — run shout CLI +- `bun run src/cli/index.ts [files...]` — run shout CLI (`--port-from ` auto-assigns `$PORT`) ## Architecture @@ -28,6 +28,8 @@ Transcript-based shell integration test runner. Bun + TypeScript. - `[N]` on last line of expected output = assert exit code N - `[*]` = assert any non-zero exit code; default expects 0 - `#` 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 - Each file runs in a fresh temp dir with a single `/bin/sh` session ## Style diff --git a/src/cli/index.ts b/src/cli/index.ts index 2a5e8ce..0bbc63b 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun import { readdir, readFile, writeFile } from "node:fs/promises" -import { resolve, relative } from "node:path" +import { resolve, relative, dirname } from "node:path" import { program } from "commander" import ansis from "ansis" -import { parse } from "../parse.ts" +import { parse, 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" @@ -60,6 +60,7 @@ program .option("--path ", "Prepend to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val]) .option("--timeout ", "Per-command timeout", "10s") .option("-v, --verbose", "Print each command as it runs") + .option("--port-from ", "Auto-assign $PORT starting from ") .option("--parallel", "Run files in parallel") .action(async (fileArgs: string[], opts) => { const timeoutMs = parseDuration(opts.timeout) @@ -74,14 +75,49 @@ program const start = performance.now() const results: TestResult[] = [] const cwd = process.cwd() + const portFrom = opts.portFrom ? parseInt(opts.portFrom, 10) : undefined + if (portFrom !== undefined && Number.isNaN(portFrom)) { + console.error("--port-from must be an integer") + process.exit(1) + } + let nextPort = portFrom - const runOne = async (filePath: string) => { + const runOne = async (filePath: string, port: number | undefined) => { const content = await readFile(filePath, "utf-8") const parsed = parse(relative(cwd, filePath), content) - const fileResult = await runFile(parsed, { + // Resolve directives in a single pass. Setup @env is collected separately + // so that the user file's @env always takes precedence. + const envVars: Record = {} + const setupEnvVars: Record = {} + const userEnvVars: Record = {} + const setupCommands: Command[] = [] + for (const d of parsed.directives) { + 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) + 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) + } else if (d.type === "env") { + userEnvVars[d.key] = d.value + } + } + Object.assign(envVars, setupEnvVars, userEnvVars) + if (port !== undefined && !("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) { + envVars["PORT"] = String(port) + } + const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] } + + const fileResult = await runFile(merged, { cleanEnv: opts.cleanEnv ?? false, pathDirs: opts.path, + envVars, timeout: timeoutMs, verbose: opts.verbose ?? false, onCommand: opts.verbose @@ -89,14 +125,39 @@ program : undefined, }) + // Check setup commands for failures + for (let i = 0; i < setupCommands.length; i++) { + const r = fileResult.results[i] + const expected = setupCommands[i]!.exitCode + const ok = expected === null + ? r?.exitCode === 0 + : expected === "*" + ? r?.exitCode !== 0 + : r?.exitCode === expected + if (!ok) { + if (opts.keep) { + process.stderr.write(`${fileResult.tmpDir}\n`) + } else { + await cleanupTmpDir(fileResult.tmpDir) + } + return evaluateFile( + parsed.path, + [], + `setup command failed (exit ${r?.exitCode ?? "?"}): $ ${setupCommands[i]!.command}`, + ) + } + } + + const fileOwnResults = fileResult.results.slice(setupCommands.length) + const testResult = evaluateFile( parsed.path, - fileResult.results, + fileOwnResults, fileResult.error, ) - if (opts.update && fileResult.results.length > 0) { - const updated = rewriteFile(parsed, fileResult.results, content) + if (opts.update && fileOwnResults.length > 0) { + const updated = rewriteFile(parsed, fileOwnResults, content) if (updated !== content) { await writeFile(filePath, updated) } @@ -126,7 +187,7 @@ program } if (opts.parallel) { - const all = await Promise.all(files.map(runOne)) + const all = await Promise.all(files.map(f => runOne(f, nextPort !== undefined ? nextPort++ : undefined))) for (const r of all) { printDots(r) results.push(r) @@ -134,7 +195,7 @@ program process.stdout.write("\n") } else { for (const filePath of files) { - const r = await runOne(filePath) + const r = await runOne(filePath, nextPort !== undefined ? nextPort++ : undefined) printDots(r) results.push(r) } diff --git a/src/index.ts b/src/index.ts index 107ee68..29281a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export type { Command, ShoutFile } from "./parse.ts" +export type { Command, Directive, ShoutFile } from "./parse.ts" export type { CommandResult, FileResult } from "./run.ts" export type { DiffLine } from "./match.ts" export type { TestResult } from "./format.ts" diff --git a/src/parse.test.ts b/src/parse.test.ts index 87712ec..70a28f7 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -66,4 +66,53 @@ describe("parse", () => { const b = parse("test.shout", "$ echo hi\nhi") expect(a.commands[0]!.expected).toEqual(b.commands[0]!.expected) }) + + test("@env directive", () => { + const result = parse("test.shout", "@env PORT=3000\n$ echo $PORT\n3000\n") + expect(result.directives).toEqual([ + { type: "env", key: "PORT", value: "3000", line: 1 }, + ]) + expect(result.commands).toHaveLength(1) + }) + + test("@env with value containing =", () => { + const result = parse("test.shout", "@env FOO=bar=baz\n$ echo $FOO\n") + expect(result.directives[0]).toEqual( + { type: "env", key: "FOO", value: "bar=baz", line: 1 }, + ) + }) + + test("@setup directive", () => { + const result = parse("test.shout", "@setup shared/setup.shout\n$ echo hi\nhi\n") + expect(result.directives).toEqual([ + { type: "setup", path: "shared/setup.shout", line: 1 }, + ]) + expect(result.commands).toHaveLength(1) + }) + + test("multiple directives", () => { + const content = "@setup setup.shout\n@env PORT=3000\n@env NODE_ENV=test\n\n$ echo hi\nhi\n" + const result = parse("test.shout", content) + expect(result.directives).toHaveLength(3) + expect(result.directives[0]!.type).toBe("setup") + expect(result.directives[1]).toEqual({ type: "env", key: "PORT", value: "3000", line: 2 }) + expect(result.directives[2]).toEqual({ type: "env", key: "NODE_ENV", value: "test", line: 3 }) + }) + + test("@ lines after first command are expected output", () => { + const result = parse("test.shout", "$ cat config\n@env PORT=3000\n") + expect(result.directives).toEqual([]) + expect(result.commands[0]!.expected).toEqual(["@env PORT=3000"]) + }) + + test("no directives returns empty array", () => { + const result = parse("test.shout", "$ echo hi\nhi\n") + expect(result.directives).toEqual([]) + }) + + test("unknown directive throws", () => { + expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow( + "test.shout:1: unknown directive: @evn PORT=3000", + ) + }) }) diff --git a/src/parse.ts b/src/parse.ts index d204ca4..94cb20c 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -6,9 +6,14 @@ export type Command = { exitCode: number | "*" | null } +export type Directive = + | { type: "setup"; path: string; line: number } + | { type: "env"; key: string; value: string; line: number } + export type ShoutFile = { path: string commands: Command[] + directives: Directive[] } function stripComment(line: string): string { @@ -58,12 +63,35 @@ export function parse(path: string, content: string): ShoutFile { } const commands: Command[] = [] + const directives: Directive[] = [] let current: Command | null = null + let seenCommand = false for (let i = 0; i < rawLines.length; i++) { const line = rawLines[i]! + if (!seenCommand && line.startsWith("@")) { + if (line.startsWith("@setup ")) { + const setupPath = line.slice(7).trim() + if (!setupPath) { + throw new Error(`${path}:${i + 1}: @setup requires a file path`) + } + 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 }) + } else { + throw new Error(`${path}:${i + 1}: unknown directive: ${line}`) + } + continue + } + if (line.startsWith("$ ")) { + seenCommand = true if (current) { const trimmed = trimTrailingEmpty(current.expected) const { lines: expectedLines, exitCode } = parseExitCode(trimmed) @@ -92,5 +120,5 @@ export function parse(path: string, content: string): ShoutFile { commands.push(current) } - return { path, commands } + return { path, commands, directives } } diff --git a/src/run.ts b/src/run.ts index b26fd38..516f0e5 100644 --- a/src/run.ts +++ b/src/run.ts @@ -20,6 +20,7 @@ export type FileResult = { type RunOptions = { cleanEnv: boolean pathDirs?: string[] + envVars?: Record timeout: number verbose: boolean onCommand?: (cmd: Command) => void @@ -128,6 +129,10 @@ export async function runFile( env["HOME"] = tmpDir env["SHOUT_DIR"] = tmpDir + if (options.envVars) { + Object.assign(env, options.envVars) + } + if (options.pathDirs?.length) { env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "") } diff --git a/test/env.shout b/test/env.shout new file mode 100644 index 0000000..245d02c --- /dev/null +++ b/test/env.shout @@ -0,0 +1,5 @@ +@env GREETING=hello +@env TARGET=world + +$ echo "$GREETING $TARGET" +hello world diff --git a/test/setup-shared.shout b/test/setup-shared.shout new file mode 100644 index 0000000..7ad3eba --- /dev/null +++ b/test/setup-shared.shout @@ -0,0 +1 @@ +$ export READY=yes diff --git a/test/setup-user.shout b/test/setup-user.shout new file mode 100644 index 0000000..023c5c2 --- /dev/null +++ b/test/setup-user.shout @@ -0,0 +1,4 @@ +@setup setup-shared.shout + +$ echo $READY +yes