#!/usr/bin/env bun import { readdir, readFile, writeFile } from "node:fs/promises" import { resolve, relative, dirname } from "node:path" import { program } from "commander" import ansis from "ansis" import { parse, parseSetup, type Command, type ShoutFile } 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 filterGitignored(files: string[]): Promise { if (files.length === 0) return files try { const proc = Bun.spawn(["git", "check-ignore", "--stdin"], { stdin: new Blob([files.join("\n")]), stdout: "pipe", stderr: "ignore", }) const output = await new Response(proc.stdout).text() await proc.exited const ignored = new Set(output.trim().split("\n").filter(Boolean)) return files.filter(f => !ignored.has(f)) } catch { return files } } async function findShoutFiles(paths: string[]): Promise { const explicit: string[] = [] const discovered: 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")) { explicit.push(abs) continue } // Try as directory try { const entries = await readdir(abs, { recursive: true }) for (const entry of entries) { if (entry.endsWith(".shout")) { discovered.push(resolve(abs, entry)) } } } catch { // If not a directory, try as file anyway if (abs.endsWith(".shout")) explicit.push(abs) } } const filtered = await filterGitignored(discovered) return [...explicit, ...filtered].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 ", "Prepend to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val], []) .option("--timeout ", "Per-command timeout", "10s") .option("-v, --verbose", "Print each command as it runs") .option("--port-from ", "Auto-assign $PORT starting from ", "5400") .option("-t, --filter ", "Only run files matching (substring match)") .option("--parallel", "Run files in parallel") .action(async (fileArgs: string[], opts) => { const timeoutMs = parseDuration(opts.timeout) const paths = fileArgs.length > 0 ? fileArgs : ["."] let files = await findShoutFiles(paths) const start = performance.now() const results: TestResult[] = [] const cwd = process.cwd() if (opts.filter) { const pattern = opts.filter files = files.filter(f => relative(cwd, f).includes(pattern)) } if (files.length === 0) { console.error(opts.filter ? `No .shout files matching "${opts.filter}"` : "No .shout files found") process.exit(1) } const portFrom = parseInt(opts.portFrom, 10) if (Number.isNaN(portFrom)) { console.error("--port-from must be an integer") process.exit(1) } let nextPort = portFrom const runOne = async (filePath: string, port: number) => { const content = await readFile(filePath, "utf-8") const parsed = parse(relative(cwd, filePath), content) // Resolve directives in a single pass. Setup @env is collected separately // so that the user file's @env always takes precedence. const envVars: Record = {} const setupEnvVars: Record = {} const userEnvVars: Record = {} const setupCommands: Command[] = [] const teardownCommands: Command[] = [...parsed.teardownCommands] for (const d of parsed.directives) { if (d.type === "setup") { const setupPath = resolve(dirname(filePath), d.path) const setupContent = await readFile(setupPath, "utf-8") const setupParsed = parseSetup(relative(cwd, setupPath), setupContent) for (const sd of setupParsed.directives) { if (sd.type === "env") setupEnvVars[sd.key] = sd.value } setupCommands.push(...setupParsed.commands) teardownCommands.push(...setupParsed.teardownCommands) } else if (d.type === "env") { userEnvVars[d.key] = d.value } } Object.assign(envVars, setupEnvVars, userEnvVars) if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) { envVars["PORT"] = String(port) } const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] } const fileResult = await runFile(merged, { cleanEnv: opts.cleanEnv ?? false, pathDirs: opts.path, envVars, sourceDir: resolve(dirname(filePath)), projectDir: cwd, timeout: timeoutMs, onCommand: opts.verbose ? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`)) : undefined, }) // Check setup commands for failures for (let i = 0; i < setupCommands.length; i++) { const r = fileResult.results[i] const expected = setupCommands[i]!.exitCode const ok = expected === null ? r?.exitCode === 0 : expected === "*" ? r?.exitCode !== 0 : r?.exitCode === expected if (!ok) { if (opts.keep) { process.stderr.write(`${fileResult.tmpDir}\n`) } else { await cleanupTmpDir(fileResult.tmpDir) } return evaluateFile( parsed.path, [], `setup command failed (exit ${r?.exitCode ?? "?"}): $ ${setupCommands[i]!.command}`, ) } } const fileOwnResults = fileResult.results.slice( setupCommands.length, setupCommands.length + parsed.commands.length, ) // Warn on teardown failures const teardownResults = fileResult.results.slice(setupCommands.length + parsed.commands.length) for (let i = 0; i < teardownResults.length; i++) { const r = teardownResults[i] if (r && r.exitCode !== 0) { process.stderr.write( ansis.yellow(`warning: teardown command failed (exit ${r.exitCode}): $ ${teardownCommands[i]!.command}\n`), ) } } const testResult = evaluateFile( parsed.path, fileOwnResults, fileResult.error, ) if (opts.update && fileOwnResults.length > 0) { const updated = rewriteFile(parsed, fileOwnResults, 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 promises = files.map(async f => { const r = await runOne(f, nextPort++) printDots(r) return r }) results.push(...await Promise.all(promises)) process.stdout.write("\n") } else { for (const filePath of files) { const r = await runOne(filePath, nextPort++) 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 .command("upgrade") .description("Upgrade to the latest version") .action(async () => { const result = await Bun.spawn(["bun", "install", "-g", "@because/shout@latest"], { stdin: "inherit", stdout: "inherit", stderr: "inherit", }).exited process.exit(result) }) program.parse()