From 70008d16b9080e403765daca15bffec86789d050 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Tue, 10 Mar 2026 09:32:06 -0700 Subject: [PATCH] Add @env, @setup directives and --port-from flag --- src/cli/index.ts | 48 +++++++++++++++++++++++++++++++++++------ src/index.ts | 2 +- src/parse.test.ts | 43 ++++++++++++++++++++++++++++++++++++ src/parse.ts | 23 +++++++++++++++++++- src/run.ts | 5 +++++ test/env.shout | 5 +++++ test/setup-shared.shout | 1 + test/setup-user.shout | 4 ++++ 8 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 test/env.shout create mode 100644 test/setup-shared.shout create mode 100644 test/setup-user.shout diff --git a/src/cli/index.ts b/src/cli/index.ts index 735b737..a357e30 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 ShoutFile } from "../parse.ts" import { runFile, cleanupTmpDir } from "../run.ts" import { evaluateFile, formatFailure, formatSummary } from "../format.ts" import type { TestResult } from "../format.ts" @@ -53,6 +53,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") .option("--example", "Print an example .shout file and exit") .action(async (fileArgs: string[], opts) => { @@ -88,14 +89,40 @@ $ true const start = performance.now() const results: TestResult[] = [] const cwd = process.cwd() + const portFrom = opts.portFrom ? parseInt(opts.portFrom, 10) : undefined + let nextPort = portFrom ?? 0 - const runOne = async (filePath: string) => { + const runOne = async (filePath: string, port?: number) => { const content = await readFile(filePath, "utf-8") const parsed = parse(relative(cwd, filePath), content) - const fileResult = await runFile(parsed, { + // Collect env vars: --port-from, then @env directives + const envVars: Record = {} + if (port !== undefined) envVars["PORT"] = String(port) + for (const d of parsed.directives) { + if (d.type === "env") envVars[d.key] = d.value + } + + // Resolve @setup directives: prepend setup commands + let setupCount = 0 + let merged: ShoutFile = parsed + const setupDirectives = parsed.directives.filter(d => d.type === "setup") + if (setupDirectives.length > 0) { + const setupCommands = [] + for (const d of setupDirectives) { + const setupPath = resolve(dirname(filePath), d.path) + const setupContent = await readFile(setupPath, "utf-8") + const setupParsed = parse(relative(cwd, setupPath), setupContent) + setupCommands.push(...setupParsed.commands) + } + setupCount = setupCommands.length + merged = { ...parsed, commands: [...setupCommands, ...parsed.commands] } + } + + const fileResult = await runFile(merged, { cleanEnv: opts.cleanEnv ?? false, pathDirs: opts.path, + envVars: Object.keys(envVars).length > 0 ? envVars : undefined, timeout: timeoutMs, verbose: opts.verbose ?? false, onCommand: opts.verbose @@ -110,7 +137,9 @@ $ true ) if (opts.update && fileResult.results.length > 0) { - const updated = rewriteFile(parsed, fileResult.results, content) + // Only rewrite with the file's own results, not setup results + const fileOwnResults = fileResult.results.slice(setupCount) + const updated = rewriteFile(parsed, fileOwnResults, content) if (updated !== content) { await writeFile(filePath, updated) } @@ -140,7 +169,11 @@ $ true } if (opts.parallel) { - const all = await Promise.all(files.map(runOne)) + const tasks = files.map(f => { + const port = portFrom !== undefined ? nextPort++ : undefined + return runOne(f, port) + }) + const all = await Promise.all(tasks) for (const r of all) { printDots(r) results.push(r) @@ -148,7 +181,8 @@ $ true process.stdout.write("\n") } else { for (const filePath of files) { - const r = await runOne(filePath) + const port = portFrom !== undefined ? nextPort++ : undefined + const r = await runOne(filePath, port) 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..8af05ba 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -66,4 +66,47 @@ 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([]) + }) }) diff --git a/src/parse.ts b/src/parse.ts index d204ca4..bd62914 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,28 @@ 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 ")) { + directives.push({ type: "setup", path: line.slice(7).trim(), line: i + 1 }) + } else if (line.startsWith("@env ")) { + const rest = line.slice(5).trim() + const eq = rest.indexOf("=") + if (eq > 0) { + directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 }) + } + } + continue + } + if (line.startsWith("$ ")) { + seenCommand = true if (current) { const trimmed = trimTrailingEmpty(current.expected) const { lines: expectedLines, exitCode } = parseExitCode(trimmed) @@ -92,5 +113,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 001ac4d..0233c7f 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["CUE_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