Stream command results as dots in real time

This commit is contained in:
Chris Wanstrath 2026-03-15 20:57:28 -07:00
parent f0c9dc8009
commit ebeedac78a
2 changed files with 70 additions and 14 deletions

View File

@ -6,9 +6,10 @@ import { program } from "commander"
import ansis from "ansis" import ansis from "ansis"
import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts" import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts"
import { runFile, cleanupTmpDir } from "../run.ts" import { runFile, cleanupTmpDir, type CommandResult } from "../run.ts"
import { evaluateFile, formatFailure, formatSummary } from "../format.ts" import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
import type { TestResult } from "../format.ts" import type { TestResult } from "../format.ts"
import { matchOutput } from "../match.ts"
import { parseDuration } from "../duration.ts" import { parseDuration } from "../duration.ts"
import { rewriteFile } from "../update.ts" import { rewriteFile } from "../update.ts"
@ -138,6 +139,26 @@ program
} }
const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] } const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] }
const setupLen = setupCommands.length
const userLen = parsed.commands.length
const printDot = (result: CommandResult) => {
const { command, actual, exitCode } = result
const outputMatches = matchOutput(command.expected, actual)
let exitCodeMismatch = false
if (command.exitCode === null) {
exitCodeMismatch = exitCode !== 0
} else if (command.exitCode === "*") {
exitCodeMismatch = exitCode === 0
} else {
exitCodeMismatch = exitCode !== command.exitCode
}
if (outputMatches && !exitCodeMismatch) {
process.stdout.write(ansis.green("."))
} else {
process.stdout.write(ansis.red("F"))
}
}
const fileResult = await runFile(merged, { const fileResult = await runFile(merged, {
cleanEnv: opts.cleanEnv ?? false, cleanEnv: opts.cleanEnv ?? false,
pathDirs: opts.path, pathDirs: opts.path,
@ -148,6 +169,11 @@ program
onCommand: opts.verbose onCommand: opts.verbose
? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`)) ? (cmd) => process.stderr.write(ansis.dim(` $ ${cmd.command}\n`))
: undefined, : undefined,
onCommandResult: (index, result) => {
if (index >= setupLen && index < setupLen + userLen) {
printDot(result)
}
},
}) })
// Check setup commands for failures // Check setup commands for failures
@ -211,24 +237,16 @@ program
return testResult return testResult
} }
const printDots = (r: TestResult) => { const printErrorDot = (r: TestResult) => {
if (r.error) { if (r.error) {
process.stdout.write(ansis.red("F")) 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) { if (opts.parallel) {
const promises = files.map(async f => { const promises = files.map(async f => {
const r = await runOne(f, nextPort++) const r = await runOne(f, nextPort++)
printDots(r) printErrorDot(r)
return r return r
}) })
results.push(...await Promise.all(promises)) results.push(...await Promise.all(promises))
@ -236,7 +254,7 @@ program
} else { } else {
for (const filePath of files) { for (const filePath of files) {
const r = await runOne(filePath, nextPort++) const r = await runOne(filePath, nextPort++)
printDots(r) printErrorDot(r)
results.push(r) results.push(r)
} }
process.stdout.write("\n") process.stdout.write("\n")

View File

@ -25,7 +25,9 @@ type RunOptions = {
sourceDir?: string sourceDir?: string
projectDir?: string projectDir?: string
timeout: number timeout: number
verbose?: boolean
onCommand?: (cmd: Command) => void onCommand?: (cmd: Command) => void
onCommandResult?: (index: number, result: CommandResult) => void
} }
function killTree(pid: number): void { function killTree(pid: number): void {
@ -192,7 +194,7 @@ export async function runFile(
} }
const sentinel = SENTINEL_PREFIX const sentinel = SENTINEL_PREFIX
const verbose = options.verbose && !!options.onCommand const verbose = !!(options.verbose && options.onCommand)
const script = buildScript(file.commands, sentinel, verbose) const script = buildScript(file.commands, sentinel, verbose)
const env: Record<string, string> = options.cleanEnv const env: Record<string, string> = options.cleanEnv
@ -236,7 +238,21 @@ export async function runFile(
const totalTimeout = options.timeout * file.commands.length const totalTimeout = options.timeout * file.commands.length
const lastSentinelSuffix = `_${file.commands.length - 1}__` const lastSentinelSuffix = `_${file.commands.length - 1}__`
const stdout = await readUntilSentinel(proc.stdout, sentinel, lastSentinelSuffix, totalTimeout) const onSentinel = options.onCommandResult
? (index: number, exitCode: number, rawOutput: string) => {
let lines = rawOutput.split("\n")
if (lines.length > 0 && lines[0] === "") lines.shift()
lines = trimTrailingEmpty(lines)
if (lines.length === 1 && lines[0] === "") lines = []
const result: CommandResult = {
command: file.commands[index]!,
actual: lines.map(stripAnsi),
exitCode,
}
options.onCommandResult!(index, result)
}
: undefined
const stdout = await readUntilSentinel(proc.stdout, sentinel, lastSentinelSuffix, totalTimeout, onSentinel)
if (!verbose) { if (!verbose) {
await readWithTimeout(proc.stderr, 1000).catch(() => "") await readWithTimeout(proc.stderr, 1000).catch(() => "")
} }
@ -272,10 +288,13 @@ async function readUntilSentinel(
sentinelPrefix: string, sentinelPrefix: string,
sentinelSuffix: string, sentinelSuffix: string,
timeoutMs: number, timeoutMs: number,
onSentinel?: (index: number, exitCode: number, outputBefore: string) => void,
): Promise<string> { ): Promise<string> {
const reader = stream.getReader() const reader = stream.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let accumulated = "" let accumulated = ""
let sentinelsReported = 0
let lastSentinelEnd = 0
let timerId: ReturnType<typeof setTimeout> let timerId: ReturnType<typeof setTimeout>
const timeout = new Promise<never>((_, reject) => const timeout = new Promise<never>((_, reject) =>
@ -288,6 +307,25 @@ async function readUntilSentinel(
if (done) break if (done) break
if (value) { if (value) {
accumulated += decoder.decode(value, { stream: true }) accumulated += decoder.decode(value, { stream: true })
// Detect new sentinels incrementally for streaming results
if (onSentinel) {
const regex = new RegExp(`${sentinelPrefix}(\\d+)_(\\d+)__`, "g")
regex.lastIndex = lastSentinelEnd
let match
while ((match = regex.exec(accumulated)) !== null) {
const idx = parseInt(match[2]!, 10)
if (idx >= sentinelsReported) {
const exitCode = parseInt(match[1]!, 10)
const output = accumulated.slice(lastSentinelEnd, match.index)
onSentinel(idx, exitCode, output)
sentinelsReported = idx + 1
lastSentinelEnd = match.index + match[0].length
if (accumulated[lastSentinelEnd] === "\n") lastSentinelEnd++
}
}
}
// Check if the last sentinel has appeared (prefix + exitcode + suffix) // Check if the last sentinel has appeared (prefix + exitcode + suffix)
const prefixIdx = accumulated.lastIndexOf(sentinelPrefix) const prefixIdx = accumulated.lastIndexOf(sentinelPrefix)
if (prefixIdx !== -1 && accumulated.indexOf(sentinelSuffix, prefixIdx) !== -1) break if (prefixIdx !== -1 && accumulated.indexOf(sentinelSuffix, prefixIdx) !== -1) break