From 17268b50f08a178ee75ae815607205384d9f8d52 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 9 Mar 2026 21:30:17 -0700 Subject: [PATCH] Add test runner core with CLI and spec updates --- SPEC.md | 34 +++---- src/cli/index.ts | 147 ++++++++++++++++++++++++++++++ src/duration.test.ts | 27 ++++++ src/duration.ts | 14 +++ src/format.ts | 119 +++++++++++++++++++++++++ src/index.ts | 11 +++ src/match.test.ts | 71 +++++++++++++++ src/match.ts | 105 ++++++++++++++++++++++ src/parse.test.ts | 69 ++++++++++++++ src/parse.ts | 96 ++++++++++++++++++++ src/run.ts | 208 +++++++++++++++++++++++++++++++++++++++++++ src/update.ts | 83 +++++++++++++++++ test/basic.shout | 9 ++ test/comments.shout | 5 ++ test/features.shout | 28 ++++++ 15 files changed, 1003 insertions(+), 23 deletions(-) create mode 100755 src/cli/index.ts create mode 100644 src/duration.test.ts create mode 100644 src/duration.ts create mode 100644 src/format.ts create mode 100644 src/index.ts create mode 100644 src/match.test.ts create mode 100644 src/match.ts create mode 100644 src/parse.test.ts create mode 100644 src/parse.ts create mode 100644 src/run.ts create mode 100644 src/update.ts create mode 100644 test/basic.shout create mode 100644 test/comments.shout create mode 100644 test/features.shout diff --git a/SPEC.md b/SPEC.md index cf7737b..7494ce1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 ` | Prepend `` to `PATH` instead of auto-detecting | -| `--timeout ` | Per-command timeout (default: `10s`) | +| `--bin ` | Prepend `` to `PATH` | +| `--timeout ` | 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 --- diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100755 index 0000000..6cfc7a7 --- /dev/null +++ b/src/cli/index.ts @@ -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 { + 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 ", "Prepend to PATH") + .option("--timeout ", "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() diff --git a/src/duration.test.ts b/src/duration.test.ts new file mode 100644 index 0000000..d8a26f6 --- /dev/null +++ b/src/duration.test.ts @@ -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() + }) +}) diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..f0b332d --- /dev/null +++ b/src/duration.ts @@ -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}`) + } +} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..652c91b --- /dev/null +++ b/src/format.ts @@ -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}` +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..107ee68 --- /dev/null +++ b/src/index.ts @@ -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" diff --git a/src/match.test.ts b/src/match.test.ts new file mode 100644 index 0000000..6362519 --- /dev/null +++ b/src/match.test.ts @@ -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) + }) +}) diff --git a/src/match.ts b/src/match.ts new file mode 100644 index 0000000..c846630 --- /dev/null +++ b/src/match.ts @@ -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 +} diff --git a/src/parse.test.ts b/src/parse.test.ts new file mode 100644 index 0000000..87712ec --- /dev/null +++ b/src/parse.test.ts @@ -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) + }) +}) diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..d204ca4 --- /dev/null +++ b/src/parse.ts @@ -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 } +} diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..23d1850 --- /dev/null +++ b/src/run.ts @@ -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____ + 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 { + 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 = options.cleanEnv + ? {} + : { ...process.env as Record } + + 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, + timeoutMs: number, +): Promise { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs), + ) + + try { + while (true) { + const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult + 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 { + await rm(dir, { recursive: true, force: true }) +} diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..e1ac545 --- /dev/null +++ b/src/update.ts @@ -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) +} diff --git a/test/basic.shout b/test/basic.shout new file mode 100644 index 0000000..2d759ed --- /dev/null +++ b/test/basic.shout @@ -0,0 +1,9 @@ +$ echo hello +hello + +$ echo one && echo two +one +two + +$ echo "working directory: $(basename $PWD)" +working directory: ... diff --git a/test/comments.shout b/test/comments.shout new file mode 100644 index 0000000..54fe5eb --- /dev/null +++ b/test/comments.shout @@ -0,0 +1,5 @@ +$ echo hello # this is a comment +hello + +$ echo "keep # this" +keep # this diff --git a/test/features.shout b/test/features.shout new file mode 100644 index 0000000..92eed91 --- /dev/null +++ b/test/features.shout @@ -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 ...