shout/src/cli/index.ts

296 lines
8.8 KiB
TypeScript
Executable File

#!/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<string[]> {
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<string[]> {
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 <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("--port-from <n>", "Auto-assign $PORT starting from <n>", "5400")
.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 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<string, string> = {}
const setupEnvVars: Record<string, string> = {}
const userEnvVars: Record<string, string> = {}
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()