shout/src/cli/index.ts
2026-03-10 10:18:54 -07:00

189 lines
4.8 KiB
TypeScript
Executable File

#!/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()
}
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("--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,
pathDirs: opts.path,
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
}
const printDots = (r: TestResult) => {
if (r.error) {
process.stdout.write(ansis.red("F"))
return
}
const passed = r.commandCount - r.failures.length
for (let i = 0; i < passed; i++) {
process.stdout.write(ansis.green("."))
}
for (let i = 0; i < r.failures.length; i++) {
process.stdout.write(ansis.red("F"))
}
}
if (opts.parallel) {
const all = await Promise.all(files.map(runOne))
for (const r of all) {
printDots(r)
results.push(r)
}
process.stdout.write("\n")
} else {
for (const filePath of files) {
const r = await runOne(filePath)
printDots(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
console.log(formatSummary(results, elapsed))
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()