#!/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 { 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("$ shell output tester") .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 ", "Prepend to PATH") .option("--timeout ", "Per-command timeout", "10s") .option("-v, --verbose", "Print each command as it runs") .option("--parallel", "Run files in parallel") .option("--example", "Print an example .shout file and exit") .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 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 } 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.parse()