Compare commits

...

4 Commits

9 changed files with 94 additions and 5 deletions

View File

@ -36,6 +36,7 @@ Transcript-based shell integration test runner. Bun + TypeScript.
- `...` inline = matches any characters on that line - `...` inline = matches any characters on that line
- `[N]` on last line of expected output = assert exit code N - `[N]` on last line of expected output = assert exit code N
- `[*]` = assert any non-zero exit code; default expects 0 - `[*]` = assert any non-zero exit code; default expects 0
- `$#` comment line = not executed, no output expected (e.g. `$# start the server`)
- `#` after a command = comment (stripped); `#` in expected output is literal - `#` after a command = comment (stripped); `#` in expected output is literal
- `@env KEY=VALUE` before first command = set environment variable - `@env KEY=VALUE` before first command = set environment variable
- `@setup path.shout` before first command = prepend commands (and `@env`) from another file - `@setup path.shout` before first command = prepend commands (and `@env`) from another file

View File

@ -30,6 +30,16 @@ ls: missing: No such file or directory
`[1]` after the expected output matches the exit code. `[1]` after the expected output matches the exit code.
`$#` is a comment line — not executed, no output expected:
```
$# start the server
$ my-server &
$# now test it
$ curl localhost:8080
OK
```
## Usage ## Usage
``` ```

View File

@ -1,6 +1,6 @@
{ {
"name": "@because/shout", "name": "@because/shout",
"version": "0.0.13", "version": "0.0.14",
"description": "shell output tester", "description": "shell output tester",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",

View File

@ -127,6 +127,38 @@ describe("parse", () => {
expect(result.commands[0]!.expected).toEqual(["hi"]) 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", () => { test("no directives returns empty array", () => {
const result = parse("test.shout", "$ echo hi\nhi\n") const result = parse("test.shout", "$ echo hi\nhi\n")
expect(result.directives).toEqual([]) expect(result.directives).toEqual([])

View File

@ -32,6 +32,11 @@ function stripComment(line: string): string {
return line 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[]): { function parseExitCode(lines: string[]): {
lines: string[] lines: string[]
exitCode: number | "*" | null exitCode: number | "*" | null
@ -134,7 +139,18 @@ export function parse(path: string, content: string): ShoutFile {
continue continue
} }
if (line.startsWith("\\$ ") && current) { 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 "$ " // Escaped dollar-space: literal expected output starting with "$ "
current.expected.push(line.slice(1)) current.expected.push(line.slice(1))
} else if (line.startsWith("$ ")) { } else if (line.startsWith("$ ")) {

View File

@ -62,6 +62,19 @@ describe("runFile", () => {
} }
}) })
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 () => { test("background process output does not leak into subsequent commands", async () => {
const file = makeFile([ const file = makeFile([
// Start a background process that writes to stdout after a delay // Start a background process that writes to stdout after a delay

View File

@ -145,6 +145,13 @@ function parseSentinelOutput(
return { outputs, exitCodes } 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 trimTrailingEmpty(lines: string[]): string[] { function trimTrailingEmpty(lines: string[]): string[] {
let end = lines.length let end = lines.length
while (end > 0 && lines[end - 1] === "") end-- while (end > 0 && lines[end - 1] === "") end--
@ -253,7 +260,7 @@ export async function runFile(
const results: CommandResult[] = file.commands.map((cmd, i) => ({ const results: CommandResult[] = file.commands.map((cmd, i) => ({
command: cmd, command: cmd,
actual: outputs[i] ?? [], actual: (outputs[i] ?? []).map(stripAnsi),
exitCode: exitCodes[i] ?? 1, exitCode: exitCodes[i] ?? 1,
})) }))

View File

@ -1,6 +1,7 @@
import type { CommandResult } from "./run.ts" import type { CommandResult } from "./run.ts"
import type { ShoutFile } from "./parse.ts" import type { ShoutFile } from "./parse.ts"
import { matchOutput, matchLine } from "./match.ts" import { matchOutput, matchLine } from "./match.ts"
import { isCommentLine } from "./parse.ts"
export function rewriteFile( export function rewriteFile(
file: ShoutFile, file: ShoutFile,
@ -15,7 +16,10 @@ export function rewriteFile(
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]! const line = lines[i]!
if (line.startsWith("$ ") && !line.startsWith("\\$ ")) { if (isCommentLine(line)) {
// Preserve comment lines as-is
output.push(line)
} else if (line.startsWith("$ ") && !line.startsWith("\\$ ")) {
// Emit the command line as-is // Emit the command line as-is
output.push(line) output.push(line)
@ -28,7 +32,7 @@ export function rewriteFile(
// Skip past old expected output lines in the original // Skip past old expected output lines in the original
let j = i + 1 let j = i + 1
while (j < lines.length && !(lines[j]!.startsWith("$ ") && !lines[j]!.startsWith("\\$ "))) { while (j < lines.length && !isCommentLine(lines[j]!) && !(lines[j]!.startsWith("$ ") && !lines[j]!.startsWith("\\$ "))) {
j++ j++
} }
// Collect old expected lines (before trimming trailing blanks for separator) // Collect old expected lines (before trimming trailing blanks for separator)

View File

@ -196,6 +196,12 @@
<span class="output">Homebrew 5</span><span class="wildcard">...</span></code></pre> <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="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="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="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> </section>
<section> <section>