Compare commits

...

4 Commits

9 changed files with 281 additions and 106 deletions

View File

@ -36,7 +36,9 @@ Transcript-based shell integration test runner. Rust.
- `...` inline = matches any characters on that line
- `[N]` on last line of expected output = assert exit code N
- `[*]` = assert any non-zero exit code; default expects 0
- `$#` comment line = not executed, no output expected (e.g. `$# start the server`)
- `#` at start of line = comment (not executed, no output expected)
- `$#` also works as comment (legacy syntax)
- `\#` in expected output = literal line starting with `#`
- `#` after a command = comment (stripped); `#` in expected output is literal
- `@env KEY=VALUE` before first command = set environment variable
- `@teardown <command>` before first command = run command after all test commands

View File

@ -30,16 +30,25 @@ ls: missing: No such file or directory
`[1]` after the expected output matches the exit code.
`$#` is a comment line — not executed, no output expected:
`#` at the start of a line is a comment — not executed, no output expected:
```
$# start the server
# start the server
$ my-server &
$# now test it
# now test it
$ curl localhost:8080
OK
```
If expected output starts with `#`, escape it with `\#`:
```
$ echo "#hashtag"
\#hashtag
```
`#` elsewhere in expected output is literal (not a comment).
## Usage
```

View File

@ -68,9 +68,10 @@ fn strip_comment(line: &str) -> &str {
line
}
/// A line like `$# ...` or `$ # ...` — a comment, not a real command.
/// A line like `$# ...`, `$ # ...`, or `# ...` — a comment, not a real command.
pub fn is_comment_line(line: &str) -> bool {
line.starts_with("$#")
line.starts_with('#')
|| line.starts_with("$#")
|| (line.starts_with("$ ") && strip_comment(&line[2..]).is_empty())
}
@ -269,6 +270,16 @@ pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
} else if line.starts_with("\\$ ") && current.is_some() {
// Escaped dollar-space: literal expected output starting with "$ "
current.as_mut().unwrap().expected.push(line[1..].to_string());
} else if line.starts_with("\\#") && current.is_some() {
// Escaped hash: literal expected output starting with "#"
current.as_mut().unwrap().expected.push(line[1..].to_string());
} else if line.starts_with('#') {
// Comment line
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
current = None;
}
} else if line.starts_with("$ ") {
seen_command = true;
if let Some(ref mut cmd) = current {

View File

@ -2,8 +2,8 @@ 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("$ ") {
fn escape_output(line: &str) -> String {
if line.starts_with("$ ") || line.starts_with('#') {
format!("\\{line}")
} else {
line.to_string()
@ -44,7 +44,7 @@ pub fn rewrite_file(
// Skip past old expected output lines in the original
let mut j = i + 1;
while j < lines.len()
&& !is_comment_line(lines[j])
&& !(is_comment_line(lines[j]) && !lines[j].starts_with("\\#"))
&& !(lines[j].starts_with("$ ") && !lines[j].starts_with("\\$ "))
{
j += 1;
@ -88,7 +88,7 @@ pub fn rewrite_file(
} else {
// Replace with actual output
for al in &result.actual {
output.push(escape_dollar(al));
output.push(escape_output(al));
}
// Re-add exit code marker if it existed
if let Some(marker) = old_exit_marker {

View File

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

View File

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

View File

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

View File

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

View File

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