175 lines
4.5 KiB
TypeScript
Executable File
175 lines
4.5 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()
|
|
}
|
|
|
|
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 <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")
|
|
.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()
|