Compare commits

...

28 Commits

Author SHA1 Message Date
0aeb97d1fc bump version 2026-04-10 11:38:55 -07:00
6529fb803f Remove publish script 2026-04-10 11:38:19 -07:00
0b0a66b6d4 Add syntax subcommand to print .shout file format reference
Gives users a quick built-in reference for the file format, directives,
wildcards, exit codes, and CLI options without needing external docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 11:34:50 -07:00
c62c1d3161 Redesign homepage with improved layout and styling 2026-04-03 09:59:50 -07:00
6fe643bf19 Refactor sections to use consistent box styling 2026-04-03 09:59:08 -07:00
691cebbfee Add color vars and redesign hero section 2026-04-03 08:09:52 -07:00
09f1852044 Support # as comment syntax and \# escaping 2026-04-03 08:03:01 -07:00
c9dba93f7f Add safety and dependency constraints 2026-04-02 20:26:49 -07:00
a12dbdcc43 Update install command URL path 2026-04-02 20:01:43 -07:00
ae2afc0ffd Add multi-target cross-compilation support 2026-04-02 19:26:32 -07:00
88bfd0fa5f Add publish script and update install instructions 2026-04-02 19:26:32 -07:00
5ae67ba391 Remove regex dependency, replace with manual parsing 2026-04-02 18:32:34 -07:00
a6b3de8883 Remove rayon dependency, use std::thread 2026-04-02 18:21:29 -07:00
faa4a4ce9e Migrate from Bun/TypeScript to Rust 2026-04-02 16:47:23 -07:00
0c9d4a27ce Add macro definition and substitution support 2026-04-02 15:48:30 -07:00
6180a8f7e9 Move tests, remove old .ts files 2026-04-02 15:40:40 -07:00
7ff92d1923 Merge branch 'rust-rewrite'
# Conflicts:
#	package.json
#	src/cli/index.ts
#	src/parse.test.ts
#	src/parse.ts
2026-04-02 15:30:50 -07:00
b71ffbf177 Add Cargo.lock file with dependencies 2026-04-02 15:20:11 -07:00
637327b356 omg rust 2026-04-02 15:18:22 -07:00
efa9d5a90b Refactor help command and add tests 2026-04-02 14:15:37 -07:00
9c516604f2 Improve help text and error handling 2026-04-02 14:05:45 -07:00
1c7c0da4b7 Add Rust implementation of the shout test runner
Rewrites the shout CLI in Rust for better performance, with parallel
test execution via rayon and the same .shout file format semantics.
2026-04-02 13:28:48 -07:00
8523132dbf 0.0.19 2026-03-19 13:50:44 -07:00
724f40c25d Allow @def body to start on the next continuation line
Previously, a backslash immediately after the macro name (with no
body on the first line) produced a leading newline. Now an empty
prefix is handled so the body begins cleanly on the continuation line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:50:01 -07:00
9943641c02 Allow @def directives in setup files and fix macro precedence
User macros now correctly override setup macros instead of
last-write-wins. Also hardens @def continuation parsing to reject
trailing backslash at EOF, blank lines, and comment lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:24:37 -07:00
7231c07a8b Apply macro expansion to teardown commands and fix @def continuation parsing
Teardown commands were missing the expandMacro call that setup and
parsed commands already received. Also relax the continuation-line
guard to catch bare `$` (not just `$ `) so lines like `$VAR` are
not silently consumed as continuation text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:59:08 -07:00
f508e30fcb Merge setup and user macros into a single map and harden @def parsing
User macros already overwrote setup macros, so two separate maps were
unnecessary. Also prevents @def continuations from silently swallowing
command or directive lines, and stops expanding macros in teardown.
2026-03-19 11:50:15 -07:00
ce1503a9d4 Add @def directive for command macro substitution
Macros let setup files define reusable command shorthands that
test files can invoke by name or override with their own definition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:28:59 -07:00
48 changed files with 2492 additions and 1998 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,12 +1,12 @@
# shout # shout
Transcript-based shell integration test runner. Bun + TypeScript. Transcript-based shell integration test runner. Rust.
## Commands ## Commands
- `bun test` — run unit tests - `cargo test` — run tests (integration tests in `tests/shout.rs`)
- `bunx tsc --noEmit` — type check - `cargo build` — build the binary
- `bun run src/cli/index.ts test [files...]` — run shout CLI - `cargo run -- test [files...]` — run shout CLI
- `-u, --update` — rewrite `.shout` files with actual output - `-u, --update` — rewrite `.shout` files with actual output
- `-k, --keep` — keep temp directories after run - `-k, --keep` — keep temp directories after run
- `--clean-env` — start with empty environment - `--clean-env` — start with empty environment
@ -19,15 +19,13 @@ Transcript-based shell integration test runner. Bun + TypeScript.
## Architecture ## Architecture
- `src/parse.ts` — parses `.shout` files into `ShoutFile` (list of `Command`) - `src/main.rs` — CLI entry point, arg parsing, file discovery, `run_one` orchestration
- `src/run.ts` — executes commands via `Bun.spawn(["/bin/sh"], { detached: true })`, captures output with sentinels - `src/parse.rs` — parses `.shout` files into `ShoutFile` (list of `Command`), also `parse_setup`
- `src/match.ts` — wildcard-aware output matching and diff generation - `src/run.rs` — executes commands via `/bin/sh`, captures output with sentinels
- `src/format.ts` — evaluates pass/fail, formats failures and summary - `src/matching.rs` — wildcard-aware output matching and diff generation
- `src/update.ts` — rewrites `.shout` files with actual output (`--update` mode) - `src/format.rs` — evaluates pass/fail, formats failures and summary
- `src/utils.ts` — shared utilities (`trimTrailingEmpty`, `escapeRegex`) - `src/update.rs` — rewrites `.shout` files with actual output (`--update` mode)
- `src/duration.ts` — parses duration strings (`10s`, `500ms`, `1m`) - `src/duration.rs` — parses duration strings (`10s`, `500ms`, `1m`)
- `src/cli/index.ts` — CLI entry point via `commander`
- `src/index.ts` — barrel exports
- `web/index.html` — web documentation page - `web/index.html` — web documentation page
## .shout file format ## .shout file format
@ -38,7 +36,9 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `...` inline = matches any characters on that line - `...` inline = matches any characters on that line
- `[N]` on last line of expected output = assert exit code N - `[N]` on last line of expected output = assert exit code N
- `[*]` = assert any non-zero exit code; default expects 0 - `[*]` = assert any non-zero exit code; default expects 0
- `$#` comment line = not executed, no output expected (e.g. `$# start the server`) - `#` at start of line = comment (not executed, no output expected)
- `$#` also works as comment (legacy syntax)
- `\#` in expected output = literal line starting with `#`
- `#` after a command = comment (stripped); `#` in expected output is literal - `#` after a command = comment (stripped); `#` in expected output is literal
- `@env KEY=VALUE` before first command = set environment variable - `@env KEY=VALUE` before first command = set environment variable
- `@teardown <command>` before first command = run command after all test commands - `@teardown <command>` before first command = run command after all test commands
@ -47,9 +47,14 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- Can appear in both `.shout` files and setup files - Can appear in both `.shout` files and setup files
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file - `@setup path.shout` before first command = prepend commands (and `@env`) from another file
- Setup files use a plain format: each line is a command (no `$ ` prefix), `#` lines are comments, blank lines ignored - Setup files use a plain format: each line is a command (no `$ ` prefix), `#` lines are comments, blank lines ignored
- Setup files can contain `@env` and `@teardown` directives but not `@setup` (no nesting) - Setup files can contain `@env`, `@teardown`, and `@def` directives but not `@setup` (no nesting)
- User file `@env` overrides setup file `@env` - User file `@env` overrides setup file `@env`
- Setup command failures abort the test with an error - Setup command failures abort the test with an error
- `@def name body` before first command = define a macro
- If a command matches `name` exactly, `body` is substituted before execution
- Backslash `\` at end of line continues the body onto the next line
- Allowed in both `.shout` files and setup files
- User file `@def` overrides setup file `@def` with the same name
- Each file runs in a fresh temp dir with a single `/bin/sh` session - Each file runs in a fresh temp dir with a single `/bin/sh` session
- `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically - `$HOME` and `$SHOUT_DIR` are set to the temp dir automatically
- `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file - `$SHOUT_SOURCE_DIR` is set to the directory containing the `.shout` file
@ -58,17 +63,19 @@ Transcript-based shell integration test runner. Bun + TypeScript.
## New feature checklist ## New feature checklist
1. `src/parse.ts` — update types (`Directive`, `ShoutFile`, `Command`) and both parsers (`parse` + `parseSetup`) 1. `src/parse.rs` — update types (`Directive`, `ShoutFile`, `Command`) and both parsers (`parse` + `parse_setup`)
2. `src/parse.test.ts` — unit tests for parsing the new syntax in both `.shout` and setup file contexts 2. `src/main.rs` — wire up the parsed result in `run_one` (directive resolution, command merging, result handling)
3. `src/cli/index.ts` — wire up the parsed result in `runOne` (directive resolution, command merging, result handling) 3. `tests/*.shout` — integration test file exercising the feature end-to-end
4. `test/*.shout` — integration test file exercising the feature end-to-end 4. `CLAUDE.md` — update `.shout file format` section
5. `CLAUDE.md` — update `.shout file format` section 5. `README.md` — update Directives section
6. `README.md` — update Directives section 6. `web/index.html` — add or update a section on the website
7. `web/index.html` — add or update a section on the website 7. Run `cargo test` to verify
8. Run `bun test` and `bun run src/cli/index.ts test test/` to verify
## Style ## Style
- Strict TypeScript, Bun runtime - Rust 2024 edition
- No classes — plain functions and types - No OOP — plain functions and structs/enums
- Tests in `src/*.test.ts`, example `.shout` files in `test/` - Integration tests in `tests/shout.rs`, example `.shout` files in `tests/`
- Never use `unsafe`
- Keep dependencies to the bare minimum.
- If you must use a dependency, use the lightest-weight version.

16
Cargo.lock generated Normal file
View File

@ -0,0 +1,16 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "shout"
version = "0.0.19"
dependencies = [
"libc",
]

7
Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "shout"
version = "0.0.19"
edition = "2024"
[dependencies]
libc = "0.2"

View File

@ -30,16 +30,25 @@ ls: missing: No such file or directory
`[1]` after the expected output matches the exit code. `[1]` after the expected output matches the exit code.
`$#` is a comment line — not executed, no output expected: `#` at the start of a line is a comment — not executed, no output expected:
``` ```
$# start the server # start the server
$ my-server & $ my-server &
$# now test it # now test it
$ curl localhost:8080 $ curl localhost:8080
OK OK
``` ```
If expected output starts with `#`, escape it with `\#`:
```
$ echo "#hashtag"
\#hashtag
```
`#` elsewhere in expected output is literal (not a comment).
## Usage ## Usage
``` ```
@ -99,6 +108,29 @@ export DB_URL=sqlite:data/test.db
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db" @teardown rm -f "$SHOUT_PROJECT_DIR/data/test.db"
``` ```
### `@def`
Define a macro that substitutes a command by name:
```
@def greet echo "hello world"
$ greet
hello world
```
Use backslash `\` for multi-line bodies. The body can start on the same line or on the next continuation line:
```
@def serve \
python3 -m http.server $PORT & \
sleep 0.5
$ serve
```
Macros defined in setup files are inherited. A user file `@def` with the same name overrides the setup version.
``` ```
Usage: shout test [options] [files...] Usage: shout test [options] [files...]

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": "*"
}
}

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()

34
src/duration.rs Normal file
View File

@ -0,0 +1,34 @@
use std::fmt;
#[derive(Debug)]
pub struct ParseDurationError(String);
impl fmt::Display for ParseDurationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Invalid duration: {}", self.0)
}
}
/// Parse a duration string like "10s", "500ms", "1m" into milliseconds.
pub fn parse_duration(s: &str) -> Result<u64, ParseDurationError> {
let (num, unit) = if let Some(rest) = s.strip_suffix("ms") {
(rest, "ms")
} else if let Some(rest) = s.strip_suffix('s') {
(rest, "s")
} else if let Some(rest) = s.strip_suffix('m') {
(rest, "m")
} else {
return Err(ParseDurationError(s.to_string()));
};
let value: f64 = num.parse().map_err(|_| ParseDurationError(s.to_string()))?;
let ms = match unit {
"ms" => value,
"s" => value * 1000.0,
"m" => value * 60_000.0,
_ => unreachable!(),
};
Ok(ms as u64)
}

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}`)
}
}

188
src/format.rs Normal file
View File

@ -0,0 +1,188 @@
use crate::matching::{DiffKind, DiffLine, diff, match_output};
use crate::parse::ExitCode;
use crate::run::CommandResult;
// ANSI color codes
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
#[allow(dead_code)]
const YELLOW: &str = "\x1b[33m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
#[derive(Debug)]
pub struct FailedCommand {
pub result: CommandResult,
pub diff_lines: Vec<DiffLine>,
pub exit_code_mismatch: bool,
}
#[derive(Debug)]
pub struct TestResult {
pub path: String,
pub passed: bool,
pub command_count: usize,
pub failures: Vec<FailedCommand>,
pub error: Option<String>,
}
pub fn evaluate_file(
path: &str,
results: &[CommandResult],
error: Option<&str>,
) -> TestResult {
if let Some(err) = error {
return TestResult {
path: path.to_string(),
passed: false,
command_count: results.len(),
failures: vec![],
error: Some(err.to_string()),
};
}
let mut failures = Vec::new();
for result in results {
let output_matches = match_output(&result.command.expected, &result.actual);
let exit_code_mismatch = match &result.command.exit_code {
ExitCode::Default => result.exit_code != 0,
ExitCode::Any => result.exit_code == 0,
ExitCode::Code(expected) => result.exit_code != *expected,
};
if !output_matches || exit_code_mismatch {
failures.push(FailedCommand {
result: result.clone(),
diff_lines: if output_matches {
vec![]
} else {
diff(&result.command.expected, &result.actual)
},
exit_code_mismatch,
});
}
}
TestResult {
path: path.to_string(),
passed: failures.is_empty(),
command_count: results.len(),
failures,
error: None,
}
}
pub fn format_failure(test: &TestResult) -> String {
let mut lines = Vec::new();
lines.push(format!("{RED}FAIL {}{RESET}", test.path));
if let Some(ref err) = test.error {
lines.push(format!(" {RED}{err}{RESET}"));
return lines.join("\n");
}
for failure in &test.failures {
lines.push(String::new());
lines.push(format!(" {DIM}${RESET} {}", failure.result.command.command));
if !failure.diff_lines.is_empty() {
let mut expected_lines = Vec::new();
let mut actual_lines = Vec::new();
for dl in &failure.diff_lines {
let text = if dl.kind == DiffKind::Context {
format!("{DIM}{}{RESET}", dl.text)
} else {
dl.text.clone()
};
match dl.kind {
DiffKind::Expected | DiffKind::Equal | DiffKind::Context => {
let prefix = if dl.kind == DiffKind::Expected {
format!("{GREEN} > {RESET}")
} else {
" ".to_string()
};
expected_lines.push(format!("{prefix}{text}"));
}
_ => {}
}
match dl.kind {
DiffKind::Actual | DiffKind::Equal | DiffKind::Context => {
let prefix = if dl.kind == DiffKind::Actual {
format!("{RED} > {RESET}")
} else {
" ".to_string()
};
actual_lines.push(format!("{prefix}{text}"));
}
_ => {}
}
}
lines.push(format!("{GREEN} expected:{RESET}"));
lines.extend(expected_lines);
lines.push(format!("{RED} actual:{RESET}"));
lines.extend(actual_lines);
}
if failure.exit_code_mismatch {
let expected = match &failure.result.command.exit_code {
ExitCode::Default => "0".to_string(),
ExitCode::Any => "non-zero".to_string(),
ExitCode::Code(c) => c.to_string(),
};
lines.push(format!("{GREEN} expected exit code: {expected}{RESET}"));
lines.push(format!("{RED} actual exit code: {}{RESET}", failure.result.exit_code));
}
}
lines.join("\n")
}
pub fn format_summary(
results: &[TestResult],
elapsed_ms: f64,
single_file: Option<&str>,
) -> String {
let total_commands: usize = results.iter().map(|r| r.command_count).sum();
let failed_commands: usize = results.iter().map(|r| r.failures.len()).sum();
let passed_commands = total_commands - failed_commands;
let mut parts = Vec::new();
if passed_commands > 0 {
parts.push(format!("{GREEN}{passed_commands} passed{RESET}"));
}
if failed_commands > 0 {
parts.push(format!("{RED}{failed_commands} failed{RESET}"));
}
let time = if elapsed_ms < 1000.0 {
format!("{}ms", elapsed_ms.round() as u64)
} else {
format!("{:.1}s", elapsed_ms / 1000.0)
};
let label = match single_file {
Some(f) => format!(" in {f}"),
None => String::new(),
};
format!("{}{label} {DIM}[{time}]{RESET}", parts.join(", "))
}
/// Print a green dot for pass, red F for fail.
pub fn print_dot(passed: bool) {
use std::io::{Write, stdout};
let s = if passed {
format!("{GREEN}.{RESET}")
} else {
format!("{RED}F{RESET}")
};
let _ = stdout().write_all(s.as_bytes());
let _ = stdout().flush();
}

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"

833
src/main.rs Normal file
View File

@ -0,0 +1,833 @@
mod duration;
mod format;
mod matching;
mod parse;
mod run;
mod update;
use std::fs;
use std::io::{Write, stderr, stdout};
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
use format::{TestResult, evaluate_file, format_failure, format_summary, print_dot};
use parse::{Command, Directive, ExitCode, ShoutFile};
use run::{CommandResult, RunOptions, cleanup_tmp_dir, command_passes, run_file};
use update::rewrite_file;
const VERSION: &str = env!("CARGO_PKG_VERSION");
// ANSI
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
struct TestOpts {
files: Vec<String>,
update: bool,
keep: bool,
clean_env: bool,
path_dirs: Vec<String>,
timeout: String,
verbose: bool,
port_from: u16,
filter: Option<String>,
parallel: bool,
}
fn print_usage() {
eprintln!("Usage: shout [options] [command]");
eprintln!();
eprintln!("$ shell output tester");
eprintln!();
eprintln!("Options:");
eprintln!(" -V, --version output the version number");
eprintln!(" -h, --help display help for command");
eprintln!();
eprintln!("Commands:");
eprintln!(" test [options] [files...] Run .shout test files");
eprintln!(" version Print the version");
eprintln!(" example Print an example .shout file");
eprintln!(" syntax Print the .shout file format reference");
eprintln!(" help [command] display help for command");
}
fn print_test_help() {
eprintln!("Usage: shout test [options] [files...]");
eprintln!();
eprintln!("Run .shout test files");
eprintln!();
eprintln!("Arguments:");
eprintln!(" files Files or directories to test");
eprintln!();
eprintln!("Options:");
eprintln!(" -u, --update Rewrite expected output in-place with actual output");
eprintln!(" -k, --keep Keep temp directories after run");
eprintln!(" --clean-env Start with empty environment");
eprintln!(" --path <path> Prepend <path> to PATH (repeatable)");
eprintln!(" --timeout <dur> Per-command timeout (default: 10s)");
eprintln!(" -v, --verbose Print each command as it runs");
eprintln!(" --port-from <n> Auto-assign $PORT starting from <n> (default: 5400)");
eprintln!(" -t, --filter <pattern> Only run files matching <pattern> (substring match)");
eprintln!(" --parallel Run files in parallel");
eprintln!(" -h, --help display help for command");
}
fn parse_args() -> Option<(&'static str, TestOpts)> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
print_usage();
process::exit(if args.is_empty() { 1 } else { 0 });
}
if args[0] == "help" {
if args.len() < 2 {
print_usage();
} else {
match args[1].as_str() {
"test" => print_test_help(),
"example" => print_example_help(),
"syntax" => print_syntax_help(),
"version" => print_version_help(),
"help" => print_help_help(),
other => {
eprintln!("Unknown command: {other}");
eprintln!("Run 'shout --help' for usage");
process::exit(1);
}
}
}
process::exit(0);
}
let subcommand = args[0].as_str();
match subcommand {
"-V" | "--version" | "version" => {
println!("{VERSION}");
process::exit(0);
}
"example" => {
print_example();
process::exit(0);
}
"syntax" => {
print_syntax();
process::exit(0);
}
"test" => {}
other => {
eprintln!("Unknown command: {other}");
eprintln!("Run 'shout --help' for usage");
process::exit(1);
}
}
let mut opts = TestOpts {
files: vec![],
update: false,
keep: false,
clean_env: false,
path_dirs: vec![],
timeout: "10s".to_string(),
verbose: false,
port_from: 5400,
filter: None,
parallel: false,
};
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print_test_help();
process::exit(0);
}
"-u" | "--update" => opts.update = true,
"-k" | "--keep" => opts.keep = true,
"--clean-env" => opts.clean_env = true,
"-v" | "--verbose" => opts.verbose = true,
"--parallel" => opts.parallel = true,
"--path" => {
i += 1;
if i >= args.len() {
eprintln!("--path requires a value");
process::exit(1);
}
opts.path_dirs.push(args[i].clone());
}
"--timeout" => {
i += 1;
if i >= args.len() {
eprintln!("--timeout requires a value");
process::exit(1);
}
opts.timeout = args[i].clone();
}
"--port-from" => {
i += 1;
if i >= args.len() {
eprintln!("--port-from requires a value");
process::exit(1);
}
opts.port_from = match args[i].parse() {
Ok(n) => n,
Err(_) => {
eprintln!("--port-from must be an integer");
process::exit(1);
}
};
}
"-t" | "--filter" => {
i += 1;
if i >= args.len() {
eprintln!("--filter requires a value");
process::exit(1);
}
opts.filter = Some(args[i].clone());
}
arg if arg.starts_with('-') => {
eprintln!("Unknown option: {arg}");
process::exit(1);
}
_ => opts.files.push(args[i].clone()),
}
i += 1;
}
Some(("test", opts))
}
fn find_shout_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
find_shout_files_recursive(&path, out);
} else if path.extension().is_some_and(|ext| ext == "shout") {
out.push(path);
}
}
}
}
fn filter_gitignored(files: Vec<PathBuf>) -> Vec<PathBuf> {
if files.is_empty() {
return files;
}
let input: String = files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>().join("\n");
let result = process::Command::new("git")
.args(["check-ignore", "--stdin"])
.stdin(process::Stdio::piped())
.stdout(process::Stdio::piped())
.stderr(process::Stdio::null())
.spawn();
let Ok(mut child) = result else {
return files;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input.as_bytes());
}
let output = child.wait_with_output();
let Ok(output) = output else {
return files;
};
let ignored: std::collections::HashSet<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
files
.into_iter()
.filter(|f| !ignored.contains(&f.to_string_lossy().to_string()))
.collect()
}
fn find_shout_files(paths: &[String]) -> Vec<PathBuf> {
let mut explicit = Vec::new();
let mut discovered = Vec::new();
for p in paths {
let abs = fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p));
if abs.is_file() && abs.extension().is_some_and(|e| e == "shout") {
explicit.push(abs);
} else if abs.is_dir() {
find_shout_files_recursive(&abs, &mut discovered);
} else if abs.extension().is_some_and(|e| e == "shout") {
explicit.push(abs);
}
}
let filtered = filter_gitignored(discovered);
let mut all: Vec<PathBuf> = explicit.into_iter().chain(filtered).collect();
all.sort();
all
}
fn run_one(
file_path: &Path,
port: u16,
opts: &TestOpts,
timeout_ms: u64,
cwd: &Path,
) -> TestResult {
let content = match fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
return evaluate_file(
&rel_path(cwd, file_path),
&[],
Some(&format!("Failed to read file: {e}")),
);
}
};
let rel = rel_path(cwd, file_path);
let parsed = match parse::parse(&rel, &content) {
Ok(p) => p,
Err(e) => {
return evaluate_file(&rel, &[], Some(&e.0));
}
};
// Resolve directives
let mut setup_env: Vec<(String, String)> = vec![];
let mut user_env: Vec<(String, String)> = vec![];
let mut setup_commands: Vec<Command> = vec![];
let mut teardown_commands: Vec<Command> = parsed.teardown_commands.clone();
let mut setup_defs: Vec<(String, String)> = vec![];
let mut user_defs: Vec<(String, String)> = vec![];
for d in &parsed.directives {
match d {
Directive::Setup { path: setup_path, .. } => {
let full_path = file_path.parent().unwrap().join(setup_path);
let setup_content = match fs::read_to_string(&full_path) {
Ok(c) => c,
Err(e) => {
return evaluate_file(
&rel,
&[],
Some(&format!("Failed to read setup file {setup_path}: {e}")),
);
}
};
let setup_rel = rel_path(cwd, &full_path);
let setup_parsed = match parse::parse_setup(&setup_rel, &setup_content) {
Ok(p) => p,
Err(e) => {
return evaluate_file(&rel, &[], Some(&e.0));
}
};
for sd in &setup_parsed.directives {
match sd {
Directive::Env { key, value, .. } => {
setup_env.push((key.clone(), value.clone()));
}
Directive::Def { name, body, .. } => {
setup_defs.push((name.clone(), body.clone()));
}
_ => {}
}
}
setup_commands.extend(setup_parsed.commands);
teardown_commands.extend(setup_parsed.teardown_commands);
}
Directive::Env { key, value, .. } => {
user_env.push((key.clone(), value.clone()));
}
Directive::Def { name, body, .. } => {
user_defs.push((name.clone(), body.clone()));
}
}
}
// Merge defs: setup first, then user overrides
let mut defs: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (name, body) in &setup_defs {
defs.insert(name.clone(), body.clone());
}
for (name, body) in &user_defs {
defs.insert(name.clone(), body.clone());
}
// Merge env: setup first, then user overrides
let mut env_vars: Vec<(String, String)> = vec![];
env_vars.extend(setup_env.clone());
// Remove setup keys that user overrides
let user_keys: std::collections::HashSet<&str> = user_env.iter().map(|(k, _)| k.as_str()).collect();
env_vars.retain(|(k, _)| !user_keys.contains(k.as_str()));
env_vars.extend(user_env.clone());
// Assign PORT if not set
let has_port = env_vars.iter().any(|(k, _)| k == "PORT");
if !has_port {
env_vars.push(("PORT".to_string(), port.to_string()));
}
// Apply macro substitutions
let apply_defs = |commands: &mut Vec<Command>| {
for cmd in commands.iter_mut() {
if let Some(body) = defs.get(&cmd.command) {
cmd.command = body.clone();
}
}
};
let mut user_commands = parsed.commands.clone();
apply_defs(&mut setup_commands);
apply_defs(&mut user_commands);
// Merge commands: setup + user + teardown
let mut merged_commands = Vec::new();
merged_commands.extend(setup_commands.clone());
merged_commands.extend(user_commands.clone());
merged_commands.extend(teardown_commands.clone());
let merged = ShoutFile {
path: parsed.path.clone(),
commands: merged_commands,
directives: vec![],
teardown_commands: vec![],
};
let setup_len = setup_commands.len();
let user_len = user_commands.len();
let run_opts = RunOptions {
clean_env: opts.clean_env,
path_dirs: opts.path_dirs.clone(),
env_vars,
source_dir: file_path.parent().map(|p| p.to_string_lossy().to_string()),
project_dir: Some(cwd.to_string_lossy().to_string()),
timeout_ms,
verbose: opts.verbose,
};
let on_cmd: Option<Box<dyn Fn(&parse::Command)>> = if opts.verbose {
Some(Box::new(|cmd: &parse::Command| {
let _ = write!(stderr(), "{DIM} $ {}{RESET}\n", cmd.command);
}))
} else {
None
};
let on_result: Box<dyn Fn(usize, &CommandResult)> = Box::new(move |index: usize, result: &CommandResult| {
if index >= setup_len && index < setup_len + user_len {
print_dot(command_passes(result));
}
});
let file_result = run_file(
&merged,
&run_opts,
on_cmd.as_deref(),
Some(&*on_result),
);
// Check setup commands for failures
for i in 0..setup_commands.len() {
if let Some(r) = file_result.results.get(i) {
let expected = &setup_commands[i].exit_code;
let ok = match expected {
ExitCode::Default => r.exit_code == 0,
ExitCode::Any => r.exit_code != 0,
ExitCode::Code(c) => r.exit_code == *c,
};
if !ok {
if opts.keep {
let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
} else {
cleanup_tmp_dir(&file_result.tmp_dir);
}
return evaluate_file(
&parsed.path,
&[],
Some(&format!(
"setup command failed (exit {}): $ {}",
r.exit_code, setup_commands[i].command
)),
);
}
}
}
// Extract user command results
let file_own_results: Vec<CommandResult> = file_result
.results
.iter()
.skip(setup_len)
.take(user_len)
.cloned()
.collect();
// Warn on teardown failures
let teardown_results: Vec<&CommandResult> = file_result
.results
.iter()
.skip(setup_len + user_len)
.collect();
for (i, r) in teardown_results.iter().enumerate() {
if r.exit_code != 0 {
if let Some(td) = teardown_commands.get(i) {
let _ = write!(
stderr(),
"{YELLOW}warning: teardown command failed (exit {}): $ {}{RESET}\n",
r.exit_code, td.command
);
}
}
}
let test_result = evaluate_file(&parsed.path, &file_own_results, file_result.error.as_deref());
// Update mode
if opts.update && !file_own_results.is_empty() {
let updated = rewrite_file(&parsed, &file_own_results, &content);
if updated != content {
let _ = fs::write(file_path, &updated);
}
}
if opts.keep {
let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
} else {
cleanup_tmp_dir(&file_result.tmp_dir);
}
test_result
}
fn rel_path(base: &Path, path: &Path) -> String {
pathdiff(path, base)
}
/// Simple relative path calculation.
fn pathdiff(path: &Path, base: &Path) -> String {
if let Ok(stripped) = path.strip_prefix(base) {
stripped.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
}
}
fn print_example_help() {
eprintln!("Usage: shout example");
eprintln!();
eprintln!("Print an example .shout file");
}
fn print_version_help() {
eprintln!("Usage: shout version");
eprintln!();
eprintln!("Print the version");
}
fn print_help_help() {
eprintln!("Usage: shout help [command]");
eprintln!();
eprintln!("Display help for command");
}
fn print_example() {
println!(
r#"# Example .shout file
$ echo hello
hello
$ echo "one"; echo "two"; echo "three"
one
...
three
$ cat nonexistent
cat: nonexistent: ...
[1]
$ true
[0]"#
);
}
fn print_syntax_help() {
eprintln!("Usage: shout syntax");
eprintln!();
eprintln!("Print the .shout file format reference");
}
fn print_syntax() {
println!(
r##"SHOUT FILE FORMAT
=================
Each .shout file describes a shell session: commands to run and their
expected output. Each file runs in a fresh temp directory with a single
/bin/sh session. State carries between commands within a file.
COMMANDS AND OUTPUT
-------------------
Lines starting with "$ " are commands. Lines between commands are expected
output (stdout and stderr are merged).
$ echo hello
hello
$ ls missing
ls: missing: No such file or directory
WILDCARDS
---------
"..." matches any characters for the rest of the line (inline wildcard):
$ date
...
$ brew --version
Homebrew ...
"..." on its own line matches zero or more entire lines (multi-line wildcard):
$ echo "one"; echo "two"; echo "three"
one
...
three
EXIT CODES
----------
"[N]" on the last line of expected output asserts exit code N:
$ false
[1]
$ sh -c "exit 42"
[42]
"[*]" asserts any non-zero exit code:
$ false
[*]
"[0]" explicitly asserts exit code 0 (the default when no code is given):
$ true
[0]
COMMENTS
--------
Lines starting with "#" are comments (not executed, no output expected):
# this is a comment
$ echo hello
hello
$ echo hi # comments after commands are also stripped
hi
Use "\#" in expected output to match a literal line starting with "#":
$ echo "# heading"
\# heading
DIRECTIVES
----------
Directives appear before the first command.
@env KEY=VALUE
Set an environment variable for the session.
@env DATABASE_URL=sqlite::memory:
@env DEBUG=1
@setup <path>
Prepend commands (and @env/@teardown/@def) from another file. The setup
file uses a plain format: each line is a command (no "$ " prefix needed),
"#" lines are comments, blank lines are ignored. Setup files cannot
reference other setup files (no nesting).
@setup shared.shout
If a setup command fails (non-zero exit), the test aborts with an error.
User file @env overrides setup file @env for the same key.
User file @def overrides setup file @def for the same name.
@teardown <command>
Run a cleanup command after all test commands, regardless of pass/fail.
Teardown failures produce warnings but don't affect test results.
Can appear in both .shout files and setup files.
@teardown rm -rf "$SHOUT_DIR/tmp"
@teardown docker rm -f test-container
@def <name> <body>
Define a macro. If a command matches <name> exactly, <body> is
substituted before execution. Use "\" at end of line to continue the
body onto the next line. Allowed in both .shout files and setup files.
@def start-server \
PORT=$PORT node server.js &
$ start-server
$ curl localhost:$PORT
OK
ENVIRONMENT VARIABLES
---------------------
These are set automatically before running commands:
HOME temp directory for this test file
SHOUT_DIR same temp directory
SHOUT_SOURCE_DIR directory containing the .shout file
SHOUT_PROJECT_DIR directory where shout was invoked
PORT auto-assigned (from 5400 or --port-from), increments per file
PATH prepended with --path dirs, if any
SETUP FILE FORMAT
-----------------
Setup files (referenced by @setup) use a simpler format:
- Each line is a shell command (no "$ " prefix)
- "#" lines are comments, blank lines are ignored
- @env, @teardown, and @def directives are supported
- @setup is not allowed (no nesting)
Example setup file:
# setup.shout
@env DB_URL=sqlite:test.db
@teardown rm -f "$SHOUT_PROJECT_DIR/test.db"
npm install --silent
CLI OPTIONS
-----------
shout test [options] [files...]
-u, --update Rewrite .shout files with actual output
-k, --keep Keep temp directories after run
--clean-env Start with empty environment
--path <path> Prepend to $PATH (repeatable)
--timeout <dur> Per-command timeout (default: 10s)
-t, --filter <pattern> Only run files matching <pattern>
-v, --verbose Print each command as it runs
--port-from <n> Auto-assign $PORT starting from <n> (default: 5400)
--parallel Run files in parallel"##
);
}
fn main() {
let (_, opts) = parse_args().unwrap();
let timeout_ms = match duration::parse_duration(&opts.timeout) {
Ok(ms) => ms,
Err(e) => {
eprintln!("{e}");
process::exit(1);
}
};
let paths = if opts.files.is_empty() {
vec![".".to_string()]
} else {
opts.files.clone()
};
let cwd = std::env::current_dir().unwrap();
let mut files = find_shout_files(&paths);
if let Some(ref pattern) = opts.filter {
files.retain(|f| rel_path(&cwd, f).contains(pattern));
}
if files.is_empty() {
if opts.filter.is_some() {
eprintln!("No .shout files matching \"{}\"", opts.filter.as_ref().unwrap());
} else {
eprintln!("No .shout files found");
}
process::exit(1);
}
let start = Instant::now();
let mut results: Vec<TestResult> = Vec::new();
let mut next_port = opts.port_from;
let print_error_dot = |r: &TestResult| {
if r.error.is_some() {
let _ = stdout().write_all(format!("{RED}F{RESET}").as_bytes());
let _ = stdout().flush();
}
};
if opts.parallel {
// Pre-assign ports
let file_ports: Vec<(PathBuf, u16)> = files
.iter()
.enumerate()
.map(|(i, f)| (f.clone(), opts.port_from + i as u16))
.collect();
let mut par_results: Vec<Option<TestResult>> = file_ports.iter().map(|_| None).collect();
std::thread::scope(|s| {
let handles: Vec<_> = file_ports
.iter()
.map(|(f, port)| s.spawn(|| run_one(f, *port, &opts, timeout_ms, &cwd)))
.collect();
for (slot, handle) in par_results.iter_mut().zip(handles) {
let r = handle.join().expect("thread panicked");
print_error_dot(&r);
*slot = Some(r);
}
});
results.extend(par_results.into_iter().map(|r| r.unwrap()));
let _ = writeln!(stdout());
} else {
for file_path in &files {
let r = run_one(file_path, next_port, &opts, timeout_ms, &cwd);
print_error_dot(&r);
results.push(r);
next_port += 1;
}
let _ = writeln!(stdout());
}
// Print failures
let failures: Vec<&TestResult> = results.iter().filter(|r| !r.passed).collect();
if !failures.is_empty() {
println!();
for f in &failures {
println!("{}", format_failure(f));
println!();
}
}
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let single_file = if files.len() == 1 {
Some(rel_path(&cwd, &files[0]))
} else {
None
};
println!("{}", format_summary(&results, elapsed, single_file.as_deref()));
process::exit(if failures.is_empty() { 0 } else { 1 });
}

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
}

136
src/matching.rs Normal file
View File

@ -0,0 +1,136 @@
/// Check if a single line matches a pattern that may contain inline `...` wildcards.
pub fn match_line(pattern: &str, actual: &str) -> bool {
if !pattern.contains("...") {
return pattern == actual;
}
let parts: Vec<&str> = pattern.split("...").collect();
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if i == 0 {
if !actual.starts_with(part) {
return false;
}
pos = part.len();
} else if i == parts.len() - 1 {
if !actual[pos..].ends_with(part) {
return false;
}
} else {
match actual[pos..].find(part) {
Some(idx) => pos += idx + part.len(),
None => return false,
}
}
}
true
}
/// Match expected output against actual output, supporting multi-line `...` wildcards.
pub fn match_output(expected: &[String], actual: &[String]) -> bool {
do_match(expected, 0, actual, 0)
}
fn do_match(expected: &[String], ei: usize, actual: &[String], ai: usize) -> bool {
// Both exhausted — match
if ei == expected.len() && ai == actual.len() {
return true;
}
// Expected exhausted but actual remains — no match
if ei == expected.len() {
return false;
}
let exp = &expected[ei];
// Multi-line wildcard
if exp == "..." {
// Try matching zero or more actual lines
for skip in ai..=actual.len() {
if do_match(expected, ei + 1, actual, skip) {
return true;
}
}
return false;
}
// Actual exhausted but expected remains — no match
if ai == actual.len() {
return false;
}
// Line-level match (with possible inline wildcards)
if match_line(exp, &actual[ai]) {
return do_match(expected, ei + 1, actual, ai + 1);
}
false
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiffKind {
Equal,
Expected,
Actual,
Context,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffKind,
pub text: String,
}
pub fn diff(expected: &[String], actual: &[String]) -> Vec<DiffLine> {
let mut result = Vec::new();
let mut ei = 0;
let mut ai = 0;
while ei < expected.len() || ai < actual.len() {
if ei < expected.len() && expected[ei] == "..." {
// Find where the wildcard ends by looking at next expected line
let next_exp = if ei + 1 < expected.len() {
Some(&expected[ei + 1])
} else {
None
};
if next_exp.is_none() {
// ... at end matches everything remaining
result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() });
break;
}
// Skip actual lines until we find the next expected match
result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() });
ei += 1;
let next = next_exp.unwrap();
while ai < actual.len() && !match_line(next, &actual[ai]) {
ai += 1;
}
continue;
}
if ei < expected.len() && ai < actual.len() {
if match_line(&expected[ei], &actual[ai]) {
result.push(DiffLine { kind: DiffKind::Equal, text: actual[ai].clone() });
ei += 1;
ai += 1;
} else {
result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() });
result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() });
ei += 1;
ai += 1;
}
} else if ei < expected.len() {
result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() });
ei += 1;
} else {
result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() });
ai += 1;
}
}
result
}

313
src/parse.rs Normal file
View File

@ -0,0 +1,313 @@
use std::fmt;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Command {
pub line: usize,
pub raw: String,
pub command: String,
pub expected: Vec<String>,
pub exit_code: ExitCode,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ExitCode {
/// No explicit exit code — expects 0
Default,
/// Expect a specific exit code
Code(i32),
/// Expect any non-zero exit code ([*])
Any,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Directive {
Setup { path: String, line: usize },
Env { key: String, value: String, line: usize },
Def { name: String, body: String, line: usize },
}
#[derive(Debug, Clone)]
pub struct ShoutFile {
pub path: String,
pub commands: Vec<Command>,
pub directives: Vec<Directive>,
pub teardown_commands: Vec<Command>,
}
#[derive(Debug)]
pub struct ParseError(pub String);
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
pub fn trim_trailing_empty(lines: &[String]) -> Vec<String> {
let mut end = lines.len();
while end > 0 && lines[end - 1].is_empty() {
end -= 1;
}
lines[..end].to_vec()
}
/// Strip trailing `# comment` from a command line, respecting quotes.
fn strip_comment(line: &str) -> &str {
let mut in_single = false;
let mut in_double = false;
for (i, ch) in line.char_indices() {
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'#' if !in_single && !in_double => return line[..i].trim_end(),
_ => {}
}
}
line
}
/// A line like `$# ...`, `$ # ...`, or `# ...` — a comment, not a real command.
pub fn is_comment_line(line: &str) -> bool {
line.starts_with('#')
|| line.starts_with("$#")
|| (line.starts_with("$ ") && strip_comment(&line[2..]).is_empty())
}
fn parse_exit_code(lines: &[String]) -> (Vec<String>, ExitCode) {
if lines.is_empty() {
return (vec![], ExitCode::Default);
}
let last = &lines[lines.len() - 1];
// Match [N] or [*]
if last.starts_with('[') && last.ends_with(']') {
let inner = &last[1..last.len() - 1];
if inner == "*" {
return (lines[..lines.len() - 1].to_vec(), ExitCode::Any);
}
if let Ok(code) = inner.parse::<i32>() {
return (lines[..lines.len() - 1].to_vec(), ExitCode::Code(code));
}
}
(lines.to_vec(), ExitCode::Default)
}
fn parse_env_directive(path: &str, line: &str, line_num: usize) -> Result<(String, String), ParseError> {
let rest = line[5..].trim();
match rest.find('=') {
Some(eq) if eq > 0 => {
let key = rest[..eq].to_string();
let value = rest[eq + 1..].to_string();
Ok((key, value))
}
_ => Err(ParseError(format!(
"{}:{}: malformed @env directive (expected KEY=VALUE): {}",
path, line_num, line
))),
}
}
/// Parse `@def name body` with backslash continuation support.
/// Returns (name, body, number_of_lines_consumed).
fn parse_def_directive(path: &str, rest: &str, line_num: usize, lines: &[&str]) -> Result<(String, String, usize), ParseError> {
let rest = rest.trim();
let space = rest.find(' ').ok_or_else(|| {
ParseError(format!("{}:{}: @def requires a name and body", path, line_num))
})?;
let name = rest[..space].to_string();
let mut body = rest[space + 1..].to_string();
let mut consumed = 1;
// Handle backslash continuation
while body.ends_with('\\') {
body.pop(); // remove trailing backslash
consumed += 1;
if consumed <= lines.len() {
body.push_str(lines[consumed - 1].trim_start());
}
}
Ok((name, body, consumed))
}
fn finalize_command(cmd: &mut Command) {
let trimmed = trim_trailing_empty(&cmd.expected);
let (expected, exit_code) = parse_exit_code(&trimmed);
cmd.expected = trim_trailing_empty(&expected);
cmd.exit_code = exit_code;
}
pub fn parse_setup(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let mut raw_lines: Vec<&str> = content.split('\n').collect();
// Remove trailing empty line
if raw_lines.last() == Some(&"") {
raw_lines.pop();
}
let mut commands = Vec::new();
let mut teardown_commands = Vec::new();
let mut directives = Vec::new();
let mut i = 0;
while i < raw_lines.len() {
let line = raw_lines[i];
let line_num = i + 1;
if line.is_empty() || line.starts_with('#') {
i += 1;
continue;
}
if let Some(rest) = line.strip_prefix("@env ") {
let _ = rest; // parsed below
let (key, value) = parse_env_directive(path, line, line_num)?;
directives.push(Directive::Env { key, value, line: line_num });
} else if let Some(rest) = line.strip_prefix("@def ") {
let (name, body, lines_consumed) = parse_def_directive(path, rest, line_num, &raw_lines[i..])?;
directives.push(Directive::Def { name, body, line: line_num });
i += lines_consumed;
continue;
} else if let Some(rest) = line.strip_prefix("@teardown ") {
if rest.trim().is_empty() {
return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
}
teardown_commands.push(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(rest).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
} else if line.starts_with("@setup ") {
return Err(ParseError(format!("{}:{}: @setup not allowed in setup files", path, line_num)));
} else if line.starts_with('@') {
return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
} else {
commands.push(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(line).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
}
i += 1;
}
Ok(ShoutFile {
path: path.to_string(),
commands,
directives,
teardown_commands,
})
}
pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let mut raw_lines: Vec<&str> = content.split('\n').collect();
// Remove trailing newline
if raw_lines.last() == Some(&"") {
raw_lines.pop();
}
let mut commands = Vec::new();
let mut teardown_commands = Vec::new();
let mut directives = Vec::new();
let mut current: Option<Command> = None;
let mut seen_command = false;
let mut i = 0;
while i < raw_lines.len() {
let line = raw_lines[i];
let line_num = i + 1;
// Directives (before first command)
if !seen_command && line.starts_with('@') {
if let Some(rest) = line.strip_prefix("@setup ") {
let setup_path = rest.trim();
if setup_path.is_empty() {
return Err(ParseError(format!("{}:{}: @setup requires a file path", path, line_num)));
}
directives.push(Directive::Setup { path: setup_path.to_string(), line: line_num });
} else if let Some(rest) = line.strip_prefix("@def ") {
let (name, body, lines_consumed) = parse_def_directive(path, rest, line_num, &raw_lines[i..])?;
directives.push(Directive::Def { name, body, line: line_num });
i += lines_consumed;
continue;
} else if let Some(rest) = line.strip_prefix("@teardown ") {
if rest.trim().is_empty() {
return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
}
teardown_commands.push(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(rest).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
} else if line.starts_with("@env ") {
let (key, value) = parse_env_directive(path, line, line_num)?;
directives.push(Directive::Env { key, value, line: line_num });
} else {
return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
}
i += 1;
continue;
}
if is_comment_line(line) {
seen_command = true;
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
current = None;
}
} else if line.starts_with("\\$ ") && current.is_some() {
// Escaped dollar-space: literal expected output starting with "$ "
current.as_mut().unwrap().expected.push(line[1..].to_string());
} else if line.starts_with("\\#") && current.is_some() {
// Escaped hash: literal expected output starting with "#"
current.as_mut().unwrap().expected.push(line[1..].to_string());
} else if line.starts_with('#') {
// Comment line
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
current = None;
}
} else if line.starts_with("$ ") {
seen_command = true;
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
}
current = Some(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(&line[2..]).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
} else if let Some(ref mut cmd) = current {
cmd.expected.push(line.to_string());
}
i += 1;
}
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
}
Ok(ShoutFile {
path: path.to_string(),
commands,
directives,
teardown_commands,
})
}

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 }
}

484
src/run.rs Normal file
View File

@ -0,0 +1,484 @@
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use crate::parse::{self, ShoutFile};
#[derive(Debug, Clone)]
pub struct CommandResult {
pub command: parse::Command,
pub actual: Vec<String>,
pub exit_code: i32,
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct FileResult {
pub file: ShoutFile,
pub results: Vec<CommandResult>,
pub tmp_dir: String,
pub error: Option<String>,
}
pub struct RunOptions {
pub clean_env: bool,
pub path_dirs: Vec<String>,
pub env_vars: Vec<(String, String)>,
pub source_dir: Option<String>,
pub project_dir: Option<String>,
pub timeout_ms: u64,
pub verbose: bool,
}
const SENTINEL_PREFIX: &str = "__SHOUT_SENTINEL_";
const VERBOSE_MARKER: &str = "__SHOUT_CMD_";
fn build_script(commands: &[parse::Command], verbose: bool) -> String {
let mut lines = Vec::new();
if verbose {
lines.push("exec 3>&2 2>&1 9>&1".to_string());
} else {
lines.push("exec 2>&1 9>&1".to_string());
}
for (i, cmd) in commands.iter().enumerate() {
if verbose {
lines.push(format!("printf '{VERBOSE_MARKER}{i}\\n' >&3"));
}
lines.push("__shout_out=$(mktemp)".to_string());
lines.push("exec 1>\"$__shout_out\" 2>&1".to_string());
lines.push(cmd.command.clone());
lines.push("__shout_ec=$?".to_string());
lines.push("exec 1>&9 2>&1".to_string());
lines.push("cat \"$__shout_out\"".to_string());
lines.push("rm -f \"$__shout_out\"".to_string());
lines.push(format!(
"printf '\\n{SENTINEL_PREFIX}%s_{i}__\\n' \"$__shout_ec\""
));
}
lines.join("\n") + "\n"
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for fc in chars.by_ref() {
if ('@'..='~').contains(&fc) {
break;
}
}
}
} else if c == '\u{009b}' {
for fc in chars.by_ref() {
if ('@'..='~').contains(&fc) {
break;
}
}
} else {
result.push(c);
}
}
result
}
/// Parse the suffix of a sentinel starting right after SENTINEL_PREFIX.
/// Expected format: `{exit_code}_{index}__`
/// Returns (exit_code, index, bytes_consumed) or None.
fn parse_sentinel_suffix(s: &str) -> Option<(i32, usize, usize)> {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i == 0 || b.get(i) != Some(&b'_') {
return None;
}
let exit_code: i32 = s[..i].parse().ok()?;
i += 1;
let j = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i == j || !s[i..].starts_with("__") {
return None;
}
let index: usize = s[j..i].parse().ok()?;
Some((exit_code, index, i + 2))
}
/// Find the next sentinel in `s` at or after byte offset `from`.
/// Returns (start, exit_code, index, end) or None.
fn find_sentinel(s: &str, from: usize) -> Option<(usize, i32, usize, usize)> {
let mut search = from;
while let Some(rel) = s[search..].find(SENTINEL_PREFIX) {
let abs = search + rel;
let after = abs + SENTINEL_PREFIX.len();
if let Some((exit_code, index, len)) = parse_sentinel_suffix(&s[after..]) {
return Some((abs, exit_code, index, after + len));
}
search = abs + 1;
}
None
}
fn parse_sentinel_output(raw: &str, command_count: usize) -> (Vec<Vec<String>>, Vec<i32>) {
let mut outputs = Vec::new();
let mut exit_codes = Vec::new();
let mut pos = 0;
for _i in 0..command_count {
if let Some((start, exit_code, _idx, end)) = find_sentinel(raw, pos) {
let before = &raw[pos..start];
let mut lines: Vec<String> = before.split('\n').map(|s| s.to_string()).collect();
// Remove leading empty line (from printf \n prefix)
if !lines.is_empty() && lines[0].is_empty() {
lines.remove(0);
}
// Remove trailing empty lines
lines = parse::trim_trailing_empty(&lines);
if lines.len() == 1 && lines[0].is_empty() {
lines.clear();
}
outputs.push(lines);
exit_codes.push(exit_code);
pos = end;
if raw.as_bytes().get(pos) == Some(&b'\n') {
pos += 1;
}
} else {
// No sentinel found — rest is output for this command
let mut lines: Vec<String> = raw[pos..].split('\n').map(|s| s.to_string()).collect();
if !lines.is_empty() && lines[0].is_empty() {
lines.remove(0);
}
lines = parse::trim_trailing_empty(&lines);
outputs.push(lines);
exit_codes.push(1); // assume failure
break;
}
}
// Fill missing entries
while outputs.len() < command_count {
outputs.push(vec![]);
exit_codes.push(1);
}
(outputs, exit_codes)
}
fn make_tmp_dir() -> std::io::Result<String> {
let base = std::env::temp_dir();
// Create a unique temp directory
loop {
let suffix: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let dir = base.join(format!("shout-{suffix}"));
if !dir.exists() {
std::fs::create_dir_all(&dir)?;
return Ok(dir.to_string_lossy().to_string());
}
}
}
fn kill_tree(pid: u32) {
// Find processes in the same process group
if let Ok(output) = Command::new("ps")
.args(["-eo", "pid,pgid"])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
let pgid = pid.to_string();
for line in text.lines() {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() >= 2 && parts[1] == pgid {
if let Ok(p) = parts[0].parse::<i32>() {
if p as u32 != pid && p > 1 {
unsafe { libc::kill(p, libc::SIGKILL); }
}
}
}
}
}
// Kill the process group
unsafe { libc::kill(-(pid as i32), libc::SIGKILL); }
}
pub fn run_file(
file: &ShoutFile,
options: &RunOptions,
on_command: Option<&dyn Fn(&parse::Command)>,
on_command_result: Option<&dyn Fn(usize, &CommandResult)>,
) -> FileResult {
let tmp_dir = match make_tmp_dir() {
Ok(d) => d,
Err(e) => {
return FileResult {
file: file.clone(),
results: vec![],
tmp_dir: String::new(),
error: Some(format!("Failed to create temp dir: {e}")),
};
}
};
if file.commands.is_empty() {
return FileResult {
file: file.clone(),
results: vec![],
tmp_dir,
error: None,
};
}
let verbose = options.verbose && on_command.is_some();
let script = build_script(&file.commands, verbose);
let mut cmd = Command::new("/bin/sh");
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(&tmp_dir);
// Set up process group (detached)
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
// Environment
if options.clean_env {
cmd.env_clear();
}
cmd.env("HOME", &tmp_dir);
cmd.env("SHOUT_DIR", &tmp_dir);
if let Some(ref source_dir) = options.source_dir {
cmd.env("SHOUT_SOURCE_DIR", source_dir);
}
if let Some(ref project_dir) = options.project_dir {
cmd.env("SHOUT_PROJECT_DIR", project_dir);
}
for (key, value) in &options.env_vars {
cmd.env(key, value);
}
if !options.path_dirs.is_empty() {
let existing = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{existing}", options.path_dirs.join(":"));
cmd.env("PATH", new_path);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return FileResult {
file: file.clone(),
results: vec![],
tmp_dir,
error: Some(format!("Failed to spawn shell: {e}")),
};
}
};
let pid = child.id();
// Stream verbose markers from stderr in a separate thread
if verbose {
let stderr = child.stderr.take().unwrap();
let commands = file.commands.clone();
let _handle = std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(stderr);
let mut buf = String::new();
let mut byte = [0u8; 1];
while reader.read(&mut byte).unwrap_or(0) > 0 {
if byte[0] == b'\n' {
if buf.starts_with(VERBOSE_MARKER) {
if let Ok(idx) = buf[VERBOSE_MARKER.len()..].parse::<usize>() {
if idx < commands.len() {
let _ = write!(
std::io::stderr(),
"\x1b[2m $ {}\x1b[0m\n",
commands[idx].command
);
}
}
}
buf.clear();
} else {
buf.push(byte[0] as char);
}
}
});
}
// Write script to stdin
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(script.as_bytes());
// stdin drops here, closing the pipe
}
// Read stdout with timeout
let total_timeout_ms = options.timeout_ms * file.commands.len() as u64;
let stdout = child.stdout.take().unwrap();
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let last_sentinel_suffix = format!("_{}_", file.commands.len() - 1);
let sentinel_prefix = SENTINEL_PREFIX.to_string();
let reader_thread = std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(stdout);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let mut accumulated = String::new();
let deadline = std::time::Instant::now() + Duration::from_millis(total_timeout_ms);
let mut timed_out = false;
let mut sentinels_reported: usize = 0;
let mut last_sentinel_end: usize = 0;
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
timed_out = true;
break;
}
match rx.recv_timeout(remaining) {
Ok(chunk) => {
accumulated.push_str(&String::from_utf8_lossy(&chunk));
// Stream command results as they come in
if let Some(on_result) = on_command_result {
loop {
if let Some((start, exit_code, idx, end)) = find_sentinel(&accumulated, last_sentinel_end) {
if idx >= sentinels_reported {
let output_slice = &accumulated[last_sentinel_end..start];
let mut lines: Vec<String> = output_slice.split('\n').map(|s| s.to_string()).collect();
if !lines.is_empty() && lines[0].is_empty() {
lines.remove(0);
}
lines = parse::trim_trailing_empty(&lines);
if lines.len() == 1 && lines[0].is_empty() {
lines.clear();
}
let result = CommandResult {
command: file.commands[idx].clone(),
actual: lines.iter().map(|l| strip_ansi(l)).collect(),
exit_code,
};
on_result(idx, &result);
sentinels_reported = idx + 1;
last_sentinel_end = end;
if accumulated.as_bytes().get(last_sentinel_end) == Some(&b'\n') {
last_sentinel_end += 1;
}
} else {
last_sentinel_end = end + 1;
}
} else {
break;
}
}
}
// Check if we've seen the last sentinel
if let Some(prefix_idx) = accumulated.rfind(&sentinel_prefix) {
if accumulated[prefix_idx..].contains(&last_sentinel_suffix) {
break;
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
timed_out = true;
break;
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = reader_thread.join();
// Kill the process tree
kill_tree(pid);
let _ = child.wait();
if timed_out {
return FileResult {
file: file.clone(),
results: vec![],
tmp_dir,
error: Some("Timeout reading output".to_string()),
};
}
let (outputs, exit_codes) = parse_sentinel_output(&accumulated, file.commands.len());
let results: Vec<CommandResult> = file
.commands
.iter()
.enumerate()
.map(|(i, cmd)| CommandResult {
command: cmd.clone(),
actual: outputs
.get(i)
.unwrap_or(&vec![])
.iter()
.map(|l| strip_ansi(l))
.collect(),
exit_code: exit_codes.get(i).copied().unwrap_or(1),
})
.collect();
FileResult {
file: file.clone(),
results,
tmp_dir,
error: None,
}
}
pub fn cleanup_tmp_dir(dir: &str) {
let _ = std::fs::remove_dir_all(dir);
}
/// Check if a command result passes.
pub fn command_passes(result: &CommandResult) -> bool {
use crate::matching::match_output;
let output_matches = match_output(&result.command.expected, &result.actual);
let exit_code_mismatch = match &result.command.exit_code {
parse::ExitCode::Default => result.exit_code != 0,
parse::ExitCode::Any => result.exit_code == 0,
parse::ExitCode::Code(expected) => result.exit_code != *expected,
};
output_matches && !exit_code_mismatch
}

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 })
}

115
src/update.rs Normal file
View File

@ -0,0 +1,115 @@
use crate::matching::match_output;
use crate::parse::{ShoutFile, is_comment_line, trim_trailing_empty};
use crate::run::CommandResult;
fn escape_output(line: &str) -> String {
if line.starts_with("$ ") || line.starts_with('#') {
format!("\\{line}")
} else {
line.to_string()
}
}
pub fn rewrite_file(
file: &ShoutFile,
results: &[CommandResult],
original_content: &str,
) -> String {
let lines: Vec<&str> = original_content.split('\n').collect();
let mut output: Vec<String> = Vec::new();
let mut cmd_idx = 0;
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if is_comment_line(line) {
output.push(line.to_string());
i += 1;
} else if line.starts_with("$ ") && !line.starts_with("\\$ ") {
// Emit the command line as-is
output.push(line.to_string());
let cmd = file.commands.get(cmd_idx);
let result = results.get(cmd_idx);
if cmd.is_none() || result.is_none() {
cmd_idx += 1;
i += 1;
continue;
}
let cmd = cmd.unwrap();
let result = result.unwrap();
// Skip past old expected output lines in the original
let mut j = i + 1;
while j < lines.len()
&& !(is_comment_line(lines[j]) && !lines[j].starts_with("\\#"))
&& !(lines[j].starts_with("$ ") && !lines[j].starts_with("\\$ "))
{
j += 1;
}
// Collect old expected lines
let old_expected_raw: Vec<String> = lines[i + 1..j].iter().map(|s| s.to_string()).collect();
// Check if old expected output had an exit code marker
let old_trimmed = trim_trailing_empty(&old_expected_raw);
let old_exit_marker = if let Some(last) = old_trimmed.last() {
if last.starts_with('[') && last.ends_with(']') {
let inner = &last[1..last.len() - 1];
if inner == "*" || inner.parse::<i32>().is_ok() {
Some(last.clone())
} else {
None
}
} else {
None
}
} else {
None
};
// Count trailing blank lines
let mut trailing_blanks = 0;
for k in (0..old_expected_raw.len()).rev() {
if old_expected_raw[k].is_empty() {
trailing_blanks += 1;
} else {
break;
}
}
// If wildcards match, keep original expected output
if match_output(&cmd.expected, &result.actual) {
for ol in &old_expected_raw {
output.push(ol.clone());
}
} else {
// Replace with actual output
for al in &result.actual {
output.push(escape_output(al));
}
// Re-add exit code marker if it existed
if let Some(marker) = old_exit_marker {
output.push(marker);
}
// Preserve trailing blank lines as separators
for _ in 0..trailing_blanks {
output.push(String::new());
}
}
i = j;
cmd_idx += 1;
} else if cmd_idx == 0 {
// Lines before first command (directives, etc.)
output.push(line.to_string());
i += 1;
} else {
i += 1;
}
}
output.join("\n")
}

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,5 +0,0 @@
$ echo hello # this is a comment
hello
$ echo "keep # this"
keep # this

15
tests/comments.shout Normal file
View File

@ -0,0 +1,15 @@
$ echo hello # this is a comment
hello
$ echo "keep # this"
keep # this
# this is a comment
$ echo "after comment"
after comment
$ echo "#hashtag"
\#hashtag
$ echo "https://google.com/#autoload"
https://google.com/#autoload

5
tests/def-override.shout Normal file
View File

@ -0,0 +1,5 @@
@setup def-shared.shout
@def serve echo "overridden"
$ serve
overridden

4
tests/def-setup.shout Normal file
View File

@ -0,0 +1,4 @@
@setup def-shared.shout
$ serve
server started

1
tests/def-shared.shout Normal file
View File

@ -0,0 +1 @@
@def serve echo "server started"

13
tests/def.shout Normal file
View File

@ -0,0 +1,13 @@
@def greet echo "hello world"
@def multi echo "line one"; \
echo "line two"
$ greet
hello world
$ multi
line one
line two
$ echo "not a macro"
not a macro

16
tests/shout.rs Normal file
View File

@ -0,0 +1,16 @@
use std::process::Command;
fn shout() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_shout"));
cmd.current_dir(concat!(env!("CARGO_MANIFEST_DIR")));
cmd
}
#[test]
fn test_suite_passes() {
let status = shout()
.args(["test", "tests/"])
.status()
.unwrap();
assert!(status.success(), "shout test tests/ exited with {status}");
}

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": "."
}
}

View File

@ -34,11 +34,12 @@ ln -s /path/to/shout/vim ~/.local/share/nvim/site/pack/shout/start/shout
| Pattern | Highlight | | Pattern | Highlight |
|---|---| |---|---|
| `$ command` | Statement (prompt as Special) | | `$ command` | Statement (prompt as Special) |
| `$# comment` | Comment | | `# comment` | Comment |
| `@env KEY=VALUE` | PreProc / Identifier / String | | `@env KEY=VALUE` | PreProc / Identifier / String |
| `@setup`, `@teardown` | PreProc | | `@setup`, `@teardown` | PreProc |
| Expected output | String | | Expected output | String |
| `...` (wildcard) | WarningMsg | | `...` (wildcard) | WarningMsg |
| `[N]`, `[*]` (exit code) | Constant | | `[N]`, `[*]` (exit code) | Constant |
| `\$ ...` (escaped dollar) | SpecialChar + String | | `\$ ...` (escaped dollar) | SpecialChar + String |
| `\# ...` (escaped hash) | SpecialChar + String |
| `# inline comment` | Comment | | `# inline comment` | Comment |

View File

@ -1,5 +1,5 @@
-- Buffer-local settings for .shout files -- Buffer-local settings for .shout files
vim.bo.commentstring = "$# %s" vim.bo.commentstring = "# %s"
vim.bo.shiftwidth = 0 vim.bo.shiftwidth = 0
vim.bo.tabstop = 2 vim.bo.tabstop = 2
vim.bo.expandtab = true vim.bo.expandtab = true

View File

@ -16,7 +16,8 @@ syn match shoutEnvValue /.*$/ contained
syn match shoutSetupDirective /^@setup\s\+.*$/ contains=shoutDirectiveKey syn match shoutSetupDirective /^@setup\s\+.*$/ contains=shoutDirectiveKey
syn match shoutTeardownDirective /^@teardown\s\+.*$/ contains=shoutDirectiveKey syn match shoutTeardownDirective /^@teardown\s\+.*$/ contains=shoutDirectiveKey
" Comment commands: $# ... or $ # ... " Comment lines: # ..., $# ..., or $ # ...
syn match shoutCommentCommand /^#.*$/
syn match shoutCommentCommand /^\$#.*$/ syn match shoutCommentCommand /^\$#.*$/
syn match shoutCommentCommand /^\$\s\+#.*$/ syn match shoutCommentCommand /^\$\s\+#.*$/
@ -25,9 +26,11 @@ syn match shoutPrompt /^\$\s/ contained
syn match shoutCommand /^\$\s.\+/ contains=shoutPrompt,shoutInlineComment syn match shoutCommand /^\$\s.\+/ contains=shoutPrompt,shoutInlineComment
syn match shoutInlineComment /\s\+#[^"']*$/ contained syn match shoutInlineComment /\s\+#[^"']*$/ contained
" Escaped dollar in expected output " Escaped dollar/hash in expected output
syn match shoutEscapedDollar /^\\\$/ contained syn match shoutEscapedChar /^\\\$/ contained
syn match shoutEscapedLine /^\\\$.*$/ contains=shoutEscapedDollar syn match shoutEscapedChar /^\\#/ contained
syn match shoutEscapedLine /^\\\$.*$/ contains=shoutEscapedChar
syn match shoutEscapedLine /^\\#.*$/ contains=shoutEscapedChar
" Wildcards " Wildcards
syn match shoutWildcardLine /^\.\.\.$/ syn match shoutWildcardLine /^\.\.\.$/
@ -55,7 +58,7 @@ hi def link shoutPrompt Special
hi def link shoutCommand Statement hi def link shoutCommand Statement
hi def link shoutInlineComment Comment hi def link shoutInlineComment Comment
hi def link shoutEscapedDollar SpecialChar hi def link shoutEscapedChar SpecialChar
hi def link shoutEscapedLine String hi def link shoutEscapedLine String
hi def link shoutWildcardLine WarningMsg hi def link shoutWildcardLine WarningMsg

View File

@ -11,13 +11,16 @@
:root { :root {
--bg: #fff; --bg: #fff;
--fg: #444; --fg: #444;
--bright: #1a1a1a; --bright: #000;
--green: #1a7f37; --green: #1a7f37;
--red: #cf222e; --red: #cf222e;
--dim: #888; --dim: #888;
--accent: #1a7f37; --accent: #1a7f37;
--code-bg: #f5f5f5; --code-bg: #f5f5f5;
--border: #ddd; --border: #ddd;
--yellow: #9a7200;
--install-bg: #f0faf2;
--install-border: #c5e4cc;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -31,83 +34,176 @@
--accent: #4ec966; --accent: #4ec966;
--code-bg: #111; --code-bg: #111;
--border: #222; --border: #222;
--yellow: #e5b567;
--install-bg: #0d1a10;
--install-border: #1a3d20;
} }
} }
html { font-size: 16px; }
body { body {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Consolas', monospace; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Consolas', monospace;
line-height: 1.6; line-height: 1.6;
padding: 0 1.5rem; padding: 0 24px;
max-width: 680px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* ---- Hero ---- */
header { header {
padding: 6rem 0 2.5rem; padding: 64px 0 0;
} }
h1 { h1 {
font-size: 2.5rem; font-size: 64px;
font-weight: 700; font-weight: 800;
color: var(--bright); color: var(--bright);
letter-spacing: -0.03em; letter-spacing: -0.04em;
line-height: 1;
display: flex;
align-items: baseline;
vertical-align: baseline;
} }
h1 span { h1 .dollar {
color: var(--accent); color: var(--accent);
margin-right: 10px;
} }
.tagline {
font-size: 1.1rem; .subtitle {
font-size: 20px;
color: var(--dim); color: var(--dim);
margin-top: 0.5rem; margin-top: 8px;
font-weight: 400;
} }
/* ---- Install ---- */
.install { .install {
margin-top: 2rem; margin-top: 32px;
display: inline-block; display: flex;
align-items: center;
justify-content: space-between;
background: var(--install-bg);
border: 1px solid var(--install-border);
padding: 11px 11px 11px 19px;
border-radius: 8px;
font-size: 14px;
color: var(--bright);
}
.install code {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.install .prompt { color: var(--dim); }
.install .cmd { color: var(--bright); }
.copy-btn {
background: var(--accent);
color: #fff;
border: none;
padding: 7px 14px;
border-radius: 5px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
flex-shrink: 0;
margin-left: 16px;
}
@media (prefers-color-scheme: dark) {
.copy-btn { color: #0a0a0a; }
}
.copy-btn:hover {
opacity: 0.85;
}
/* ---- Section boxes ---- */
.section-box {
background: var(--code-bg); background: var(--code-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0.6rem 1.2rem; border-radius: 8px;
border-radius: 4px; padding: 20px;
font-size: 0.9rem; padding-top: 16px;
color: var(--bright); margin-bottom: 24px;
cursor: pointer;
position: relative;
transition: border-color 0.15s;
} }
.install:hover { .two-col {
border-color: var(--accent); display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 32px;
align-items: start;
} }
.install .hint { .two-col .explain h2 {
color: var(--dim); margin-bottom: 16px;
font-size: 0.75rem;
margin-left: 1rem;
} }
section { .two-col .explain p {
padding: 0 0 2em 0; margin-bottom: 14px;
font-size: 15px;
}
.two-col .explain .feature {
margin-bottom: 11px;
font-size: 14px;
}
.two-col .explain .feature code {
padding: 2px 0px;
border-radius: 3px;
font-size: 13px;
}
.section-box pre {
background: var(--bg);
margin-bottom: 0;
}
.section-box pre + pre {
margin-top: 12px;
}
.section-box p:last-child {
margin-bottom: 0;
}
@media (max-width: 680px) {
.two-col {
grid-template-columns: 1fr;
gap: 24px;
}
.section-box {
padding: 19px;
}
} }
h2 { h2 {
font-size: 0.85rem; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--dim); color: var(--fg);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin-bottom: 1.5rem; margin-bottom: 16px;
}
section {
padding: 0 0 32px 0;
} }
p { p {
margin-bottom: 1rem; margin-bottom: 16px;
font-size: 0.95rem; font-size: 15px;
} }
.bright { color: var(--bright); } .bright { color: var(--bright); }
@ -118,19 +214,19 @@
pre { pre {
background: var(--code-bg); background: var(--code-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 6px;
padding: 1.2rem 1.4rem; padding: 19px 22px;
overflow-x: auto; overflow-x: auto;
font-size: 0.85rem; font-size: 13px;
line-height: 1.7; line-height: 1.7;
margin-bottom: 1.5rem; margin-bottom: 24px;
} }
pre code { pre code {
color: var(--fg); color: var(--fg);
} }
.prompt { color: var(--dim); } .prompt { color: var(--yellow); }
.cmd { color: var(--bright); } .cmd { color: var(--bright); }
.output { color: var(--fg); } .output { color: var(--fg); }
.comment { color: var(--dim); font-style: italic; } .comment { color: var(--dim); font-style: italic; }
@ -138,12 +234,11 @@
.exit-code { color: var(--red); } .exit-code { color: var(--red); }
.pass { color: var(--green); } .pass { color: var(--green); }
footer { footer {
padding: 3rem 0; padding: 48px 0;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
color: var(--dim); color: var(--dim);
font-size: 0.8rem; font-size: 13px;
} }
footer a { footer a {
@ -165,27 +260,38 @@
} }
@media (max-width: 520px) { @media (max-width: 520px) {
header { padding: 3rem 0 2rem; } header { padding: 40px 0 0; }
h1 { font-size: 2rem; } h1 { font-size: 44px; }
section { padding: 2rem 0; } .subtitle { font-size: 16px; }
} }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1><span>$</span> shout</h1> <h1><span class="dollar">$</span>shout</h1>
<p class="tagline">shell output tester</p> <p class="subtitle">shell output tester</p>
<div class="install" onclick="navigator.clipboard.writeText('bun install -g @because/shout --registry=https://npm.nose.space')"> <div class="install">
<span class="prompt">$</span> <span class="cmd">bun install -g @because/shout --registry=https://npm.nose.space</span> <code><span class="prompt">$</span> <span class="cmd">curl -fsSL https://because.sh/shout | sh</span></code>
<span class="hint">click to copy</span> <button class="copy-btn" onclick="copyInstall(this)">Copy</button>
</div> </div>
</header> </header>
<section> <div style="padding: 40px 0 0;">
<h2>Write a test</h2> <div class="section-box">
<p>A <code>.shout</code> file is just a shell session. Commands start with <code class="bright">$</code>, everything else is expected output.</p> <div class="two-col">
<pre><code><span class="prompt">$</span> <span class="cmd">echo hello</span> <div class="explain">
<h2>✓ Write a test</h2>
<p>Each <code class="bright">.shout</code> file is a session.</p>
<p class="feature">Commands start with <code class="prompt">$</code>.</p>
<p class="feature">Everything else is expected output.</p>
<p class="feature"><code class="wildcard">...</code> matches anything &mdash; inline or across lines.</p>
<p class="feature"><code class="exit-code">[1]</code> asserts the exit code. Default expects 0.</p>
<p class="feature">Lines starting with <code class="dim">#</code> are comments.</p>
<p class="feature">(<code class="dim">\#</code> matches output starting with <code class="dim">#</code>.)</p>
</div>
<div class="example">
<pre><code><span class="prompt">$</span> <span class="cmd">echo hello</span>
<span class="output">hello</span> <span class="output">hello</span>
<span class="prompt">$</span> <span class="cmd">ls missing</span> <span class="prompt">$</span> <span class="cmd">ls missing</span>
@ -193,44 +299,79 @@
<span class="exit-code">[1]</span> <span class="exit-code">[1]</span>
<span class="prompt">$</span> <span class="cmd">brew --version</span> <span class="prompt">$</span> <span class="cmd">brew --version</span>
<span class="output">Homebrew 5</span><span class="wildcard">...</span></code></pre> <span class="output">Homebrew 5</span><span class="wildcard">...</span>
<p><code class="wildcard">...</code> matches anything &mdash; inline or across lines.</p>
<p><code class="exit-code">[1]</code> asserts the exit code.</p> <span class="comment"># start the server</span>
<p><code class="dim">$#</code> starts a comment line &mdash; not executed, no output expected.</p>
<pre><code><span class="prompt">$</span><span class="comment"># start the server</span>
<span class="prompt">$</span> <span class="cmd">my-server &amp;</span> <span class="prompt">$</span> <span class="cmd">my-server &amp;</span>
<span class="prompt">$</span><span class="comment"># now test it</span>
<span class="prompt">$</span> <span class="cmd">curl localhost:8080</span> <span class="prompt">$</span> <span class="cmd">curl localhost:8080</span>
<span class="output">OK</span></code></pre> <span class="output">OK</span></code></pre>
</section> </div>
</div>
</div>
</div>
<section> <div class="section-box">
<h2>Setup &amp; teardown</h2> <div class="two-col">
<p>Use <code class="bright">@setup</code> to share commands across test files. Use <code class="bright">@teardown</code> to clean up after tests &mdash; it runs regardless of pass/fail.</p> <div class="explain">
<pre><code><span class="comment"># setup.shout</span> <h2>✓ Setup &amp; teardown</h2>
<p>Use <code class="bright">@setup</code> to share commands across test files.</p>
<p>Use <code class="bright">@teardown</code> to clean up after tests &mdash; it runs regardless of pass/fail.</p>
<p><code>@teardown</code> can appear in both <code>.shout</code> files and setup files. Teardown failures produce warnings but don't affect test results.</p>
</div>
<div class="example">
<pre><code><span class="comment"># setup.shout</span>
<span class="output">export DB_URL=sqlite:data/test.db</span> <span class="output">export DB_URL=sqlite:data/test.db</span>
<span class="bright">@teardown</span> <span class="cmd">rm -f "$SHOUT_PROJECT_DIR/data/test.db"</span></code></pre> <span class="bright">@teardown</span> <span class="cmd">rm -f "$SHOUT_PROJECT_DIR/data/test.db"</span></code></pre>
<pre><code><span class="bright">@setup</span> <span class="cmd">setup.shout</span> <pre><code><span class="bright">@setup</span> <span class="cmd">setup.shout</span>
<span class="bright">@teardown</span> <span class="cmd">rm -f /tmp/extra-cleanup</span> <span class="bright">@teardown</span> <span class="cmd">rm -f /tmp/extra-cleanup</span>
<span class="prompt">$</span> <span class="cmd">create-db &amp;&amp; run-tests</span> <span class="prompt">$</span> <span class="cmd">create-db &amp;&amp; run-tests</span>
<span class="wildcard">...</span></code></pre> <span class="wildcard">...</span></code></pre>
<p><code>@teardown</code> can appear in both <code>.shout</code> files and setup files. Teardown failures produce warnings but don't affect test results.</p> </div>
</section> </div>
</div>
<section> <div class="section-box">
<h2>Run it</h2> <div class="two-col">
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span> <div class="explain">
<h2>✓ Macros</h2>
<p>Use <code class="bright">@def</code> to define reusable command macros.</p>
<p>If a command matches a macro name exactly, the body is substituted. Use <code>\</code> for multi-line bodies. Macros from setup files are inherited; user-file macros override them.</p>
</div>
<div class="example">
<pre><code><span class="bright">@def</span> <span class="cmd">greet echo "hello world"</span>
<span class="prompt">$</span> <span class="cmd">greet</span>
<span class="output">hello world</span></code></pre>
</div>
</div>
</div>
<div class="section-box">
<div class="two-col">
<div class="explain">
<h2>✓ Run it</h2>
<p>Each file gets a fresh temp directory and its own <code>/bin/sh</code> session. State carries between commands within a file.</p>
</div>
<div class="example">
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span>
<span class="pass">............... <span class="pass">...............
15 passed</span> <span class="dim">in 23ms</span></code></pre> 15 passed</span> <span class="dim">in 23ms</span></code></pre>
<p>Each file gets a fresh temp directory and its own <code>/bin/sh</code> session. State carries between commands within a file.</p> </div>
</section> </div>
</div>
<section> <div class="section-box">
<h2>Update expectations</h2> <div class="two-col">
<pre><code><span class="prompt">$</span> <span class="cmd">shout test --update</span></code></pre> <div class="explain">
<p>Rewrites your <code>.shout</code> files with the actual output. No more copy-pasting from the terminal.</p> <h2>✓ Update expectations</h2>
</section> <p>Rewrites your <code>.shout</code> files with the actual output. No more copy-pasting from the terminal.</p>
</div>
<div class="example">
<pre><code><span class="prompt">$</span> <span class="cmd">shout test --update</span></code></pre>
</div>
</div>
</div>
<section> <section>
<h2>Usage</h2> <h2>Usage</h2>
@ -258,12 +399,12 @@ Options:
<section> <section>
<h2>Environment</h2> <h2>Environment</h2>
<p>Shout sets these variables before running your commands:</p> <p>Shout sets these variables before running your commands:</p>
<pre><code><span class="bright">HOME</span> <span class="dim"></span> <span class="output">temp directory for this test file</span> <pre><code><span class="bright">HOME</span> <span class="dim"></span> <span class="output">temp directory for this test file</span>
<span class="bright">SHOUT_DIR</span> <span class="dim"></span> <span class="output">same temp directory</span> <span class="bright">SHOUT_DIR</span> <span class="dim"></span> <span class="output">same temp directory</span>
<span class="bright">SHOUT_SOURCE_DIR</span> <span class="dim"></span> <span class="output">directory containing the .shout file</span> <span class="bright">SHOUT_SOURCE_DIR</span> <span class="dim"></span> <span class="output">directory containing the .shout file</span>
<span class="bright">SHOUT_PROJECT_DIR</span> <span class="dim"></span> <span class="output">directory where shout was invoked</span> <span class="bright">SHOUT_PROJECT_DIR</span> <span class="dim"></span> <span class="output">directory where shout was invoked</span>
<span class="bright">PORT</span> <span class="dim"></span> <span class="output">auto-assigned from 5400 (or --port-from), increments per file</span> <span class="bright">PORT</span> <span class="dim"></span> <span class="output">auto-assigned from 5400 (or --port-from), increments per file</span>
<span class="bright">PATH</span> <span class="dim"></span> <span class="output">prepended with --path dirs, if any</span></code></pre> <span class="bright">PATH</span> <span class="dim"></span> <span class="output">prepended with --path dirs, if any</span></code></pre>
<p>Each file runs in its own temp directory. <code>--clean-env</code> starts with an empty environment instead of inheriting yours.</p> <p>Each file runs in its own temp directory. <code>--clean-env</code> starts with an empty environment instead of inheriting yours.</p>
</section> </section>
@ -271,5 +412,13 @@ Options:
<a href="https://github.com/because/shout">GitHub</a> &middot; <a href="https://www.npmjs.com/package/@because/shout">npm</a> <a href="https://github.com/because/shout">GitHub</a> &middot; <a href="https://www.npmjs.com/package/@because/shout">npm</a>
</footer> </footer>
<script>
function copyInstall(btn) {
navigator.clipboard.writeText('curl -fsSL https://because.sh/shout | sh');
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
}
</script>
</body> </body>
</html> </html>