Add test runner core with CLI and spec updates

This commit is contained in:
Chris Wanstrath 2026-03-09 21:30:17 -07:00
parent c4cd5353bf
commit 17268b50f0
15 changed files with 1003 additions and 23 deletions

34
SPEC.md
View File

@ -53,32 +53,20 @@ Each `.shout` file runs in a fresh temporary directory. The directory is
created before the first command and removed after the last (unless
`--keep` is passed).
All commands in a file run in a single shell session (`/bin/sh`), so `cd`,
`export`, and other shell state persists between commands.
The following environment variables are set for every command:
| Variable | Value |
|---|---|
| `HOME` | the temp directory |
| `PATH` | prepended with the directory containing the binary under test |
| `PATH` | inherited from host (or prepended via `--bin`) |
| `CUE_DIR` | the temp directory |
All other environment variables are inherited from the host unless explicitly
cleared with `--clean-env`.
### Setup blocks
Commands before the first blank line + command sequence are run as setup and
their output is not asserted.
Alternatively, a `# ---` line separates setup from the test body explicitly:
```
$ export TOKEN=abc
$ cd myproject
# ---
$ dev status
on timeline @ change 0
```
### Exit codes
By default, a non-zero exit code fails the test regardless of output. To
@ -111,8 +99,8 @@ and subdirectories. Each command in each shout file is run sequentially
| `--update` / `-u` | Rewrite expected output in-place with actual output |
| `--keep` / `-k` | Keep temp directories after run (printed to stderr) |
| `--clean-env` | Start with empty environment (only `PATH` and `CUE_DIR` set) |
| `--bin <path>` | Prepend `<path>` to `PATH` instead of auto-detecting |
| `--timeout <dur>` | Per-command timeout (default: `10s`) |
| `--bin <path>` | Prepend `<path>` to `PATH` |
| `--timeout <dur>` | Per-command timeout, e.g. `500ms`, `10s`, `1m` (default: `10s`) |
| `--verbose` / `-v` | Print each command as it runs |
| `--parallel` | Run files in parallel (implies all files run regardless of failures) |
@ -165,12 +153,12 @@ No special directory structure is required. `.shout` files can live anywhere.
## Implementation notes
- Bun + TypeScript
- Commands run via `Bun.spawn` with a shell (`/bin/sh -c`)
- Each file runs in a single `/bin/sh` session via `Bun.spawn`
- Stdout and stderr merged (same as a terminal)
- Each command in a file shares a working directory but runs in a fresh
process — no persistent shell state between commands
- For persistent state (e.g. `cd`, `export`), users wrap in a shell block or
use a setup script
- Shell state (`cd`, `export`, etc.) persists across commands within a file
- Commands are fed to the shell sequentially; output between commands is
captured by delimiting with sentinel `echo` statements
- shout exits `0` if all tests pass, `1` if any fail
---

147
src/cli/index.ts Executable file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env bun
import { readdir, readFile, writeFile } from "node:fs/promises"
import { resolve, relative } from "node:path"
import { program } from "commander"
import ansis from "ansis"
import { parse } from "../parse.ts"
import { runFile, cleanupTmpDir } from "../run.ts"
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
import type { TestResult } from "../format.ts"
import { parseDuration } from "../duration.ts"
import { rewriteFile } from "../update.ts"
async function findShoutFiles(paths: string[]): Promise<string[]> {
const files: string[] = []
for (const p of paths) {
const abs = resolve(p)
const stat = await Bun.file(abs).exists()
? Bun.file(abs)
: null
if (stat && abs.endsWith(".shout")) {
files.push(abs)
continue
}
// Try as directory
try {
const entries = await readdir(abs, { recursive: true })
for (const entry of entries) {
if (entry.endsWith(".shout")) {
files.push(resolve(abs, entry))
}
}
} catch {
// If not a directory, try as file anyway
if (abs.endsWith(".shout")) files.push(abs)
}
}
return files.sort()
}
program
.name("shout")
.description("Transcript-based shell integration test runner")
.argument("[files...]", "Files or directories to test")
.option("-u, --update", "Rewrite expected output in-place with actual output")
.option("-k, --keep", "Keep temp directories after run")
.option("--clean-env", "Start with empty environment")
.option("--bin <path>", "Prepend <path> to PATH")
.option("--timeout <dur>", "Per-command timeout", "10s")
.option("-v, --verbose", "Print each command as it runs")
.option("--parallel", "Run files in parallel")
.action(async (fileArgs: string[], opts) => {
const timeoutMs = parseDuration(opts.timeout)
const paths = fileArgs.length > 0 ? fileArgs : ["."]
const files = await findShoutFiles(paths)
if (files.length === 0) {
console.error("No .shout files found")
process.exit(1)
}
const start = performance.now()
const results: TestResult[] = []
const cwd = process.cwd()
const runOne = async (filePath: string) => {
const content = await readFile(filePath, "utf-8")
const parsed = parse(relative(cwd, filePath), content)
const fileResult = await runFile(parsed, {
cleanEnv: opts.cleanEnv ?? false,
binPath: opts.bin,
timeout: timeoutMs,
verbose: opts.verbose ?? false,
onCommand: opts.verbose
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
: undefined,
})
const testResult = evaluateFile(
parsed.path,
fileResult.results,
fileResult.error,
)
if (opts.update && fileResult.results.length > 0) {
const updated = rewriteFile(parsed, fileResult.results, content)
if (updated !== content) {
await writeFile(filePath, updated)
}
}
if (opts.keep) {
process.stderr.write(`${fileResult.tmpDir}\n`)
} else {
await cleanupTmpDir(fileResult.tmpDir)
}
return testResult
}
if (opts.parallel) {
const all = await Promise.all(files.map(runOne))
for (const r of all) {
if (r.passed) {
process.stdout.write(ansis.green("."))
} else {
process.stdout.write(ansis.red("F"))
}
results.push(r)
}
process.stdout.write("\n")
} else {
for (const filePath of files) {
const r = await runOne(filePath)
if (r.passed) {
process.stdout.write(ansis.green("."))
} else {
process.stdout.write(ansis.red("F"))
}
results.push(r)
}
process.stdout.write("\n")
}
// Print failures
const failures = results.filter(r => !r.passed)
if (failures.length > 0) {
console.log()
for (const f of failures) {
console.log(formatFailure(f))
console.log()
}
}
const elapsed = performance.now() - start
console.log(formatSummary(results, elapsed))
process.exit(failures.length > 0 ? 1 : 0)
})
program.parse()

27
src/duration.test.ts Normal file
View File

@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { parseDuration } from "./duration.ts"
describe("parseDuration", () => {
test("milliseconds", () => {
expect(parseDuration("500ms")).toBe(500)
})
test("seconds", () => {
expect(parseDuration("10s")).toBe(10000)
})
test("minutes", () => {
expect(parseDuration("1m")).toBe(60000)
})
test("fractional seconds", () => {
expect(parseDuration("1.5s")).toBe(1500)
})
test("invalid throws", () => {
expect(() => parseDuration("abc")).toThrow()
expect(() => parseDuration("10")).toThrow()
expect(() => parseDuration("10h")).toThrow()
})
})

14
src/duration.ts Normal file
View File

@ -0,0 +1,14 @@
export function parseDuration(s: string): number {
const match = s.match(/^(\d+(?:\.\d+)?)(ms|s|m)$/)
if (!match) throw new Error(`Invalid duration: ${s}`)
const value = parseFloat(match[1]!)
const unit = match[2]!
switch (unit) {
case "ms": return value
case "s": return value * 1000
case "m": return value * 60_000
default: throw new Error(`Unknown unit: ${unit}`)
}
}

119
src/format.ts Normal file
View File

@ -0,0 +1,119 @@
import ansis from "ansis"
import type { CommandResult } from "./run.ts"
import type { DiffLine } from "./match.ts"
import { diff, matchOutput } from "./match.ts"
export type TestResult = {
path: string
passed: boolean
failures: FailedCommand[]
error?: string
}
type FailedCommand = {
result: CommandResult
diffLines: DiffLine[]
exitCodeMismatch: boolean
}
export function evaluateFile(
path: string,
results: CommandResult[],
error?: string,
): TestResult {
if (error) {
return { path, passed: false, failures: [], error }
}
const failures: FailedCommand[] = []
for (const result of results) {
const { command, actual, exitCode } = result
const outputMatches = matchOutput(command.expected, actual)
let exitCodeMismatch = false
if (command.exitCode === null) {
// Expect exit code 0
exitCodeMismatch = exitCode !== 0
} else if (command.exitCode === "*") {
// Expect any non-zero
exitCodeMismatch = exitCode === 0
} else {
// Expect specific code
exitCodeMismatch = exitCode !== command.exitCode
}
if (!outputMatches || exitCodeMismatch) {
failures.push({
result,
diffLines: outputMatches ? [] : diff(command.expected, actual),
exitCodeMismatch,
})
}
}
return { path, passed: failures.length === 0, failures }
}
export function formatFailure(test: TestResult): string {
const lines: string[] = []
lines.push(ansis.red(`FAIL ${test.path}`))
if (test.error) {
lines.push(` ${ansis.red(test.error)}`)
return lines.join("\n")
}
for (const failure of test.failures) {
lines.push("")
lines.push(` ${ansis.dim("$")} ${failure.result.command.command}`)
for (const dl of failure.diffLines) {
switch (dl.kind) {
case "equal":
lines.push(` ${dl.text}`)
break
case "expected":
lines.push(ansis.red(` - ${dl.text}`))
break
case "actual":
lines.push(ansis.green(` + ${dl.text}`))
break
case "context":
lines.push(ansis.dim(` ${dl.text}`))
break
}
}
if (failure.exitCodeMismatch) {
const expected = failure.result.command.exitCode ?? 0
const actual = failure.result.exitCode
lines.push(
ansis.red(` - exit code: ${expected === "*" ? "non-zero" : expected}`),
)
lines.push(ansis.green(` + exit code: ${actual}`))
}
}
return lines.join("\n")
}
export function formatSummary(
results: TestResult[],
elapsed: number,
): string {
const passed = results.filter(r => r.passed).length
const failed = results.filter(r => !r.passed).length
const parts: string[] = []
if (passed > 0) parts.push(ansis.green(`${passed} passed`))
if (failed > 0) parts.push(ansis.red(`${failed} failed`))
const time = elapsed < 1000
? `${Math.round(elapsed)}ms`
: `${(elapsed / 1000).toFixed(1)}s`
return `${parts.join(", ")} in ${time}`
}

11
src/index.ts Normal file
View File

@ -0,0 +1,11 @@
export type { Command, ShoutFile } from "./parse.ts"
export type { CommandResult, FileResult } from "./run.ts"
export type { DiffLine } from "./match.ts"
export type { TestResult } from "./format.ts"
export { parse } from "./parse.ts"
export { runFile, cleanupTmpDir } from "./run.ts"
export { matchLine, matchOutput, diff } from "./match.ts"
export { evaluateFile, formatFailure, formatSummary } from "./format.ts"
export { parseDuration } from "./duration.ts"
export { rewriteFile } from "./update.ts"

71
src/match.test.ts Normal file
View File

@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { matchLine, matchOutput } from "./match.ts"
describe("matchLine", () => {
test("exact match", () => {
expect(matchLine("hello", "hello")).toBe(true)
})
test("exact mismatch", () => {
expect(matchLine("hello", "world")).toBe(false)
})
test("inline wildcard", () => {
expect(matchLine("draft 1 (v...)", "draft 1 (v2)")).toBe(true)
expect(matchLine("draft 1 (v...)", "draft 1 (v123)")).toBe(true)
})
test("wildcard at start", () => {
expect(matchLine("...world", "hello world")).toBe(true)
})
test("wildcard at end", () => {
expect(matchLine("hello...", "hello world")).toBe(true)
})
test("multiple inline wildcards", () => {
expect(matchLine("a...b...c", "aXXbYYc")).toBe(true)
})
})
describe("matchOutput", () => {
test("exact match", () => {
expect(matchOutput(["hello"], ["hello"])).toBe(true)
})
test("mismatch", () => {
expect(matchOutput(["hello"], ["world"])).toBe(false)
})
test("multiline wildcard matches zero lines", () => {
expect(matchOutput(["...", "end"], ["end"])).toBe(true)
})
test("multiline wildcard matches multiple lines", () => {
expect(matchOutput(["...", "end"], ["a", "b", "end"])).toBe(true)
})
test("multiline wildcard at end", () => {
expect(matchOutput(["start", "..."], ["start", "a", "b"])).toBe(true)
})
test("multiline wildcard in middle", () => {
expect(
matchOutput(["first", "...", "last"], ["first", "a", "b", "last"]),
).toBe(true)
})
test("empty expected matches empty actual", () => {
expect(matchOutput([], [])).toBe(true)
})
test("empty expected does not match non-empty actual", () => {
expect(matchOutput([], ["something"])).toBe(false)
})
test("multiline wildcard alone matches anything", () => {
expect(matchOutput(["..."], ["a", "b", "c"])).toBe(true)
expect(matchOutput(["..."], [])).toBe(true)
})
})

105
src/match.ts Normal file
View File

@ -0,0 +1,105 @@
export function matchLine(pattern: string, actual: string): boolean {
if (!pattern.includes("...")) return pattern === actual
// Convert inline ... to regex
const parts = pattern.split("...")
const escaped = parts.map(p => escapeRegex(p))
const regex = new RegExp("^" + escaped.join(".*") + "$")
return regex.test(actual)
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
export function matchOutput(
expected: string[],
actual: string[],
): boolean {
return doMatch(expected, 0, actual, 0)
}
function doMatch(
expected: string[],
ei: number,
actual: string[],
ai: number,
): boolean {
// Both exhausted — match
if (ei === expected.length && ai === actual.length) return true
// Expected exhausted but actual remains — no match
if (ei === expected.length) return false
const exp = expected[ei]!
// Multi-line wildcard
if (exp === "...") {
// Try matching zero or more actual lines
for (let skip = ai; skip <= actual.length; skip++) {
if (doMatch(expected, ei + 1, actual, skip)) return true
}
return false
}
// Actual exhausted but expected remains — no match
if (ai === actual.length) return false
// Line-level match (with possible inline wildcards)
if (matchLine(exp, actual[ai]!)) {
return doMatch(expected, ei + 1, actual, ai + 1)
}
return false
}
export type DiffLine = {
kind: "equal" | "expected" | "actual" | "context"
text: string
}
export function diff(expected: string[], actual: string[]): DiffLine[] {
const result: DiffLine[] = []
let ei = 0
let ai = 0
while (ei < expected.length || ai < actual.length) {
if (ei < expected.length && expected[ei] === "...") {
// Find where the wildcard ends by looking at next expected line
const nextExp = ei + 1 < expected.length ? expected[ei + 1] : null
if (nextExp === null) {
// ... at end matches everything remaining
result.push({ kind: "context", text: "..." })
break
}
// Skip actual lines until we find the next expected match
result.push({ kind: "context", text: "..." })
ei++
while (ai < actual.length && !matchLine(nextExp!, actual[ai]!)) {
ai++
}
continue
}
if (ei < expected.length && ai < actual.length) {
if (matchLine(expected[ei]!, actual[ai]!)) {
result.push({ kind: "equal", text: actual[ai]! })
ei++
ai++
} else {
result.push({ kind: "expected", text: expected[ei]! })
result.push({ kind: "actual", text: actual[ai]! })
ei++
ai++
}
} else if (ei < expected.length) {
result.push({ kind: "expected", text: expected[ei]! })
ei++
} else {
result.push({ kind: "actual", text: actual[ai]! })
ai++
}
}
return result
}

69
src/parse.test.ts Normal file
View File

@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import { parse } from "./parse.ts"
describe("parse", () => {
test("simple command with output", () => {
const result = parse("test.shout", "$ echo hello\nhello\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("echo hello")
expect(result.commands[0]!.expected).toEqual(["hello"])
expect(result.commands[0]!.exitCode).toBeNull()
})
test("multiple commands", () => {
const content = "$ echo one\none\n\n$ echo two\ntwo\n"
const result = parse("test.shout", content)
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual(["one"])
expect(result.commands[1]!.expected).toEqual(["two"])
})
test("command with no expected output", () => {
const result = parse("test.shout", "$ export FOO=bar\n$ echo $FOO\nbar\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual([])
expect(result.commands[1]!.expected).toEqual(["bar"])
})
test("strips trailing comment from command", () => {
const result = parse("test.shout", '$ echo hello # a comment\nhello\n')
expect(result.commands[0]!.command).toBe("echo hello")
expect(result.commands[0]!.raw).toBe("$ echo hello # a comment")
})
test("preserves # inside quotes", () => {
const result = parse("test.shout", '$ echo "keep # this"\nkeep # this\n')
expect(result.commands[0]!.command).toBe('echo "keep # this"')
})
test("exit code [N]", () => {
const result = parse("test.shout", "$ false\n[1]\n")
expect(result.commands[0]!.exitCode).toBe(1)
expect(result.commands[0]!.expected).toEqual([])
})
test("exit code [*]", () => {
const result = parse("test.shout", "$ false\noops\n[*]\n")
expect(result.commands[0]!.exitCode).toBe("*")
expect(result.commands[0]!.expected).toEqual(["oops"])
})
test("exit code [42] with output", () => {
const result = parse("test.shout", "$ sh -c 'echo err && exit 42'\nerr\n[42]\n")
expect(result.commands[0]!.exitCode).toBe(42)
expect(result.commands[0]!.expected).toEqual(["err"])
})
test("blank lines in expected output", () => {
const content = '$ echo -e "a\\n\\nb"\na\n\nb\n'
const result = parse("test.shout", content)
expect(result.commands[0]!.expected).toEqual(["a", "", "b"])
})
test("trailing newline ignored", () => {
const a = parse("test.shout", "$ echo hi\nhi\n")
const b = parse("test.shout", "$ echo hi\nhi")
expect(a.commands[0]!.expected).toEqual(b.commands[0]!.expected)
})
})

96
src/parse.ts Normal file
View File

@ -0,0 +1,96 @@
export type Command = {
line: number
raw: string
command: string
expected: string[]
exitCode: number | "*" | null
}
export type ShoutFile = {
path: string
commands: Command[]
}
function stripComment(line: string): string {
// Strip trailing # comment from command line
// Be careful not to strip # inside quotes
let inSingle = false
let inDouble = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === "'" && !inDouble) inSingle = !inSingle
else if (ch === '"' && !inSingle) inDouble = !inDouble
else if (ch === "#" && !inSingle && !inDouble) {
return line.slice(0, i).trimEnd()
}
}
return line
}
function parseExitCode(lines: string[]): {
lines: string[]
exitCode: number | "*" | null
} {
if (lines.length === 0) return { lines, exitCode: null }
const last = lines[lines.length - 1]!
const match = last.match(/^\[(\d+|\*)\]$/)
if (match) {
const code = match[1] === "*" ? "*" as const : parseInt(match[1]!, 10)
return { lines: lines.slice(0, -1), exitCode: code }
}
return { lines, exitCode: null }
}
function trimTrailingEmpty(lines: string[]): string[] {
let end = lines.length
while (end > 0 && lines[end - 1] === "") end--
return lines.slice(0, end)
}
export function parse(path: string, content: string): ShoutFile {
const rawLines = content.split("\n")
// Remove trailing newline (spec: "Trailing newline on the file is ignored")
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
rawLines.pop()
}
const commands: Command[] = []
let current: Command | null = null
for (let i = 0; i < rawLines.length; i++) {
const line = rawLines[i]!
if (line.startsWith("$ ")) {
if (current) {
const trimmed = trimTrailingEmpty(current.expected)
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
current.expected = trimTrailingEmpty(expectedLines)
current.exitCode = exitCode
commands.push(current)
}
current = {
line: i + 1,
raw: line,
command: stripComment(line.slice(2)),
expected: [],
exitCode: null,
}
} else if (current) {
current.expected.push(line)
}
}
if (current) {
const trimmed = trimTrailingEmpty(current.expected)
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
current.expected = trimTrailingEmpty(expectedLines)
current.exitCode = exitCode
commands.push(current)
}
return { path, commands }
}

208
src/run.ts Normal file
View File

@ -0,0 +1,208 @@
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { Command, ShoutFile } from "./parse.ts"
export type CommandResult = {
command: Command
actual: string[]
exitCode: number
}
export type FileResult = {
file: ShoutFile
results: CommandResult[]
tmpDir: string
error?: string
}
type RunOptions = {
cleanEnv: boolean
binPath?: string
timeout: number
verbose: boolean
onCommand?: (cmd: Command) => void
}
const SENTINEL_PREFIX = "__SHOUT_SENTINEL_"
function buildScript(commands: Command[], sentinel: string): string {
const lines: string[] = ["exec 2>&1"]
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
lines.push(cmd.command)
// Sentinel: printf to avoid echo interpretation issues
// Format: __SHOUT_SENTINEL_<exitcode>_<index>__
lines.push(
`printf '\\n${sentinel}%s_${i}__\\n' "$?"`,
)
}
return lines.join("\n") + "\n"
}
function parseSentinelOutput(
raw: string,
sentinel: string,
commandCount: number,
): { outputs: string[][]; exitCodes: number[] } {
const outputs: string[][] = []
const exitCodes: number[] = []
// Split by sentinel lines
const sentinelRegex = new RegExp(
`${escapeRegex(sentinel)}(\\d+)_(\\d+)__`,
)
let remaining = raw
for (let i = 0; i < commandCount; i++) {
const match = remaining.match(sentinelRegex)
if (!match) {
// No sentinel found — rest is output for this command
const lines = remaining.split("\n")
// Remove leading empty line (from printf \n prefix)
if (lines.length > 0 && lines[0] === "") lines.shift()
outputs.push(trimTrailingEmpty(lines))
exitCodes.push(1) // assume failure
break
}
const idx = remaining.indexOf(match[0])
const before = remaining.slice(0, idx)
const afterSentinel = remaining.slice(idx + match[0].length)
// Parse output lines
let lines = before.split("\n")
// Remove leading empty line from previous sentinel's trailing \n
if (lines.length > 0 && lines[0] === "") lines.shift()
// Remove trailing empty lines (from printf's \n prefix)
lines = trimTrailingEmpty(lines)
outputs.push(lines.length === 1 && lines[0] === "" ? [] : lines)
exitCodes.push(parseInt(match[1]!, 10))
// Skip past sentinel line (including trailing newline)
remaining = afterSentinel.startsWith("\n")
? afterSentinel.slice(1)
: afterSentinel
}
// Fill missing entries
while (outputs.length < commandCount) {
outputs.push([])
exitCodes.push(1)
}
return { outputs, exitCodes }
}
function trimTrailingEmpty(lines: string[]): string[] {
let end = lines.length
while (end > 0 && lines[end - 1] === "") end--
return lines.slice(0, end)
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
export async function runFile(
file: ShoutFile,
options: RunOptions,
): Promise<FileResult> {
const tmpDir = await mkdtemp(join(tmpdir(), "shout-"))
if (file.commands.length === 0) {
return { file, results: [], tmpDir }
}
const sentinel = SENTINEL_PREFIX
const script = buildScript(file.commands, sentinel)
const env: Record<string, string> = options.cleanEnv
? {}
: { ...process.env as Record<string, string> }
env["HOME"] = tmpDir
env["CUE_DIR"] = tmpDir
if (options.binPath) {
env["PATH"] = options.binPath + ":" + (env["PATH"] ?? "")
}
try {
const proc = Bun.spawn(["/bin/sh"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
cwd: tmpDir,
env,
})
proc.stdin.write(script)
proc.stdin.end()
const stdout = await readWithTimeout(proc.stdout, options.timeout * file.commands.length)
const stderr = await readWithTimeout(proc.stderr, 1000).catch(() => "")
await proc.exited
const { outputs, exitCodes } = parseSentinelOutput(
stdout,
sentinel,
file.commands.length,
)
const results: CommandResult[] = file.commands.map((cmd, i) => {
if (options.verbose && options.onCommand) {
options.onCommand(cmd)
}
return {
command: cmd,
actual: outputs[i] ?? [],
exitCode: exitCodes[i] ?? 1,
}
})
return { file, results, tmpDir }
} catch (err) {
return {
file,
results: [],
tmpDir,
error: err instanceof Error ? err.message : String(err),
}
}
}
async function readWithTimeout(
stream: ReadableStream<Uint8Array>,
timeoutMs: number,
): Promise<string> {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs),
)
try {
while (true) {
const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult<Uint8Array>
if (done) break
if (value) chunks.push(value)
}
} finally {
reader.releaseLock()
}
const decoder = new TextDecoder()
return chunks.map(c => decoder.decode(c, { stream: true })).join("") +
decoder.decode()
}
export async function cleanupTmpDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true })
}

83
src/update.ts Normal file
View File

@ -0,0 +1,83 @@
import type { CommandResult } from "./run.ts"
import type { ShoutFile } from "./parse.ts"
import { matchOutput, matchLine } from "./match.ts"
export function rewriteFile(
file: ShoutFile,
results: CommandResult[],
originalContent: string,
): string {
const lines = originalContent.split("\n")
const output: string[] = []
let cmdIdx = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!
if (line.startsWith("$ ")) {
// Emit the command line as-is
output.push(line)
const cmd = file.commands[cmdIdx]
const result = results[cmdIdx]
if (!cmd || !result) {
cmdIdx++
continue
}
// Skip past old expected output lines in the original
let j = i + 1
while (j < lines.length && !lines[j]!.startsWith("$ ")) {
j++
}
// Collect old expected lines (before trimming trailing blanks for separator)
const oldExpectedRaw = lines.slice(i + 1, j)
// Check if old expected output had an exit code marker
const oldTrimmed = trimTrailingEmpty(oldExpectedRaw)
let oldExitMarker: string | null = null
if (oldTrimmed.length > 0) {
const last = oldTrimmed[oldTrimmed.length - 1]!
if (/^\[(\d+|\*)\]$/.test(last)) {
oldExitMarker = last
}
}
// Determine how many trailing blank lines the original had
let trailingBlanks = 0
for (let k = oldExpectedRaw.length - 1; k >= 0; k--) {
if (oldExpectedRaw[k] === "") trailingBlanks++
else break
}
// If wildcards match, keep original expected output
if (matchOutput(cmd.expected, result.actual)) {
// Output original lines as-is
for (const ol of oldExpectedRaw) output.push(ol)
} else {
// Replace with actual output
for (const al of result.actual) output.push(al)
// Re-add exit code marker if it existed
if (oldExitMarker) output.push(oldExitMarker)
// Preserve trailing blank lines as separators
for (let k = 0; k < trailingBlanks; k++) output.push("")
}
// Skip original expected output lines (we already handled them)
i = j - 1
cmdIdx++
} else if (cmdIdx === 0) {
// Lines before first command (shouldn't normally exist but preserve them)
output.push(line)
}
}
return output.join("\n")
}
function trimTrailingEmpty(lines: string[]): string[] {
let end = lines.length
while (end > 0 && lines[end - 1] === "") end--
return lines.slice(0, end)
}

9
test/basic.shout Normal file
View File

@ -0,0 +1,9 @@
$ echo hello
hello
$ echo one && echo two
one
two
$ echo "working directory: $(basename $PWD)"
working directory: ...

5
test/comments.shout Normal file
View File

@ -0,0 +1,5 @@
$ echo hello # this is a comment
hello
$ echo "keep # this"
keep # this

28
test/features.shout Normal file
View File

@ -0,0 +1,28 @@
$ echo "test exit codes"
test exit codes
$ false
[1]
$ sh -c "exit 42"
[42]
$ sh -c "echo oops && exit 1"
oops
[*]
$ export MY_VAR=hello
$ echo $MY_VAR
hello
$ cd /tmp
$ pwd
/tmp
$ echo "line 1" && echo "" && echo "line 3"
line 1
line 3
$ echo "match ..."
match ...