296 lines
8.8 KiB
TypeScript
Executable File
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()
|