Add test runner core with CLI and spec updates
This commit is contained in:
parent
c4cd5353bf
commit
17268b50f0
34
SPEC.md
34
SPEC.md
|
|
@ -53,32 +53,20 @@ Each `.shout` file runs in a fresh temporary directory. The directory is
|
||||||
created before the first command and removed after the last (unless
|
created before the first command and removed after the last (unless
|
||||||
`--keep` is passed).
|
`--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:
|
The following environment variables are set for every command:
|
||||||
|
|
||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `HOME` | the temp directory |
|
| `HOME` | the temp directory |
|
||||||
| `PATH` | prepended with the directory containing the binary under test |
|
| `PATH` | inherited from host (or prepended via `--bin`) |
|
||||||
| `CUE_DIR` | the temp directory |
|
| `CUE_DIR` | the temp directory |
|
||||||
|
|
||||||
All other environment variables are inherited from the host unless explicitly
|
All other environment variables are inherited from the host unless explicitly
|
||||||
cleared with `--clean-env`.
|
cleared with `--clean-env`.
|
||||||
|
|
||||||
### Setup blocks
|
|
||||||
|
|
||||||
Commands before the first blank line + command sequence are run as setup and
|
|
||||||
their output is not asserted.
|
|
||||||
|
|
||||||
Alternatively, a `# ---` line separates setup from the test body explicitly:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ export TOKEN=abc
|
|
||||||
$ cd myproject
|
|
||||||
# ---
|
|
||||||
$ dev status
|
|
||||||
on timeline @ change 0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Exit codes
|
### Exit codes
|
||||||
|
|
||||||
By default, a non-zero exit code fails the test regardless of output. To
|
By default, a non-zero exit code fails the test regardless of output. To
|
||||||
|
|
@ -111,8 +99,8 @@ and subdirectories. Each command in each shout file is run sequentially
|
||||||
| `--update` / `-u` | Rewrite expected output in-place with actual output |
|
| `--update` / `-u` | Rewrite expected output in-place with actual output |
|
||||||
| `--keep` / `-k` | Keep temp directories after run (printed to stderr) |
|
| `--keep` / `-k` | Keep temp directories after run (printed to stderr) |
|
||||||
| `--clean-env` | Start with empty environment (only `PATH` and `CUE_DIR` set) |
|
| `--clean-env` | Start with empty environment (only `PATH` and `CUE_DIR` set) |
|
||||||
| `--bin <path>` | Prepend `<path>` to `PATH` instead of auto-detecting |
|
| `--bin <path>` | Prepend `<path>` to `PATH` |
|
||||||
| `--timeout <dur>` | Per-command timeout (default: `10s`) |
|
| `--timeout <dur>` | Per-command timeout, e.g. `500ms`, `10s`, `1m` (default: `10s`) |
|
||||||
| `--verbose` / `-v` | Print each command as it runs |
|
| `--verbose` / `-v` | Print each command as it runs |
|
||||||
| `--parallel` | Run files in parallel (implies all files run regardless of failures) |
|
| `--parallel` | Run files in parallel (implies all files run regardless of failures) |
|
||||||
|
|
||||||
|
|
@ -165,12 +153,12 @@ No special directory structure is required. `.shout` files can live anywhere.
|
||||||
## Implementation notes
|
## Implementation notes
|
||||||
|
|
||||||
- Bun + TypeScript
|
- Bun + TypeScript
|
||||||
- Commands run via `Bun.spawn` with a shell (`/bin/sh -c`)
|
- Each file runs in a single `/bin/sh` session via `Bun.spawn`
|
||||||
- Stdout and stderr merged (same as a terminal)
|
- Stdout and stderr merged (same as a terminal)
|
||||||
- Each command in a file shares a working directory but runs in a fresh
|
- Shell state (`cd`, `export`, etc.) persists across commands within a file
|
||||||
process — no persistent shell state between commands
|
- Commands are fed to the shell sequentially; output between commands is
|
||||||
- For persistent state (e.g. `cd`, `export`), users wrap in a shell block or
|
captured by delimiting with sentinel `echo` statements
|
||||||
use a setup script
|
- shout exits `0` if all tests pass, `1` if any fail
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
147
src/cli/index.ts
Executable file
147
src/cli/index.ts
Executable file
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { readdir, readFile, writeFile } from "node:fs/promises"
|
||||||
|
import { resolve, relative } from "node:path"
|
||||||
|
import { program } from "commander"
|
||||||
|
import ansis from "ansis"
|
||||||
|
|
||||||
|
import { parse } from "../parse.ts"
|
||||||
|
import { runFile, cleanupTmpDir } from "../run.ts"
|
||||||
|
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
|
||||||
|
import type { TestResult } from "../format.ts"
|
||||||
|
import { parseDuration } from "../duration.ts"
|
||||||
|
import { rewriteFile } from "../update.ts"
|
||||||
|
|
||||||
|
async function findShoutFiles(paths: string[]): Promise<string[]> {
|
||||||
|
const files: 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")) {
|
||||||
|
files.push(abs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as directory
|
||||||
|
try {
|
||||||
|
const entries = await readdir(abs, { recursive: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.endsWith(".shout")) {
|
||||||
|
files.push(resolve(abs, entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If not a directory, try as file anyway
|
||||||
|
if (abs.endsWith(".shout")) files.push(abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("shout")
|
||||||
|
.description("Transcript-based shell integration test runner")
|
||||||
|
.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("--bin <path>", "Prepend <path> to PATH")
|
||||||
|
.option("--timeout <dur>", "Per-command timeout", "10s")
|
||||||
|
.option("-v, --verbose", "Print each command as it runs")
|
||||||
|
.option("--parallel", "Run files in parallel")
|
||||||
|
.action(async (fileArgs: string[], opts) => {
|
||||||
|
const timeoutMs = parseDuration(opts.timeout)
|
||||||
|
const paths = fileArgs.length > 0 ? fileArgs : ["."]
|
||||||
|
const files = await findShoutFiles(paths)
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.error("No .shout files found")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
const results: TestResult[] = []
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
const runOne = async (filePath: string) => {
|
||||||
|
const content = await readFile(filePath, "utf-8")
|
||||||
|
const parsed = parse(relative(cwd, filePath), content)
|
||||||
|
|
||||||
|
const fileResult = await runFile(parsed, {
|
||||||
|
cleanEnv: opts.cleanEnv ?? false,
|
||||||
|
binPath: opts.bin,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
verbose: opts.verbose ?? false,
|
||||||
|
onCommand: opts.verbose
|
||||||
|
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const testResult = evaluateFile(
|
||||||
|
parsed.path,
|
||||||
|
fileResult.results,
|
||||||
|
fileResult.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (opts.update && fileResult.results.length > 0) {
|
||||||
|
const updated = rewriteFile(parsed, fileResult.results, content)
|
||||||
|
if (updated !== content) {
|
||||||
|
await writeFile(filePath, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.keep) {
|
||||||
|
process.stderr.write(`${fileResult.tmpDir}\n`)
|
||||||
|
} else {
|
||||||
|
await cleanupTmpDir(fileResult.tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return testResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.parallel) {
|
||||||
|
const all = await Promise.all(files.map(runOne))
|
||||||
|
for (const r of all) {
|
||||||
|
if (r.passed) {
|
||||||
|
process.stdout.write(ansis.green("."))
|
||||||
|
} else {
|
||||||
|
process.stdout.write(ansis.red("F"))
|
||||||
|
}
|
||||||
|
results.push(r)
|
||||||
|
}
|
||||||
|
process.stdout.write("\n")
|
||||||
|
} else {
|
||||||
|
for (const filePath of files) {
|
||||||
|
const r = await runOne(filePath)
|
||||||
|
if (r.passed) {
|
||||||
|
process.stdout.write(ansis.green("."))
|
||||||
|
} else {
|
||||||
|
process.stdout.write(ansis.red("F"))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
console.log(formatSummary(results, elapsed))
|
||||||
|
|
||||||
|
process.exit(failures.length > 0 ? 1 : 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
program.parse()
|
||||||
27
src/duration.test.ts
Normal file
27
src/duration.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
14
src/duration.ts
Normal file
14
src/duration.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/format.ts
Normal file
119
src/format.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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
|
||||||
|
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, 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, 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}`)
|
||||||
|
|
||||||
|
for (const dl of failure.diffLines) {
|
||||||
|
switch (dl.kind) {
|
||||||
|
case "equal":
|
||||||
|
lines.push(` ${dl.text}`)
|
||||||
|
break
|
||||||
|
case "expected":
|
||||||
|
lines.push(ansis.red(` - ${dl.text}`))
|
||||||
|
break
|
||||||
|
case "actual":
|
||||||
|
lines.push(ansis.green(` + ${dl.text}`))
|
||||||
|
break
|
||||||
|
case "context":
|
||||||
|
lines.push(ansis.dim(` ${dl.text}`))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure.exitCodeMismatch) {
|
||||||
|
const expected = failure.result.command.exitCode ?? 0
|
||||||
|
const actual = failure.result.exitCode
|
||||||
|
lines.push(
|
||||||
|
ansis.red(` - exit code: ${expected === "*" ? "non-zero" : expected}`),
|
||||||
|
)
|
||||||
|
lines.push(ansis.green(` + exit code: ${actual}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSummary(
|
||||||
|
results: TestResult[],
|
||||||
|
elapsed: number,
|
||||||
|
): string {
|
||||||
|
const passed = results.filter(r => r.passed).length
|
||||||
|
const failed = results.filter(r => !r.passed).length
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (passed > 0) parts.push(ansis.green(`${passed} passed`))
|
||||||
|
if (failed > 0) parts.push(ansis.red(`${failed} failed`))
|
||||||
|
|
||||||
|
const time = elapsed < 1000
|
||||||
|
? `${Math.round(elapsed)}ms`
|
||||||
|
: `${(elapsed / 1000).toFixed(1)}s`
|
||||||
|
|
||||||
|
return `${parts.join(", ")} in ${time}`
|
||||||
|
}
|
||||||
11
src/index.ts
Normal file
11
src/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export type { Command, 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 } 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"
|
||||||
71
src/match.test.ts
Normal file
71
src/match.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
105
src/match.ts
Normal file
105
src/match.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
69
src/parse.test.ts
Normal file
69
src/parse.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { parse } 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
96
src/parse.ts
Normal file
96
src/parse.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
export type Command = {
|
||||||
|
line: number
|
||||||
|
raw: string
|
||||||
|
command: string
|
||||||
|
expected: string[]
|
||||||
|
exitCode: number | "*" | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShoutFile = {
|
||||||
|
path: string
|
||||||
|
commands: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 trimTrailingEmpty(lines: string[]): string[] {
|
||||||
|
let end = lines.length
|
||||||
|
while (end > 0 && lines[end - 1] === "") end--
|
||||||
|
return lines.slice(0, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] = []
|
||||||
|
let current: Command | null = null
|
||||||
|
|
||||||
|
for (let i = 0; i < rawLines.length; i++) {
|
||||||
|
const line = rawLines[i]!
|
||||||
|
|
||||||
|
if (line.startsWith("$ ")) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
208
src/run.ts
Normal file
208
src/run.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
import type { Command, ShoutFile } from "./parse.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
|
||||||
|
binPath?: string
|
||||||
|
timeout: number
|
||||||
|
verbose: boolean
|
||||||
|
onCommand?: (cmd: Command) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENTINEL_PREFIX = "__SHOUT_SENTINEL_"
|
||||||
|
|
||||||
|
function buildScript(commands: Command[], sentinel: string): string {
|
||||||
|
const lines: string[] = ["exec 2>&1"]
|
||||||
|
|
||||||
|
for (let i = 0; i < commands.length; i++) {
|
||||||
|
const cmd = commands[i]!
|
||||||
|
lines.push(cmd.command)
|
||||||
|
// Sentinel: printf to avoid echo interpretation issues
|
||||||
|
// Format: __SHOUT_SENTINEL_<exitcode>_<index>__
|
||||||
|
lines.push(
|
||||||
|
`printf '\\n${sentinel}%s_${i}__\\n' "$?"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSentinelOutput(
|
||||||
|
raw: string,
|
||||||
|
sentinel: string,
|
||||||
|
commandCount: number,
|
||||||
|
): { outputs: string[][]; exitCodes: number[] } {
|
||||||
|
const outputs: string[][] = []
|
||||||
|
const exitCodes: number[] = []
|
||||||
|
|
||||||
|
// Split by sentinel lines
|
||||||
|
const sentinelRegex = new RegExp(
|
||||||
|
`${escapeRegex(sentinel)}(\\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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingEmpty(lines: string[]): string[] {
|
||||||
|
let end = lines.length
|
||||||
|
while (end > 0 && lines[end - 1] === "") end--
|
||||||
|
return lines.slice(0, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 script = buildScript(file.commands, sentinel)
|
||||||
|
|
||||||
|
const env: Record<string, string> = options.cleanEnv
|
||||||
|
? {}
|
||||||
|
: { ...process.env as Record<string, string> }
|
||||||
|
|
||||||
|
env["HOME"] = tmpDir
|
||||||
|
env["CUE_DIR"] = tmpDir
|
||||||
|
|
||||||
|
if (options.binPath) {
|
||||||
|
env["PATH"] = options.binPath + ":" + (env["PATH"] ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(["/bin/sh"], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
cwd: tmpDir,
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stdin.write(script)
|
||||||
|
proc.stdin.end()
|
||||||
|
|
||||||
|
const stdout = await readWithTimeout(proc.stdout, options.timeout * file.commands.length)
|
||||||
|
const stderr = await readWithTimeout(proc.stderr, 1000).catch(() => "")
|
||||||
|
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
|
const { outputs, exitCodes } = parseSentinelOutput(
|
||||||
|
stdout,
|
||||||
|
sentinel,
|
||||||
|
file.commands.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const results: CommandResult[] = file.commands.map((cmd, i) => {
|
||||||
|
if (options.verbose && options.onCommand) {
|
||||||
|
options.onCommand(cmd)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: cmd,
|
||||||
|
actual: outputs[i] ?? [],
|
||||||
|
exitCode: exitCodes[i] ?? 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { file, results, tmpDir }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
results: [],
|
||||||
|
tmpDir,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWithTimeout(
|
||||||
|
stream: ReadableStream<Uint8Array>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
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 {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
83
src/update.ts
Normal file
83
src/update.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import type { CommandResult } from "./run.ts"
|
||||||
|
import type { ShoutFile } from "./parse.ts"
|
||||||
|
import { matchOutput, matchLine } from "./match.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 (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 && !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
|
||||||
|
for (const al of result.actual) output.push(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 trimTrailingEmpty(lines: string[]): string[] {
|
||||||
|
let end = lines.length
|
||||||
|
while (end > 0 && lines[end - 1] === "") end--
|
||||||
|
return lines.slice(0, end)
|
||||||
|
}
|
||||||
9
test/basic.shout
Normal file
9
test/basic.shout
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
$ echo hello
|
||||||
|
hello
|
||||||
|
|
||||||
|
$ echo one && echo two
|
||||||
|
one
|
||||||
|
two
|
||||||
|
|
||||||
|
$ echo "working directory: $(basename $PWD)"
|
||||||
|
working directory: ...
|
||||||
5
test/comments.shout
Normal file
5
test/comments.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
$ echo hello # this is a comment
|
||||||
|
hello
|
||||||
|
|
||||||
|
$ echo "keep # this"
|
||||||
|
keep # this
|
||||||
28
test/features.shout
Normal file
28
test/features.shout
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
$ echo "test exit codes"
|
||||||
|
test exit codes
|
||||||
|
|
||||||
|
$ false
|
||||||
|
[1]
|
||||||
|
|
||||||
|
$ sh -c "exit 42"
|
||||||
|
[42]
|
||||||
|
|
||||||
|
$ sh -c "echo oops && exit 1"
|
||||||
|
oops
|
||||||
|
[*]
|
||||||
|
|
||||||
|
$ export MY_VAR=hello
|
||||||
|
$ echo $MY_VAR
|
||||||
|
hello
|
||||||
|
|
||||||
|
$ cd /tmp
|
||||||
|
$ pwd
|
||||||
|
/tmp
|
||||||
|
|
||||||
|
$ echo "line 1" && echo "" && echo "line 3"
|
||||||
|
line 1
|
||||||
|
|
||||||
|
line 3
|
||||||
|
|
||||||
|
$ echo "match ..."
|
||||||
|
match ...
|
||||||
Loading…
Reference in New Issue
Block a user