docs: add @env/@setup directives to CLAUDE.md

This commit is contained in:
Chris Wanstrath 2026-03-10 09:45:43 -07:00
parent 70008d16b9
commit 86eba1a624
4 changed files with 27 additions and 19 deletions

View File

@ -6,7 +6,7 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `bun test` — run unit tests - `bun test` — run unit tests
- `bunx tsc --noEmit` — type check - `bunx tsc --noEmit` — type check
- `bun run src/cli/index.ts [files...]` — run shout CLI - `bun run src/cli/index.ts [files...]` — run shout CLI (`--port-from <n>` auto-assigns `$PORT`)
## Architecture ## Architecture
@ -28,6 +28,8 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `[N]` on last line of expected output = assert exit code N - `[N]` on last line of expected output = assert exit code N
- `[*]` = assert any non-zero exit code; default expects 0 - `[*]` = assert any non-zero exit code; default expects 0
- `#` 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
- `@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 - Each file runs in a fresh temp dir with a single `/bin/sh` session
## Style ## Style

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 ShoutFile } from "../parse.ts" import { parse, 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"
@ -103,21 +103,19 @@ $ true
if (d.type === "env") envVars[d.key] = d.value if (d.type === "env") envVars[d.key] = d.value
} }
// Resolve @setup directives: prepend setup commands // Resolve @setup directives: prepend setup commands, collect their @env
let setupCount = 0 const setupCommands: Command[] = []
let merged: ShoutFile = parsed for (const d of parsed.directives) {
const setupDirectives = parsed.directives.filter(d => d.type === "setup") if (d.type !== "setup") continue
if (setupDirectives.length > 0) { const setupPath = resolve(dirname(filePath), d.path)
const setupCommands = [] const setupContent = await readFile(setupPath, "utf-8")
for (const d of setupDirectives) { const setupParsed = parse(relative(cwd, setupPath), setupContent)
const setupPath = resolve(dirname(filePath), d.path) for (const sd of setupParsed.directives) {
const setupContent = await readFile(setupPath, "utf-8") if (sd.type === "env") envVars[sd.key] = sd.value
const setupParsed = parse(relative(cwd, setupPath), setupContent)
setupCommands.push(...setupParsed.commands)
} }
setupCount = setupCommands.length setupCommands.push(...setupParsed.commands)
merged = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
} }
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands] }
const fileResult = await runFile(merged, { const fileResult = await runFile(merged, {
cleanEnv: opts.cleanEnv ?? false, cleanEnv: opts.cleanEnv ?? false,
@ -130,15 +128,15 @@ $ true
: undefined, : undefined,
}) })
const fileOwnResults = fileResult.results.slice(setupCommands.length)
const testResult = evaluateFile( const testResult = evaluateFile(
parsed.path, parsed.path,
fileResult.results, fileOwnResults,
fileResult.error, fileResult.error,
) )
if (opts.update && fileResult.results.length > 0) { if (opts.update && fileOwnResults.length > 0) {
// Only rewrite with the file's own results, not setup results
const fileOwnResults = fileResult.results.slice(setupCount)
const updated = rewriteFile(parsed, fileOwnResults, content) const updated = rewriteFile(parsed, fileOwnResults, content)
if (updated !== content) { if (updated !== content) {
await writeFile(filePath, updated) await writeFile(filePath, updated)

View File

@ -109,4 +109,10 @@ describe("parse", () => {
const result = parse("test.shout", "$ echo hi\nhi\n") const result = parse("test.shout", "$ echo hi\nhi\n")
expect(result.directives).toEqual([]) 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",
)
})
}) })

View File

@ -79,6 +79,8 @@ export function parse(path: string, content: string): ShoutFile {
if (eq > 0) { if (eq > 0) {
directives.push({ type: "env", key: rest.slice(0, eq), value: rest.slice(eq + 1), line: i + 1 }) 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 continue
} }