This commit is contained in:
Chris Wanstrath 2026-04-02 15:18:22 -07:00
parent 4f42cd4887
commit db5a7d66e6
38 changed files with 1 additions and 2055 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
target/
.sandlot/ .sandlot/
.dev/ .dev/

View File

@ -1,57 +0,0 @@
# Stdin / Prompt Support
## The challenge
Currently, the shell's stdin is consumed by the script itself (`buildScript` writes all commands at once, then `proc.stdin.end()`). There's no channel left to feed stdin to individual commands.
## Simplest design: heredoc injection
Add a `< ` prefix for stdin lines in `.shout` files. At script-build time, transform the command into a heredoc pipe. No runtime changes needed.
**Syntax:**
```
$ read -p "Name: " name && echo "Hello, $name"
< Chris
Name: Hello, Chris
$ cat
< line one
< line two
line one
line two
$ grep -c foo
< foo bar
< baz
< foo baz
2
```
**Implementation:** In `parse.ts`, collect `< ` lines as a new `stdin: string[]` field on `Command`. In `buildScript`, when `cmd.stdin` is non-empty, wrap the command:
```sh
# instead of:
some_command
# generate:
some_command <<'__SHOUT_STDIN__'
line one
line two
__SHOUT_STDIN__
```
This is ~30 lines of change across parse + run, and fits cleanly into the existing architecture.
## What it wouldn't handle
- **Interactive prompts with branching** (send input, read output, decide next input) — would require per-command execution, a much bigger refactor
- **Timing-sensitive input** (send after a delay) — same issue
- **Binary stdin** — heredocs are text-only
## Alternative: named pipes (more complex)
For each command needing stdin, create a FIFO in the temp dir and redirect from it. Shout would write to the FIFO from the Node side at the right time. This could support interactive flows but adds significant complexity to the sentinel-based output parsing — you'd need to synchronize "when to write the next stdin chunk."
## Recommendation
The heredoc approach covers the 90% case (testing CLIs that read input, `cat`, `read`, piped filters) with minimal change.

View File

@ -1,38 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "shout",
"dependencies": {
"ansis": "*",
"commander": "14.0.3",
"diff": "^8.0.3",
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.4.0", "https://npm.nose.space/@types/node/-/node-25.4.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
"ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@ -1,31 +0,0 @@
{
"name": "@because/shout",
"version": "0.0.18",
"description": "shell output tester",
"module": "src/index.ts",
"type": "module",
"files": [
"src"
],
"exports": {
".": "./src/index.ts"
},
"bin": {
"shout": "src/cli/index.ts"
},
"scripts": {
"check": "bunx tsc --noEmit",
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/shout",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
"typescript": "^5.9.3"
},
"dependencies": {
"commander": "14.0.3",
"diff": "^8.0.3",
"ansis": "*"
}
}

1
shout-rs/.gitignore vendored
View File

@ -1 +0,0 @@
target/

113
shout-rs/Cargo.lock generated
View File

@ -1,113 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "shout"
version = "0.0.18"
dependencies = [
"libc",
"rayon",
"regex",
]

View File

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

View File

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

View File

@ -1,9 +0,0 @@
$ echo '$ hello world'
\$ hello world
$ printf '$ line one\n$ line two\n'
\$ line one
\$ line two
$ echo 'no dollar here'
no dollar here

View File

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

View File

@ -1,28 +0,0 @@
$ 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 ...

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export READY=yes
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.cleanup.db"

View File

@ -1,4 +0,0 @@
@setup teardown-setup.shout
$ touch marker.txt && ls marker.txt
marker.txt

View File

@ -1,320 +0,0 @@
#!/usr/bin/env bun
import { readdir, readFile, writeFile } from "node:fs/promises"
import { resolve, relative, dirname } from "node:path"
import { program } from "commander"
import ansis from "ansis"
import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts"
import { runFile, cleanupTmpDir, type CommandResult } from "../run.ts"
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
import type { TestResult } from "../format.ts"
import { matchOutput } from "../match.ts"
import { parseDuration } from "../duration.ts"
import { rewriteFile } from "../update.ts"
async function filterGitignored(files: string[]): Promise<string[]> {
if (files.length === 0) return files
try {
const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
stdin: new Blob([files.join("\n")]),
stdout: "pipe",
stderr: "ignore",
})
const output = await new Response(proc.stdout).text()
await proc.exited
const ignored = new Set(output.trim().split("\n").filter(Boolean))
return files.filter(f => !ignored.has(f))
} catch {
return files
}
}
async function findShoutFiles(paths: string[]): Promise<string[]> {
const explicit: string[] = []
const discovered: 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")) {
explicit.push(abs)
continue
}
// Try as directory
try {
const entries = await readdir(abs, { recursive: true })
for (const entry of entries) {
if (entry.endsWith(".shout")) {
discovered.push(resolve(abs, entry))
}
}
} catch {
// If not a directory, try as file anyway
if (abs.endsWith(".shout")) explicit.push(abs)
}
}
const filtered = await filterGitignored(discovered)
return [...explicit, ...filtered].sort()
}
import pkg from "../../package.json"
program
.name("shout")
.description("$ shell output tester")
.version(pkg.version)
program
.command("test")
.description("Run .shout test files")
.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("--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>", "5400")
.option("-t, --filter <pattern>", "Only run files matching <pattern> (substring match)")
.option("--parallel", "Run files in parallel")
.action(async (fileArgs: string[], opts) => {
const timeoutMs = parseDuration(opts.timeout)
const paths = fileArgs.length > 0 ? fileArgs : ["."]
let files = await findShoutFiles(paths)
const start = performance.now()
const results: TestResult[] = []
const cwd = process.cwd()
if (opts.filter) {
const pattern = opts.filter
files = files.filter(f => relative(cwd, f).includes(pattern))
}
if (files.length === 0) {
console.error(opts.filter ? `No .shout files matching "${opts.filter}"` : "No .shout files found")
process.exit(1)
}
const portFrom = parseInt(opts.portFrom, 10)
if (Number.isNaN(portFrom)) {
console.error("--port-from must be an integer")
process.exit(1)
}
let nextPort = portFrom
const runOne = async (filePath: string, port: number) => {
const content = await readFile(filePath, "utf-8")
const parsed = parse(relative(cwd, filePath), content)
// 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[] = []
const teardownCommands: Command[] = [...parsed.teardownCommands]
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 = parseSetup(relative(cwd, setupPath), setupContent)
for (const sd of setupParsed.directives) {
if (sd.type === "env") setupEnvVars[sd.key] = sd.value
}
setupCommands.push(...setupParsed.commands)
teardownCommands.push(...setupParsed.teardownCommands)
} else if (d.type === "env") {
userEnvVars[d.key] = d.value
}
}
Object.assign(envVars, setupEnvVars, userEnvVars)
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
envVars["PORT"] = String(port)
}
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] }
const setupLen = setupCommands.length
const userLen = parsed.commands.length
const printDot = (result: CommandResult) => {
const { command, actual, exitCode } = result
const outputMatches = matchOutput(command.expected, actual)
let exitCodeMismatch = false
if (command.exitCode === null) {
exitCodeMismatch = exitCode !== 0
} else if (command.exitCode === "*") {
exitCodeMismatch = exitCode === 0
} else {
exitCodeMismatch = exitCode !== command.exitCode
}
if (outputMatches && !exitCodeMismatch) {
process.stdout.write(ansis.green("."))
} else {
process.stdout.write(ansis.red("F"))
}
}
const fileResult = await runFile(merged, {
cleanEnv: opts.cleanEnv ?? false,
pathDirs: opts.path,
envVars,
sourceDir: resolve(dirname(filePath)),
projectDir: cwd,
timeout: timeoutMs,
onCommand: opts.verbose
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
: undefined,
onCommandResult: (index, result) => {
if (index >= setupLen && index < setupLen + userLen) {
printDot(result)
}
},
})
// 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,
setupCommands.length + parsed.commands.length,
)
// Warn on teardown failures
const teardownResults = fileResult.results.slice(setupCommands.length + parsed.commands.length)
for (let i = 0; i < teardownResults.length; i++) {
const r = teardownResults[i]
if (r && r.exitCode !== 0) {
process.stderr.write(
ansis.yellow(`warning: teardown command failed (exit ${r.exitCode}): $ ${teardownCommands[i]!.command}\n`),
)
}
}
const testResult = evaluateFile(
parsed.path,
fileOwnResults,
fileResult.error,
)
if (opts.update && fileOwnResults.length > 0) {
const updated = rewriteFile(parsed, fileOwnResults, content)
if (updated !== content) {
await writeFile(filePath, updated)
}
}
if (opts.keep) {
process.stderr.write(`${fileResult.tmpDir}\n`)
} else {
await cleanupTmpDir(fileResult.tmpDir)
}
return testResult
}
const printErrorDot = (r: TestResult) => {
if (r.error) {
process.stdout.write(ansis.red("F"))
}
}
if (opts.parallel) {
const promises = files.map(async f => {
const r = await runOne(f, nextPort++)
printErrorDot(r)
return r
})
results.push(...await Promise.all(promises))
process.stdout.write("\n")
} else {
for (const filePath of files) {
const r = await runOne(filePath, nextPort++)
printErrorDot(r)
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
const singleFile = files.length === 1 ? relative(cwd, files[0]) : undefined
console.log(formatSummary(results, elapsed, singleFile))
process.exit(failures.length > 0 ? 1 : 0)
})
program
.command("version")
.description("Print the version")
.action(() => {
console.log(pkg.version)
})
program
.command("example")
.description("Print an example .shout file")
.action(() => {
console.log(`# Example .shout file
$ echo hello
hello
$ echo "one"; echo "two"; echo "three"
one
...
three
$ cat nonexistent
cat: nonexistent: ...
[1]
$ true
[0]`)
})
program
.command("upgrade")
.description("Upgrade to the latest version")
.action(async () => {
const result = await Bun.spawn(["bun", "install", "-g", "@because/shout@latest"], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).exited
process.exit(result)
})
program.parse()

View File

@ -1,27 +0,0 @@
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()
})
})

View File

@ -1,14 +0,0 @@
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}`)
}
}

View File

@ -1,124 +0,0 @@
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
commandCount: number
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, commandCount: results.length, 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, commandCount: results.length, 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}`)
if (failure.diffLines.length > 0) {
const expectedLines: string[] = []
const actualLines: string[] = []
for (const dl of failure.diffLines) {
const text = dl.kind === "context" ? ansis.dim(dl.text) : dl.text
if (dl.kind === "expected" || dl.kind === "equal" || dl.kind === "context") {
const prefix = dl.kind === "expected" ? ansis.green(" > ") : " "
expectedLines.push(`${prefix}${text}`)
}
if (dl.kind === "actual" || dl.kind === "equal" || dl.kind === "context") {
const prefix = dl.kind === "actual" ? ansis.red(" > ") : " "
actualLines.push(`${prefix}${text}`)
}
}
lines.push(ansis.green(" expected:"), ...expectedLines)
lines.push(ansis.red(" actual:"), ...actualLines)
}
if (failure.exitCodeMismatch) {
const expected = failure.result.command.exitCode ?? 0
const actual = failure.result.exitCode
lines.push(
ansis.green(` expected exit code: ${expected === "*" ? "non-zero" : expected}`),
)
lines.push(ansis.red(` actual exit code: ${actual}`))
}
}
return lines.join("\n")
}
export function formatSummary(
results: TestResult[],
elapsed: number,
singleFile?: string,
): string {
const totalCommands = results.reduce((n, r) => n + r.commandCount, 0)
const failedCommands = results.reduce((n, r) => n + r.failures.length, 0)
const passedCommands = totalCommands - failedCommands
const parts: string[] = []
if (passedCommands > 0) parts.push(ansis.green(`${passedCommands} passed`))
if (failedCommands > 0) parts.push(ansis.red(`${failedCommands} failed`))
const time = elapsed < 1000
? `${Math.round(elapsed)}ms`
: `${(elapsed / 1000).toFixed(1)}s`
const label = singleFile ? ` in ${singleFile}` : ""
return `${parts.join(", ")}${label} ${ansis.dim(`[${time}]`)}`
}

View File

@ -1,11 +0,0 @@
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"
export { parse, parseSetup } 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"

View File

@ -1,71 +0,0 @@
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)
})
})

View File

@ -1,103 +0,0 @@
import { escapeRegex } from "./utils.ts"
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)
}
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
}

View File

@ -1,255 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parse, parseSetup } 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)
})
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("escaped dollar sign in expected output", () => {
const content = "$ echo '$ hello'\n\\$ hello\n"
const result = parse("test.shout", content)
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["$ hello"])
})
test("multiple escaped dollar signs", () => {
const content = "$ printf '$ a\\n$ b\\n'\n\\$ a\n\\$ b\n"
const result = parse("test.shout", content)
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["$ a", "$ b"])
})
test("escaped dollar before first command is ignored", () => {
const content = "\\$ not a command\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
// \$ before any command — no current command to attach to, so skipped
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["hi"])
})
test("$# comment line is skipped", () => {
const result = parse("test.shout", "$# start the server\n$ echo hi\nhi\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("echo hi")
})
test("$# comment between commands", () => {
const result = parse("test.shout", "$ echo one\none\n$# now do two\n$ echo two\ntwo\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual(["one"])
expect(result.commands[1]!.expected).toEqual(["two"])
})
test("$# comment with space after hash", () => {
const result = parse("test.shout", "$ # server setup\n$ echo hi\nhi\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("echo hi")
})
test("$# comment as last line", () => {
const result = parse("test.shout", "$ echo hi\nhi\n$# done\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.expected).toEqual(["hi"])
})
test("output after $# comment is ignored", () => {
const result = parse("test.shout", "$ echo hi\nhi\n$# comment\nstray line\n$ echo bye\nbye\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.expected).toEqual(["hi"])
expect(result.commands[1]!.expected).toEqual(["bye"])
})
test("no directives returns empty array", () => {
const result = parse("test.shout", "$ echo hi\nhi\n")
expect(result.directives).toEqual([])
})
test("@teardown in .shout file", () => {
const result = parse("test.shout", "@teardown rm -f /tmp/test.db\n$ echo hi\nhi\n")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
expect(result.commands).toHaveLength(1)
})
test("@teardown with @setup in .shout file", () => {
const content = "@setup setup.shout\n@teardown rm -f /tmp/test.db\n@env PORT=3000\n$ echo hi\nhi\n"
const result = parse("test.shout", content)
expect(result.directives).toHaveLength(2)
expect(result.directives[0]!.type).toBe("setup")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("unknown directive throws", () => {
expect(() => parse("test.shout", "@evn PORT=3000\n$ echo hi\n")).toThrow(
"test.shout:1: unknown directive: @evn PORT=3000",
)
})
})
describe("parseSetup", () => {
test("plain commands without $ prefix", () => {
const result = parseSetup("setup.shout", "export FOO=bar\necho hello\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.commands[1]!.command).toBe("echo hello")
})
test("@env directives", () => {
const result = parseSetup("setup.shout", "@env PORT=3000\nexport FOO=bar\n")
expect(result.directives).toEqual([
{ type: "env", key: "PORT", value: "3000", line: 1 },
])
expect(result.commands).toHaveLength(1)
})
test("blank lines and comments are ignored", () => {
const result = parseSetup("setup.shout", "# set up env\nexport FOO=bar\n\nexport BAZ=qux\n")
expect(result.commands).toHaveLength(2)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.commands[1]!.command).toBe("export BAZ=qux")
})
test("strips trailing comments from commands", () => {
const result = parseSetup("setup.shout", "export FOO=bar # set foo\n")
expect(result.commands[0]!.command).toBe("export FOO=bar")
})
test("@setup in setup file throws", () => {
expect(() => parseSetup("setup.shout", "@setup other.shout\n")).toThrow(
"setup.shout:1: @setup not allowed in setup files",
)
})
test("@teardown commands in setup file", () => {
const result = parseSetup("setup.shout", "export FOO=bar\n@teardown rm -f /tmp/test.db\n")
expect(result.commands).toHaveLength(1)
expect(result.commands[0]!.command).toBe("export FOO=bar")
expect(result.teardownCommands).toHaveLength(1)
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("@teardown strips trailing comment", () => {
const result = parseSetup("setup.shout", "@teardown rm -f /tmp/test.db # cleanup\n")
expect(result.teardownCommands[0]!.command).toBe("rm -f /tmp/test.db")
})
test("@teardown with empty command throws", () => {
expect(() => parseSetup("setup.shout", "@teardown \n")).toThrow(
"setup.shout:1: @teardown requires a command",
)
})
test("multiple @teardown commands", () => {
const result = parseSetup("setup.shout", "@teardown rm -f a.db\n@teardown rm -f b.db\n")
expect(result.teardownCommands).toHaveLength(2)
expect(result.teardownCommands[0]!.command).toBe("rm -f a.db")
expect(result.teardownCommands[1]!.command).toBe("rm -f b.db")
})
test("commands have no expected output", () => {
const result = parseSetup("setup.shout", "echo hello\n")
expect(result.commands[0]!.expected).toEqual([])
expect(result.commands[0]!.exitCode).toBeNull()
})
})

View File

@ -1,210 +0,0 @@
import { trimTrailingEmpty } from "./utils.ts"
export type Command = {
line: number
raw: string
command: string
expected: string[]
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[]
teardownCommands: 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
}
/** A line like "$# ..." or "$ # ..." — a comment, not a real command */
export function isCommentLine(line: string): boolean {
return line.startsWith("$#") || (line.startsWith("$ ") && stripComment(line.slice(2)) === "")
}
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 parseEnvDirective(path: string, line: string, lineNum: number): { key: string; value: string } {
const rest = line.slice(5).trim()
const eq = rest.indexOf("=")
if (eq <= 0) {
throw new Error(`${path}:${lineNum}: malformed @env directive (expected KEY=VALUE): ${line}`)
}
return { key: rest.slice(0, eq), value: rest.slice(eq + 1) }
}
export function parseSetup(path: string, content: string): ShoutFile {
const rawLines = content.split("\n")
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
rawLines.pop()
}
const commands: Command[] = []
const teardownCommands: Command[] = []
const directives: Directive[] = []
for (let i = 0; i < rawLines.length; i++) {
const line = rawLines[i]!
if (line === "" || line.startsWith("#")) continue
if (line.startsWith("@")) {
if (line.startsWith("@env ")) {
const { key, value } = parseEnvDirective(path, line, i + 1)
directives.push({ type: "env", key, value, line: i + 1 })
} else if (line.startsWith("@teardown ")) {
const cmd = line.slice(10)
if (!cmd.trim()) {
throw new Error(`${path}:${i + 1}: @teardown requires a command`)
}
teardownCommands.push({
line: i + 1,
raw: line,
command: stripComment(cmd),
expected: [],
exitCode: null,
})
} else if (line.startsWith("@setup ")) {
throw new Error(`${path}:${i + 1}: @setup not allowed in setup files`)
} else {
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
}
continue
}
commands.push({
line: i + 1,
raw: line,
command: stripComment(line),
expected: [],
exitCode: null,
})
}
return { path, commands, directives, teardownCommands }
}
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[] = []
const teardownCommands: 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 ")) {
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("@teardown ")) {
const cmd = line.slice(10)
if (!cmd.trim()) {
throw new Error(`${path}:${i + 1}: @teardown requires a command`)
}
teardownCommands.push({
line: i + 1,
raw: line,
command: stripComment(cmd),
expected: [],
exitCode: null,
})
} else if (line.startsWith("@env ")) {
const { key, value } = parseEnvDirective(path, line, i + 1)
directives.push({ type: "env", key, value, line: i + 1 })
} else {
throw new Error(`${path}:${i + 1}: unknown directive: ${line}`)
}
continue
}
if (isCommentLine(line)) {
// Comment line like "$# ..." or "$ # ..." — finalize current and skip
seenCommand = true
if (current) {
const trimmed = trimTrailingEmpty(current.expected)
const { lines: expectedLines, exitCode } = parseExitCode(trimmed)
current.expected = trimTrailingEmpty(expectedLines)
current.exitCode = exitCode
commands.push(current)
}
current = null
} else if (line.startsWith("\\$ ") && current) {
// Escaped dollar-space: literal expected output starting with "$ "
current.expected.push(line.slice(1))
} else if (line.startsWith("$ ")) {
seenCommand = true
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, directives, teardownCommands }
}

View File

@ -1,114 +0,0 @@
import { describe, expect, test } from "bun:test"
import { readFile } from "node:fs/promises"
import { runFile, cleanupTmpDir } from "./run.ts"
import type { ShoutFile } from "./parse.ts"
function makeFile(commands: { command: string; expected?: string[] }[]): ShoutFile {
return {
path: "test.shout",
directives: [],
commands: commands.map((c, i) => ({
line: i + 1,
raw: `$ ${c.command}`,
command: c.command,
expected: c.expected ?? [],
exitCode: null,
})),
}
}
const defaultOpts = {
cleanEnv: false,
timeout: 5000,
verbose: false,
}
async function isProcessRunning(pid: number): Promise<boolean> {
try {
const stat = await readFile(`/proc/${pid}/stat`, "utf-8")
// Find state after the comm field (which is wrapped in parens and may contain spaces)
const state = stat.charAt(stat.lastIndexOf(")") + 2)
return state !== "Z" && state !== "X"
} catch {
return false
}
}
describe("runFile", () => {
test("basic command", async () => {
const file = makeFile([{ command: "echo hello" }])
const result = await runFile(file, defaultOpts)
try {
expect(result.results[0]?.actual).toEqual(["hello"])
expect(result.results[0]?.exitCode).toBe(0)
} finally {
await cleanupTmpDir(result.tmpDir)
}
})
test("cleans up backgrounded processes after exit", async () => {
const file = makeFile([
{ command: "sleep 300 >/dev/null 2>&1 & echo $!" },
])
const result = await runFile(file, defaultOpts)
try {
const pid = parseInt(result.results[0]?.actual[0] ?? "", 10)
expect(pid).toBeGreaterThan(0)
await new Promise(r => setTimeout(r, 100))
expect(await isProcessRunning(pid)).toBe(false)
} finally {
await cleanupTmpDir(result.tmpDir)
}
})
test("strips ANSI color codes from output", async () => {
const file = makeFile([
{ command: `printf '\\033[31mred\\033[0m and \\033[1;32mbold green\\033[0m'` },
])
const result = await runFile(file, defaultOpts)
try {
expect(result.results[0]?.actual).toEqual(["red and bold green"])
expect(result.results[0]?.exitCode).toBe(0)
} finally {
await cleanupTmpDir(result.tmpDir)
}
})
test("background process output does not leak into subsequent commands", async () => {
const file = makeFile([
// Start a background process that writes to stdout after a delay
{ command: "{ sleep 0.1; echo LEAKED; } &" },
// Wait long enough for the background output to appear
{ command: "sleep 0.3; echo clean" },
])
const result = await runFile(file, defaultOpts)
try {
// The background command itself should have no output
expect(result.results[0]?.actual).toEqual([])
// The sleep command should only see its own output, not the background "LEAKED"
expect(result.results[1]?.actual).toEqual(["clean"])
} finally {
await cleanupTmpDir(result.tmpDir)
}
})
test("cleans up multiple backgrounded processes", async () => {
const file = makeFile([
{ command: "sleep 300 >/dev/null 2>&1 & P1=$!; sleep 300 >/dev/null 2>&1 & P2=$!; echo $P1 $P2" },
])
const result = await runFile(file, defaultOpts)
try {
const pids = (result.results[0]?.actual[0] ?? "").split(" ").map(Number)
expect(pids).toHaveLength(2)
expect(pids[0]).toBeGreaterThan(0)
expect(pids[1]).toBeGreaterThan(0)
await new Promise(r => setTimeout(r, 100))
for (const pid of pids) {
expect(await isProcessRunning(pid)).toBe(false)
}
} finally {
await cleanupTmpDir(result.tmpDir)
}
})
})

View File

@ -1,372 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { Command, ShoutFile } from "./parse.ts"
import { trimTrailingEmpty } from "./utils.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
pathDirs?: string[]
envVars?: Record<string, string>
sourceDir?: string
projectDir?: string
timeout: number
verbose?: boolean
onCommand?: (cmd: Command) => void
onCommandResult?: (index: number, result: CommandResult) => void
}
function killTree(pid: number): void {
// Find any processes that escaped the process group (e.g. via setsid).
// This assumes pid === pgid, which holds because the child is spawned
// with detached: true (making it a process group leader).
try {
const result = Bun.spawnSync(["ps", "-eo", "pid,pgid"])
const output = result.stdout.toString()
const pgid = String(pid)
for (const line of output.split("\n")) {
const parts = line.trim().split(/\s+/)
if (parts[1] === pgid) {
const p = parseInt(parts[0]!, 10)
if (!isNaN(p) && p !== pid && p > 1) {
try { process.kill(p, "SIGKILL") } catch {}
}
}
}
} catch {}
// Kill the process group
try { process.kill(-pid, "SIGKILL") } catch {}
}
const SENTINEL_PREFIX = "__SHOUT_SENTINEL_"
const VERBOSE_MARKER = "__SHOUT_CMD_"
function buildScript(commands: Command[], sentinel: string, verbose: boolean): string {
const lines: string[] = []
if (verbose) {
// Save original stderr to fd 3 before merging stderr into stdout
// Save the pipe on fd 9 so we can restore after each command
lines.push("exec 3>&2 2>&1 9>&1")
} else {
lines.push("exec 2>&1 9>&1")
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
if (verbose) {
lines.push(`printf '${VERBOSE_MARKER}${i}\\n' >&3`)
}
// Redirect stdout+stderr to a temp file so background processes
// from previous commands can't pollute this command's output.
// Background processes keep their fd pointing at the old temp file,
// which becomes orphaned after rm — their output goes nowhere.
lines.push(`__shout_out=$(mktemp)`)
lines.push(`exec 1>"$__shout_out" 2>&1`)
lines.push(cmd.command)
lines.push(`__shout_ec=$?`)
lines.push(`exec 1>&9 2>&1`)
lines.push(`cat "$__shout_out"`)
lines.push(`rm -f "$__shout_out"`)
// Sentinel: printf to avoid echo interpretation issues
// Format: __SHOUT_SENTINEL_<exitcode>_<index>__
lines.push(
`printf '\\n${sentinel}%s_${i}__\\n' "$__shout_ec"`,
)
}
return lines.join("\n") + "\n"
}
function parseSentinelOutput(
raw: string,
commandCount: number,
): { outputs: string[][]; exitCodes: number[] } {
const outputs: string[][] = []
const exitCodes: number[] = []
// Split by sentinel lines
const sentinelRegex = new RegExp(
`${SENTINEL_PREFIX}(\\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 }
}
// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /[\u001b\u009b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
function stripAnsi(line: string): string {
return line.replace(ANSI_REGEX, "")
}
function streamVerboseMarkers(
stderr: ReadableStream<Uint8Array>,
commands: Command[],
onCommand: (cmd: Command) => void,
): void {
const reader = stderr.getReader()
const decoder = new TextDecoder()
let buffer = ""
const pump = (): void => {
reader.read().then(({ done, value }) => {
if (done) return
if (value) buffer += decoder.decode(value, { stream: true })
let nlIdx: number
while ((nlIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nlIdx)
buffer = buffer.slice(nlIdx + 1)
if (line.startsWith(VERBOSE_MARKER)) {
const i = parseInt(line.slice(VERBOSE_MARKER.length), 10)
if (i >= 0 && i < commands.length) {
onCommand(commands[i]!)
}
}
}
pump()
}).catch(() => {})
}
pump()
}
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 verbose = !!(options.verbose && options.onCommand)
const script = buildScript(file.commands, sentinel, verbose)
const env: Record<string, string> = options.cleanEnv
? {}
: { ...process.env as Record<string, string> }
env["HOME"] = tmpDir
env["SHOUT_DIR"] = tmpDir
if (options.sourceDir) {
env["SHOUT_SOURCE_DIR"] = options.sourceDir
}
if (options.projectDir) {
env["SHOUT_PROJECT_DIR"] = options.projectDir
}
if (options.envVars) {
Object.assign(env, options.envVars)
}
if (options.pathDirs?.length) {
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
}
const proc = Bun.spawn(["/bin/sh"], {
detached: true,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
cwd: tmpDir,
env,
})
try {
if (verbose) {
// Stream stderr for verbose command markers before writing script
streamVerboseMarkers(proc.stderr, file.commands, options.onCommand!)
}
proc.stdin.write(script)
proc.stdin.end()
const totalTimeout = options.timeout * file.commands.length
const lastSentinelSuffix = `_${file.commands.length - 1}__`
const onSentinel = options.onCommandResult
? (index: number, exitCode: number, rawOutput: string) => {
let lines = rawOutput.split("\n")
if (lines.length > 0 && lines[0] === "") lines.shift()
lines = trimTrailingEmpty(lines)
if (lines.length === 1 && lines[0] === "") lines = []
const result: CommandResult = {
command: file.commands[index]!,
actual: lines.map(stripAnsi),
exitCode,
}
options.onCommandResult!(index, result)
}
: undefined
const stdout = await readUntilSentinel(proc.stdout, sentinel, lastSentinelSuffix, totalTimeout, onSentinel)
if (!verbose) {
await readWithTimeout(proc.stderr, 1000).catch(() => "")
}
const { outputs, exitCodes } = parseSentinelOutput(
stdout,
file.commands.length,
)
const results: CommandResult[] = file.commands.map((cmd, i) => ({
command: cmd,
actual: (outputs[i] ?? []).map(stripAnsi),
exitCode: exitCodes[i] ?? 1,
}))
return { file, results, tmpDir }
} catch (err) {
return {
file,
results: [],
tmpDir,
error: err instanceof Error ? err.message : String(err),
}
} finally {
if (proc.pid) {
killTree(proc.pid)
}
}
}
async function readUntilSentinel(
stream: ReadableStream<Uint8Array>,
sentinelPrefix: string,
sentinelSuffix: string,
timeoutMs: number,
onSentinel?: (index: number, exitCode: number, outputBefore: string) => void,
): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let accumulated = ""
let sentinelsReported = 0
let lastSentinelEnd = 0
let timerId: ReturnType<typeof setTimeout>
const timeout = new Promise<never>((_, reject) =>
timerId = 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) {
accumulated += decoder.decode(value, { stream: true })
// Detect new sentinels incrementally for streaming results
if (onSentinel) {
const regex = new RegExp(`${sentinelPrefix}(\\d+)_(\\d+)__`, "g")
regex.lastIndex = lastSentinelEnd
let match
while ((match = regex.exec(accumulated)) !== null) {
const idx = parseInt(match[2]!, 10)
if (idx >= sentinelsReported) {
const exitCode = parseInt(match[1]!, 10)
const output = accumulated.slice(lastSentinelEnd, match.index)
onSentinel(idx, exitCode, output)
sentinelsReported = idx + 1
lastSentinelEnd = match.index + match[0].length
if (accumulated[lastSentinelEnd] === "\n") lastSentinelEnd++
}
}
}
// Check if the last sentinel has appeared (prefix + exitcode + suffix)
const prefixIdx = accumulated.lastIndexOf(sentinelPrefix)
if (prefixIdx !== -1 && accumulated.indexOf(sentinelSuffix, prefixIdx) !== -1) break
}
}
} finally {
clearTimeout(timerId!)
reader.releaseLock()
}
return accumulated + decoder.decode()
}
async function readWithTimeout(
stream: ReadableStream<Uint8Array>,
timeoutMs: number,
): Promise<string> {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
let timerId: ReturnType<typeof setTimeout>
const timeout = new Promise<never>((_, reject) =>
timerId = 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 {
clearTimeout(timerId!)
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 })
}

View File

@ -1,86 +0,0 @@
import type { CommandResult } from "./run.ts"
import type { ShoutFile } from "./parse.ts"
import { matchOutput } from "./match.ts"
import { isCommentLine } from "./parse.ts"
import { trimTrailingEmpty } from "./utils.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 (isCommentLine(line)) {
// Preserve comment lines as-is
output.push(line)
} else if (line.startsWith("$ ") && !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 && !isCommentLine(lines[j]!) && !(lines[j]!.startsWith("$ ") && !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 (escape lines starting with "$ ")
for (const al of result.actual) output.push(escapeDollar(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 escapeDollar(line: string): string {
return line.startsWith("$ ") ? "\\" + line : line
}

View File

@ -1,9 +0,0 @@
export function trimTrailingEmpty(lines: string[]): string[] {
let end = lines.length
while (end > 0 && lines[end - 1] === "") end--
return lines.slice(0, end)
}
export function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

View File

@ -1,32 +0,0 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": "."
}
}