Compare commits
9 Commits
0c9d4a27ce
...
6869628353
| Author | SHA1 | Date | |
|---|---|---|---|
| 6869628353 | |||
| aba7f9676d | |||
| eef0f4d2dc | |||
| d92078fa8f | |||
| db5a7d66e6 | |||
| 4f42cd4887 | |||
| 7ca6e12263 | |||
| 1dffe5f98a | |||
| 175899001a |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
target/
|
||||
.sandlot/
|
||||
.dev/
|
||||
|
||||
|
|
|
|||
|
|
@ -66,14 +66,14 @@ Transcript-based shell integration test runner. Bun + TypeScript.
|
|||
1. `src/parse.ts` — update types (`Directive`, `ShoutFile`, `Command`) and both parsers (`parse` + `parseSetup`)
|
||||
2. `src/parse.test.ts` — unit tests for parsing the new syntax in both `.shout` and setup file contexts
|
||||
3. `src/cli/index.ts` — wire up the parsed result in `runOne` (directive resolution, command merging, result handling)
|
||||
4. `test/*.shout` — integration test file exercising the feature end-to-end
|
||||
4. `tests/*.shout` — integration test file exercising the feature end-to-end
|
||||
5. `CLAUDE.md` — update `.shout file format` section
|
||||
6. `README.md` — update Directives section
|
||||
7. `web/index.html` — add or update a section on the website
|
||||
8. Run `bun test` and `bun run src/cli/index.ts test test/` to verify
|
||||
8. Run `cargo test` to verify
|
||||
|
||||
## Style
|
||||
|
||||
- Strict TypeScript, Bun runtime
|
||||
- No classes — plain functions and types
|
||||
- Tests in `src/*.test.ts`, example `.shout` files in `test/`
|
||||
- Integration tests in `tests/shout.rs`, example `.shout` files in `tests/`
|
||||
|
|
|
|||
113
Cargo.lock
generated
Normal file
113
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "shout"
|
||||
version = "0.0.18"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rayon",
|
||||
"regex",
|
||||
]
|
||||
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "shout"
|
||||
version = "0.0.18"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
rayon = "1"
|
||||
regex = "1"
|
||||
57
STDIN.md
57
STDIN.md
|
|
@ -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.
|
||||
38
bun.lock
38
bun.lock
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
31
package.json
31
package.json
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "@because/shout",
|
||||
"version": "0.0.19",
|
||||
"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": "*"
|
||||
}
|
||||
}
|
||||
333
src/cli/index.ts
333
src/cli/index.ts
|
|
@ -1,333 +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 setupMacros: Record<string, string> = {}
|
||||
const userMacros: 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
|
||||
else if (sd.type === "def") setupMacros[sd.name] = sd.body
|
||||
}
|
||||
setupCommands.push(...setupParsed.commands)
|
||||
teardownCommands.push(...setupParsed.teardownCommands)
|
||||
} else if (d.type === "env") {
|
||||
userEnvVars[d.key] = d.value
|
||||
} else if (d.type === "def") {
|
||||
userMacros[d.name] = d.body
|
||||
}
|
||||
}
|
||||
Object.assign(envVars, setupEnvVars, userEnvVars)
|
||||
if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
|
||||
envVars["PORT"] = String(port)
|
||||
}
|
||||
const macros: Record<string, string> = Object.assign({}, setupMacros, userMacros)
|
||||
const expandMacro = (cmd: Command): Command => {
|
||||
const body = macros[cmd.command]
|
||||
return body !== undefined ? { ...cmd, command: body } : cmd
|
||||
}
|
||||
const merged: ShoutFile = {
|
||||
...parsed,
|
||||
commands: [...setupCommands, ...parsed.commands, ...teardownCommands].map(expandMacro),
|
||||
}
|
||||
|
||||
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
34
src/duration.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
188
src/format.rs
Normal 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();
|
||||
}
|
||||
124
src/format.ts
124
src/format.ts
|
|
@ -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}]`)}`
|
||||
}
|
||||
11
src/index.ts
11
src/index.ts
|
|
@ -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"
|
||||
649
src/main.rs
Normal file
649
src/main.rs
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
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!(" 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(),
|
||||
"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);
|
||||
}
|
||||
"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 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 {
|
||||
use rayon::prelude::*;
|
||||
|
||||
// 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 par_results: Vec<TestResult> = file_ports
|
||||
.par_iter()
|
||||
.map(|(f, port)| {
|
||||
let r = run_one(f, *port, &opts, timeout_ms, &cwd);
|
||||
print_error_dot(&r);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.extend(par_results);
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
103
src/match.ts
103
src/match.ts
|
|
@ -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
|
||||
}
|
||||
123
src/matching.rs
Normal file
123
src/matching.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use regex::Regex;
|
||||
|
||||
/// 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 escaped: Vec<String> = parts.iter().map(|p| regex::escape(p)).collect();
|
||||
let re_str = format!("^{}$", escaped.join(".*"));
|
||||
match Regex::new(&re_str) {
|
||||
Ok(re) => re.is_match(actual),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
302
src/parse.rs
Normal file
302
src/parse.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
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("$ ") && 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("$ ") {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,352 +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",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def simple macro", () => {
|
||||
const result = parse("test.shout", "@def greet echo hello\n$ greet\nhello\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "greet", body: "echo hello", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
expect(result.commands[0]!.command).toBe("greet")
|
||||
})
|
||||
|
||||
test("@def with backslash continuation", () => {
|
||||
const content = "@def multi echo one; \\\n echo two\n$ multi\none\ntwo\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("@def with body starting on continuation line", () => {
|
||||
const content = "@def serve \\\n python3 -m http.server\n$ serve\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "serve", body: "python3 -m http.server", line: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test("@def multiple macros", () => {
|
||||
const content = "@def foo echo foo\n@def bar echo bar\n$ foo\nfoo\n"
|
||||
const result = parse("test.shout", content)
|
||||
expect(result.directives).toHaveLength(2)
|
||||
expect(result.directives[0]).toEqual({ type: "def", name: "foo", body: "echo foo", line: 1 })
|
||||
expect(result.directives[1]).toEqual({ type: "def", name: "bar", body: "echo bar", line: 2 })
|
||||
})
|
||||
|
||||
test("@def without body throws", () => {
|
||||
expect(() => parse("test.shout", "@def greet\n$ echo hi\n")).toThrow(
|
||||
"test.shout:1: @def requires a name and body",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def with whitespace-only body throws", () => {
|
||||
expect(() => parse("test.shout", "@def greet \n$ echo hi\n")).toThrow(
|
||||
"test.shout:1: @def requires a name and body",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming $ line throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n$ echo real\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming @ directive throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n@env PORT=3000\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def trailing backslash with no continuation throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n")).toThrow(
|
||||
"test.shout:1: @def ends with \\ but has no continuation line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming blank line throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n\n$ echo real\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def continuation consuming comment line throws", () => {
|
||||
expect(() => parse("test.shout", "@def foo echo a \\\n# comment\n$ echo real\n")).toThrow(
|
||||
"test.shout:2: @def continuation consumed a command or directive line",
|
||||
)
|
||||
})
|
||||
|
||||
test("@def after first command is expected output", () => {
|
||||
const result = parse("test.shout", "$ cat file\n@def foo bar\n")
|
||||
expect(result.directives).toEqual([])
|
||||
expect(result.commands[0]!.expected).toEqual(["@def foo bar"])
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test("@def in setup file", () => {
|
||||
const result = parseSetup("setup.shout", "@def greet echo hello\nexport FOO=bar\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "greet", body: "echo hello", line: 1 },
|
||||
])
|
||||
expect(result.commands).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("@def with backslash continuation in setup file", () => {
|
||||
const result = parseSetup("setup.shout", "@def multi echo one; \\\n echo two\n")
|
||||
expect(result.directives).toEqual([
|
||||
{ type: "def", name: "multi", body: "echo one;\necho two", line: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
252
src/parse.ts
252
src/parse.ts
|
|
@ -1,252 +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 }
|
||||
| { type: "def"; name: string; body: 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) }
|
||||
}
|
||||
|
||||
function parseDefDirective(
|
||||
path: string,
|
||||
rawLines: string[],
|
||||
i: number,
|
||||
): { name: string; body: string; linesConsumed: number } {
|
||||
const rest = rawLines[i]!.slice(5).trim() // strip "@def "
|
||||
const spaceIdx = rest.indexOf(" ")
|
||||
if (spaceIdx < 0) {
|
||||
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
||||
}
|
||||
const name = rest.slice(0, spaceIdx)
|
||||
let body = rest.slice(spaceIdx + 1).trim()
|
||||
if (!body) {
|
||||
throw new Error(`${path}:${i + 1}: @def requires a name and body`)
|
||||
}
|
||||
let extra = 0
|
||||
|
||||
while (body.endsWith("\\")) {
|
||||
if (i + extra + 1 >= rawLines.length) {
|
||||
throw new Error(`${path}:${i + extra + 1}: @def ends with \\ but has no continuation line`)
|
||||
}
|
||||
const next = rawLines[i + extra + 1]!
|
||||
if (next.startsWith("$") || next.startsWith("@") || next.trim() === "" || next.startsWith("#")) {
|
||||
throw new Error(`${path}:${i + extra + 2}: @def continuation consumed a command or directive line`)
|
||||
}
|
||||
const prefix = body.slice(0, -1).trimEnd()
|
||||
body = prefix ? prefix + "\n" + next.trim() : next.trim()
|
||||
extra++
|
||||
}
|
||||
|
||||
return { name, body, linesConsumed: extra }
|
||||
}
|
||||
|
||||
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("@def ")) {
|
||||
const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i)
|
||||
directives.push({ type: "def", name, body, line: i + 1 })
|
||||
i += linesConsumed
|
||||
} 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 if (line.startsWith("@def ")) {
|
||||
const { name, body, linesConsumed } = parseDefDirective(path, rawLines, i)
|
||||
directives.push({ type: "def", name, body, line: i + 1 })
|
||||
i += linesConsumed
|
||||
} 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 }
|
||||
}
|
||||
436
src/run.rs
Normal file
436
src/run.rs
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
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(line: &str) -> String {
|
||||
// Same regex as the TS version
|
||||
let re = Regex::new(r"[\x1b\x9b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]").unwrap();
|
||||
re.replace_all(line, "").to_string()
|
||||
}
|
||||
|
||||
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 sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap();
|
||||
|
||||
let mut remaining = raw;
|
||||
|
||||
for _i in 0..command_count {
|
||||
if let Some(m) = sentinel_re.find(remaining) {
|
||||
let caps = sentinel_re.captures(&remaining[m.start()..]).unwrap();
|
||||
let exit_code: i32 = caps[1].parse().unwrap_or(1);
|
||||
|
||||
let before = &remaining[..m.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);
|
||||
|
||||
// Skip past sentinel
|
||||
let after = &remaining[m.end()..];
|
||||
remaining = if after.starts_with('\n') {
|
||||
&after[1..]
|
||||
} else {
|
||||
after
|
||||
};
|
||||
} else {
|
||||
// No sentinel found — rest is output for this command
|
||||
let mut lines: Vec<String> = remaining.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;
|
||||
|
||||
let sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap();
|
||||
|
||||
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 {
|
||||
for caps in sentinel_re.captures_iter(&accumulated[last_sentinel_end..]) {
|
||||
let idx: usize = caps[2].parse().unwrap_or(0);
|
||||
if idx >= sentinels_reported {
|
||||
let exit_code: i32 = caps[1].parse().unwrap_or(1);
|
||||
let sentinel_match = caps.get(0).unwrap();
|
||||
let abs_start = last_sentinel_end + sentinel_match.start();
|
||||
let abs_end = last_sentinel_end + sentinel_match.end();
|
||||
let output_slice = &accumulated[last_sentinel_end..abs_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 = abs_end;
|
||||
if accumulated.as_bytes().get(last_sentinel_end) == Some(&b'\n') {
|
||||
last_sentinel_end += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
114
src/run.test.ts
114
src/run.test.ts
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
372
src/run.ts
372
src/run.ts
|
|
@ -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
115
src/update.rs
Normal 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_dollar(line: &str) -> String {
|
||||
if 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("\\$ "))
|
||||
{
|
||||
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_dollar(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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, "\\$&")
|
||||
}
|
||||
16
tests/shout.rs
Normal file
16
tests/shout.rs
Normal 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}");
|
||||
}
|
||||
|
|
@ -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": "."
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user