Merge branch 'setup'
# Conflicts: # CLAUDE.md
This commit is contained in:
commit
371fb8c761
|
|
@ -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 test [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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { readdir, readFile, writeFile } from "node:fs/promises"
|
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 { program } from "commander"
|
||||||
import ansis from "ansis"
|
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 { 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"
|
||||||
|
|
@ -60,6 +60,7 @@ program
|
||||||
.option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val])
|
.option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val])
|
||||||
.option("--timeout <dur>", "Per-command timeout", "10s")
|
.option("--timeout <dur>", "Per-command timeout", "10s")
|
||||||
.option("-v, --verbose", "Print each command as it runs")
|
.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("--parallel", "Run files in parallel")
|
||||||
.action(async (fileArgs: string[], opts) => {
|
.action(async (fileArgs: string[], opts) => {
|
||||||
const timeoutMs = parseDuration(opts.timeout)
|
const timeoutMs = parseDuration(opts.timeout)
|
||||||
|
|
@ -74,14 +75,49 @@ program
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
const results: TestResult[] = []
|
const results: TestResult[] = []
|
||||||
const cwd = process.cwd()
|
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 content = await readFile(filePath, "utf-8")
|
||||||
const parsed = parse(relative(cwd, filePath), content)
|
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<string, string> = {}
|
||||||
|
const setupEnvVars: Record<string, string> = {}
|
||||||
|
const userEnvVars: Record<string, string> = {}
|
||||||
|
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,
|
cleanEnv: opts.cleanEnv ?? false,
|
||||||
pathDirs: opts.path,
|
pathDirs: opts.path,
|
||||||
|
envVars,
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
verbose: opts.verbose ?? false,
|
verbose: opts.verbose ?? false,
|
||||||
onCommand: opts.verbose
|
onCommand: opts.verbose
|
||||||
|
|
@ -89,14 +125,39 @@ program
|
||||||
: undefined,
|
: 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(
|
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) {
|
||||||
const updated = rewriteFile(parsed, fileResult.results, content)
|
const updated = rewriteFile(parsed, fileOwnResults, content)
|
||||||
if (updated !== content) {
|
if (updated !== content) {
|
||||||
await writeFile(filePath, updated)
|
await writeFile(filePath, updated)
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +187,7 @@ program
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.parallel) {
|
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) {
|
for (const r of all) {
|
||||||
printDots(r)
|
printDots(r)
|
||||||
results.push(r)
|
results.push(r)
|
||||||
|
|
@ -134,7 +195,7 @@ program
|
||||||
process.stdout.write("\n")
|
process.stdout.write("\n")
|
||||||
} else {
|
} else {
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
const r = await runOne(filePath)
|
const r = await runOne(filePath, nextPort !== undefined ? nextPort++ : undefined)
|
||||||
printDots(r)
|
printDots(r)
|
||||||
results.push(r)
|
results.push(r)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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"
|
||||||
|
|
|
||||||
|
|
@ -66,4 +66,53 @@ describe("parse", () => {
|
||||||
const b = parse("test.shout", "$ echo hi\nhi")
|
const b = parse("test.shout", "$ echo hi\nhi")
|
||||||
expect(a.commands[0]!.expected).toEqual(b.commands[0]!.expected)
|
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",
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
30
src/parse.ts
30
src/parse.ts
|
|
@ -6,9 +6,14 @@ export type Command = {
|
||||||
exitCode: number | "*" | null
|
exitCode: number | "*" | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Directive =
|
||||||
|
| { type: "setup"; path: string; line: number }
|
||||||
|
| { type: "env"; key: string; value: string; line: number }
|
||||||
|
|
||||||
export type ShoutFile = {
|
export type ShoutFile = {
|
||||||
path: string
|
path: string
|
||||||
commands: Command[]
|
commands: Command[]
|
||||||
|
directives: Directive[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripComment(line: string): string {
|
function stripComment(line: string): string {
|
||||||
|
|
@ -58,12 +63,35 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands: Command[] = []
|
const commands: Command[] = []
|
||||||
|
const directives: Directive[] = []
|
||||||
let current: Command | null = null
|
let current: Command | null = null
|
||||||
|
let seenCommand = false
|
||||||
|
|
||||||
for (let i = 0; i < rawLines.length; i++) {
|
for (let i = 0; i < rawLines.length; i++) {
|
||||||
const line = rawLines[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("$ ")) {
|
if (line.startsWith("$ ")) {
|
||||||
|
seenCommand = true
|
||||||
if (current) {
|
if (current) {
|
||||||
const trimmed = trimTrailingEmpty(current.expected)
|
const trimmed = trimTrailingEmpty(current.expected)
|
||||||
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
|
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
|
||||||
|
|
@ -92,5 +120,5 @@ export function parse(path: string, content: string): ShoutFile {
|
||||||
commands.push(current)
|
commands.push(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path, commands }
|
return { path, commands, directives }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export type FileResult = {
|
||||||
type RunOptions = {
|
type RunOptions = {
|
||||||
cleanEnv: boolean
|
cleanEnv: boolean
|
||||||
pathDirs?: string[]
|
pathDirs?: string[]
|
||||||
|
envVars?: Record<string, string>
|
||||||
timeout: number
|
timeout: number
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
onCommand?: (cmd: Command) => void
|
onCommand?: (cmd: Command) => void
|
||||||
|
|
@ -128,6 +129,10 @@ export async function runFile(
|
||||||
env["HOME"] = tmpDir
|
env["HOME"] = tmpDir
|
||||||
env["SHOUT_DIR"] = tmpDir
|
env["SHOUT_DIR"] = tmpDir
|
||||||
|
|
||||||
|
if (options.envVars) {
|
||||||
|
Object.assign(env, options.envVars)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.pathDirs?.length) {
|
if (options.pathDirs?.length) {
|
||||||
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
|
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
test/env.shout
Normal file
5
test/env.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@env GREETING=hello
|
||||||
|
@env TARGET=world
|
||||||
|
|
||||||
|
$ echo "$GREETING $TARGET"
|
||||||
|
hello world
|
||||||
1
test/setup-shared.shout
Normal file
1
test/setup-shared.shout
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
$ export READY=yes
|
||||||
4
test/setup-user.shout
Normal file
4
test/setup-user.shout
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@setup setup-shared.shout
|
||||||
|
|
||||||
|
$ echo $READY
|
||||||
|
yes
|
||||||
Loading…
Reference in New Issue
Block a user