Stream command results as dots in real time
This commit is contained in:
parent
f0c9dc8009
commit
ebeedac78a
|
|
@ -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")
|
||||||
|
|
|
||||||
42
src/run.ts
42
src/run.ts
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user