Compare commits

..

5 Commits

Author SHA1 Message Date
040c3a8c47 version 2026-03-10 10:18:54 -07:00
d2c24da7dc shout test 2026-03-10 10:15:16 -07:00
b04849cc5a Rebrand to shout, add light mode, update docs 2026-03-10 10:07:03 -07:00
60eb31cfc9 Add shout landing page 2026-03-10 09:25:44 -07:00
56d982db17 ty 2026-03-10 08:07:06 -07:00
7 changed files with 297 additions and 217 deletions

View File

@ -6,7 +6,7 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `bun test` — run unit tests - `bun test` — run unit tests
- `bunx tsc --noEmit` — type check - `bunx tsc --noEmit` — type check
- `bun run src/cli/index.ts [files...]` — run shout CLI - `bun run src/cli/index.ts test [files...]` — run shout CLI
## Architecture ## Architecture

View File

@ -33,21 +33,21 @@ ls: missing: No such file or directory
## Usage ## Usage
``` ```
$ shout $ shout test
............... ...............
15 passed in 23ms 15 passed in 23ms
``` ```
`shout` runs code in a temp directory. `-k`/`--keep` keeps it around. `shout test` runs code in a temp directory. `-k`/`--keep` keeps it around.
`--update` will modify `.shout` files to match reality, without running any tests. `--update` will modify `.shout` files to match reality, without running any tests.
Each line in a `.shout` file is run sequentially, unless `--parallel` is passed. Each line in a `.shout` file is run sequentially, unless `--parallel` is passed.
``` ```
Usage: shout [options] [files...] Usage: shout test [options] [files...]
shell output tester. Run .shout test files
Arguments: Arguments:
files Files or directories to test files Files or directories to test
@ -61,5 +61,10 @@ Options:
-v, --verbose Print each command as it runs -v, --verbose Print each command as it runs
--parallel Run files in parallel --parallel Run files in parallel
-h, --help display help for command -h, --help display help for command
```
Print an example `.shout` file:
``` ```
$ shout example
```

189
SPEC.md
View File

@ -1,189 +0,0 @@
# shout
A transcript-based shell integration test runner.
## Format
A `.shout` file is a plain text transcript of a shell session. Lines starting
with `$ ` are commands. Everything after — until the next `$` or end of file
— is the expected output (stdout and stderr combined).
```
$ dev new "add auth"
created draft 1 "add auth"
$ dev save
saved draft 1 (v1)
```
Blank lines within expected output are significant. Trailing newline on the
file is ignored.
### Comments
`#` after a command is a comment and is stripped before execution. Comments
in expected output are matched literally.
```
$ dev new "add auth" # create a draft from timeline HEAD
created draft 1 "add auth"
```
### Wildcards
A `...` on its own line in expected output matches any number of lines
(including zero).
```
$ dev log
...
draft 1 "add auth"
```
A `...` inline matches any sequence of characters on that line.
```
$ dev status
draft 1 "add auth" (v...)
```
### Environment
Each `.shout` file runs in a fresh temporary directory. The directory is
created before the first command and removed after the last (unless
`--keep` is passed).
All commands in a file run in a single shell session (`/bin/sh`), so `cd`,
`export`, and other shell state persists between commands.
The following environment variables are set for every command:
| Variable | Value |
|---|---|
| `HOME` | the temp directory |
| `PATH` | inherited from host (or prepended via `--path`) |
| `CUE_DIR` | the temp directory |
All other environment variables are inherited from the host unless explicitly
cleared with `--clean-env`.
### Exit codes
By default, a non-zero exit code fails the test regardless of output. To
assert a specific exit code, append `[N]` on the last line of expected output:
```
$ dev rm
error: draft 1 has children. use dev rm -f to cascade.
[1]
```
`[*]` accepts any non-zero exit code without asserting the value.
---
## CLI
```
shout [options] [files|dirs...]
```
If no files are given, shout runs all `*.shout` files in the current directory
and subdirectories. Each command in each shout file is run sequentially
(unless `--parallel` is passed).
### Options
| Flag | Description |
|---|---|
| `--update` / `-u` | Rewrite expected output in-place with actual output |
| `--keep` / `-k` | Keep temp directories after run (printed to stderr) |
| `--clean-env` | Start with empty environment (only `PATH` and `CUE_DIR` set) |
| `--path <path>` | Prepend `<path>` to `PATH` (repeatable) |
| `--timeout <dur>` | Per-command timeout, e.g. `500ms`, `10s`, `1m` (default: `10s`) |
| `--verbose` / `-v` | Print each command as it runs |
| `--parallel` | Run files in parallel (implies all files run regardless of failures) |
### Output
Passing files print a single `.` per file. Failing files print a unified diff:
```
FAIL tests/auth.shout
$ dev rm
- error: draft 1 has children. use dev rm -f to cascade.
+ error: draft 1 has dependents. use dev rm -f to cascade.
[1]
```
Summary line at the end:
```
12 passed, 1 failed in 340ms
```
---
## Update mode
`--update` rewrites the expected output sections of each `.shout` file with the
actual output from the run. Commands, comments, and whitespace are preserved.
Wildcard lines are left in place if the actual output matches them; they are
only replaced if the match fails.
This makes it safe to run `shout --update` routinely after intentional output
changes — review the diff, commit if correct.
---
## File layout
```
tests/
auth.shout
drafts.shout
stack.shout
```
No special directory structure is required. `.shout` files can live anywhere.
---
## Implementation notes
- Bun + TypeScript
- Each file runs in a single `/bin/sh` session via `Bun.spawn`
- Stdout and stderr merged (same as a terminal)
- Shell state (`cd`, `export`, etc.) persists across commands within a file
- Commands are fed to the shell sequentially; output between commands is
captured by delimiting with sentinel `echo` statements
- shout exits `0` if all tests pass, `1` if any fail
---
## Example
```
$ dev new "add auth"
created draft 1 "add auth"
$ echo 'export function auth() {}' > auth.ts
$ dev save
saved draft 1 (v1)
$ echo 'export function auth(token: string) {}' > auth.ts
$ dev save
saved draft 1 (v2)
$ dev status
draft 1 "add auth" (v2)
modified: (none)
$ dev new "add db"
created draft 2 "add db"
$ dev rm 1
error: draft 1 has children. use dev rm -f to cascade.
[1]
```

250
index.html Normal file
View File

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shout — shell output tester</title>
<meta name="description" content="shell output tester">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #fff;
--fg: #444;
--bright: #1a1a1a;
--green: #1a7f37;
--red: #cf222e;
--dim: #888;
--accent: #1a7f37;
--code-bg: #f5f5f5;
--border: #ddd;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--fg: #b0b0b0;
--bright: #e0e0e0;
--green: #4ec966;
--red: #e55;
--dim: #555;
--accent: #4ec966;
--code-bg: #111;
--border: #222;
}
}
html { font-size: 16px; }
body {
background: var(--bg);
color: var(--fg);
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', 'Consolas', monospace;
line-height: 1.6;
padding: 0 1.5rem;
max-width: 680px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
}
header {
padding: 6rem 0 2.5rem;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--bright);
letter-spacing: -0.03em;
}
h1 span {
color: var(--accent);
}
.tagline {
font-size: 1.1rem;
color: var(--dim);
margin-top: 0.5rem;
}
.install {
margin-top: 2rem;
display: inline-block;
background: var(--code-bg);
border: 1px solid var(--border);
padding: 0.6rem 1.2rem;
border-radius: 4px;
font-size: 0.9rem;
color: var(--bright);
cursor: pointer;
position: relative;
transition: border-color 0.15s;
}
.install:hover {
border-color: var(--accent);
}
.install .hint {
color: var(--dim);
font-size: 0.75rem;
margin-left: 1rem;
}
section {
padding: 0 0 2em 0;
}
h2 {
font-size: 0.85rem;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1.5rem;
}
p {
margin-bottom: 1rem;
font-size: 0.95rem;
}
.bright { color: var(--bright); }
.green { color: var(--green); }
.red { color: var(--red); }
.dim { color: var(--dim); }
pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1.2rem 1.4rem;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.7;
margin-bottom: 1.5rem;
}
pre code {
color: var(--fg);
}
.prompt { color: var(--dim); }
.cmd { color: var(--bright); }
.output { color: var(--fg); }
.comment { color: var(--dim); font-style: italic; }
.wildcard { color: var(--accent); }
.exit-code { color: var(--red); }
.pass { color: var(--green); }
footer {
padding: 3rem 0;
border-top: 1px solid var(--border);
color: var(--dim);
font-size: 0.8rem;
}
footer a {
color: var(--fg);
text-decoration: none;
}
footer a:hover {
color: var(--accent);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 520px) {
header { padding: 3rem 0 2rem; }
h1 { font-size: 2rem; }
section { padding: 2rem 0; }
}
</style>
</head>
<body>
<header>
<h1><span>$</span> shout</h1>
<p class="tagline">shell output tester</p>
<div class="install" onclick="navigator.clipboard.writeText('bun install -g @because/shout --registry=https://npm.nose.space')">
<span class="prompt">$</span> <span class="cmd">bun install -g @because/shout --registry=https://npm.nose.space</span>
<span class="hint">click to copy</span>
</div>
</header>
<section>
<h2>Write a test</h2>
<p>A <code>.shout</code> file is just a shell session. Commands start with <code class="bright">$</code>, everything else is expected output.</p>
<pre><code><span class="prompt">$</span> <span class="cmd">echo hello</span>
<span class="output">hello</span>
<span class="prompt">$</span> <span class="cmd">ls missing</span>
<span class="output">ls: missing: No such file or directory</span>
<span class="exit-code">[1]</span>
<span class="prompt">$</span> <span class="cmd">brew --version</span>
<span class="output">Homebrew 5</span><span class="wildcard">...</span></code></pre>
<p><code class="wildcard">...</code> matches anything &mdash; inline or across lines.</p>
<p><code class="exit-code">[1]</code> asserts the exit code.</p>
</section>
<section>
<h2>Run it</h2>
<pre><code><span class="prompt">$</span> <span class="cmd">shout test</span>
<span class="pass">...............
15 passed</span> <span class="dim">in 23ms</span></code></pre>
<p>Each file gets a fresh temp directory and its own <code>/bin/sh</code> session. State carries between commands within a file.</p>
</section>
<section>
<h2>Update expectations</h2>
<pre><code><span class="prompt">$</span> <span class="cmd">shout test --update</span></code></pre>
<p>Rewrites your <code>.shout</code> files with the actual output. No more copy-pasting from the terminal.</p>
</section>
<section>
<h2>Usage</h2>
<pre><code><span class="prompt">$</span> <span class="cmd">shout test --help</span>
<span class="output">Usage: shout test [options] [files...]
Run .shout test files
Arguments:
files Files or directories to test
Options:
-u, --update Rewrite expected output in-place with actual output
-k, --keep Keep temp directories after run
--clean-env Start with empty environment
--path &lt;path&gt; Prepend &lt;path&gt; to PATH (repeatable)
--timeout &lt;dur&gt; Per-command timeout (default: "10s")
-v, --verbose Print each command as it runs
--parallel Run files in parallel
-h, --help display help for command</span></code></pre>
</section>
<section>
<h2>Environment</h2>
<p>Shout sets these variables before running your commands:</p>
<pre><code><span class="bright">HOME</span> <span class="dim"></span> <span class="output">temp directory for this test file</span>
<span class="bright">SHOUT_DIR</span> <span class="dim"></span> <span class="output">same temp directory</span>
<span class="bright">PATH</span> <span class="dim"></span> <span class="output">prepended with --path dirs, if any</span></code></pre>
<p>Each file runs in its own temp directory. <code>--clean-env</code> starts with an empty environment instead of inheriting yours.</p>
</section>
<footer>
<a href="https://github.com/because/shout">GitHub</a> &middot; <a href="https://www.npmjs.com/package/@because/shout">npm</a>
</footer>
</body>
</html>

View File

@ -1,7 +1,7 @@
{ {
"name": "@because/shout", "name": "@because/shout",
"version": "0.0.2", "version": "0.0.2",
"description": "test shell output", "description": "shell output tester",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"files": [ "files": [

View File

@ -43,9 +43,16 @@ async function findShoutFiles(paths: string[]): Promise<string[]> {
return files.sort() return files.sort()
} }
import pkg from "../../package.json"
program program
.name("shout") .name("shout")
.description("$ shell output tester") .description("$ shell output tester")
.version(pkg.version)
program
.command("test")
.description("Run .shout test files")
.argument("[files...]", "Files or directories to test") .argument("[files...]", "Files or directories to test")
.option("-u, --update", "Rewrite expected output in-place with actual output") .option("-u, --update", "Rewrite expected output in-place with actual output")
.option("-k, --keep", "Keep temp directories after run") .option("-k, --keep", "Keep temp directories after run")
@ -54,28 +61,7 @@ program
.option("--timeout <dur>", "Per-command timeout", "10s") .option("--timeout <dur>", "Per-command timeout", "10s")
.option("-v, --verbose", "Print each command as it runs") .option("-v, --verbose", "Print each command as it runs")
.option("--parallel", "Run files in parallel") .option("--parallel", "Run files in parallel")
.option("--example", "Print an example .shout file and exit")
.action(async (fileArgs: string[], opts) => { .action(async (fileArgs: string[], opts) => {
if (opts.example) {
console.log(`# Example .shout file
$ echo hello
hello
$ echo "one"; echo "two"; echo "three"
one
...
three
$ cat nonexistent
cat: nonexistent: ...
[1]
$ true
[0]`)
process.exit(0)
}
const timeoutMs = parseDuration(opts.timeout) const timeoutMs = parseDuration(opts.timeout)
const paths = fileArgs.length > 0 ? fileArgs : ["."] const paths = fileArgs.length > 0 ? fileArgs : ["."]
const files = await findShoutFiles(paths) const files = await findShoutFiles(paths)
@ -171,4 +157,32 @@ $ true
process.exit(failures.length > 0 ? 1 : 0) 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.parse() program.parse()

View File

@ -126,7 +126,7 @@ export async function runFile(
: { ...process.env as Record<string, string> } : { ...process.env as Record<string, string> }
env["HOME"] = tmpDir env["HOME"] = tmpDir
env["CUE_DIR"] = tmpDir env["SHOUT_DIR"] = tmpDir
if (options.pathDirs?.length) { if (options.pathDirs?.length) {
env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "") env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")