Add @env, @setup directives and --port-from flag

This commit is contained in:
Chris Wanstrath 2026-03-10 09:32:06 -07:00
parent 56d982db17
commit 70008d16b9
8 changed files with 122 additions and 9 deletions

View File

@ -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 <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val])
.option("--timeout <dur>", "Per-command timeout", "10s")
.option("-v, --verbose", "Print each command as it runs")
.option("--port-from <n>", "Auto-assign $PORT starting from <n>")
.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<string, string> = {}
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)
}

View File

@ -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"

View File

@ -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([])
})
})

View File

@ -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 }
}

View File

@ -20,6 +20,7 @@ export type FileResult = {
type RunOptions = {
cleanEnv: boolean
pathDirs?: string[]
envVars?: Record<string, string>
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"] ?? "")
}

5
test/env.shout Normal file
View File

@ -0,0 +1,5 @@
@env GREETING=hello
@env TARGET=world
$ echo "$GREETING $TARGET"
hello world

1
test/setup-shared.shout Normal file
View File

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

4
test/setup-user.shout Normal file
View File

@ -0,0 +1,4 @@
@setup setup-shared.shout
$ echo $READY
yes