Skip to content

Commit 8032260

Browse files
committed
enhance scenario execution logging and summary reporting
1 parent 5cca90f commit 8032260

4 files changed

Lines changed: 161 additions & 23 deletions

File tree

src/cli/parallel-display.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it, beforeEach, afterEach } from "node:test";
3+
import type { ScenarioResult } from "../types/verdict.js";
4+
import { emptyTranscript } from "./concurrency.js";
5+
import { ParallelScenarioDisplay } from "./parallel-display.js";
6+
7+
function stubResult(
8+
overrides: Partial<ScenarioResult> & Pick<ScenarioResult, "scenario" | "status">,
9+
): ScenarioResult {
10+
return {
11+
filePath: "scenarios/example.md",
12+
durationMs: 1000,
13+
verdict: null,
14+
transcript: emptyTranscript(),
15+
...overrides,
16+
};
17+
}
18+
19+
describe("ParallelScenarioDisplay", () => {
20+
let stdoutLines: string[];
21+
let originalIsTTY: boolean | undefined;
22+
let originalWrite: typeof process.stdout.write;
23+
24+
beforeEach(() => {
25+
stdoutLines = [];
26+
originalIsTTY = process.stdout.isTTY;
27+
originalWrite = process.stdout.write.bind(process.stdout);
28+
Object.defineProperty(process.stdout, "isTTY", {
29+
configurable: true,
30+
value: false,
31+
});
32+
process.stdout.write = ((chunk: string | Uint8Array) => {
33+
stdoutLines.push(String(chunk));
34+
return true;
35+
}) as typeof process.stdout.write;
36+
});
37+
38+
afterEach(() => {
39+
Object.defineProperty(process.stdout, "isTTY", {
40+
configurable: true,
41+
value: originalIsTTY,
42+
});
43+
process.stdout.write = originalWrite;
44+
});
45+
46+
it("prints only the final status line when stdout is not a TTY", () => {
47+
const display = new ParallelScenarioDisplay();
48+
const result = stubResult({ scenario: "login-admin", status: "pass" });
49+
50+
display.start("login-admin");
51+
display.finish("login-admin", result, ["[login-admin] hook log"], () => {});
52+
53+
assert.equal(stdoutLines.join(""), "[login-admin] hook log\n[login-admin] passed\n");
54+
});
55+
56+
it("prints failure reason via callback after the final status line", () => {
57+
const display = new ParallelScenarioDisplay();
58+
const result = stubResult({
59+
scenario: "checkout",
60+
status: "fail",
61+
verdict: {
62+
status: "fail",
63+
summary: "Cart empty",
64+
checkpoints: [],
65+
},
66+
});
67+
const failureLines: string[] = [];
68+
69+
display.finish("checkout", result, [], (r) => {
70+
failureLines.push(r.verdict?.summary ?? "");
71+
});
72+
73+
assert.match(stdoutLines.at(-1) ?? "", /checkout.*fail/);
74+
assert.deepEqual(failureLines, ["Cart empty"]);
75+
});
76+
});

src/cli/parallel-display.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import chalk from "chalk";
2+
import ora, { type Ora } from "ora";
3+
import type { ScenarioResult } from "../types/verdict.js";
4+
5+
export type ScenarioFailureLogger = (result: ScenarioResult) => void;
6+
7+
/** In-place status lines for parallel scenario workers. */
8+
export class ParallelScenarioDisplay {
9+
private readonly spinners = new Map<string, Ora>();
10+
private readonly isTTY = Boolean(process.stdout.isTTY);
11+
12+
start(name: string): void {
13+
if (!this.isTTY) return;
14+
const spinner = ora({ text: `[${name}] running...` }).start();
15+
this.spinners.set(name, spinner);
16+
}
17+
18+
finish(
19+
name: string,
20+
result: ScenarioResult,
21+
bufferedLines: string[],
22+
logFailureReason: ScenarioFailureLogger,
23+
): void {
24+
for (const line of bufferedLines) {
25+
process.stdout.write(`${line}\n`);
26+
}
27+
28+
if (result.status === "pass") {
29+
const message = chalk.green(`[${name}] passed`);
30+
const spinner = this.spinners.get(name);
31+
if (spinner) {
32+
spinner.succeed(message);
33+
this.spinners.delete(name);
34+
} else {
35+
console.log(message);
36+
}
37+
return;
38+
}
39+
40+
const message = chalk.red(`[${name}] ${result.status}`);
41+
const spinner = this.spinners.get(name);
42+
if (spinner) {
43+
spinner.fail(message);
44+
this.spinners.delete(name);
45+
} else {
46+
console.log(message);
47+
}
48+
logFailureReason(result);
49+
}
50+
}

src/cli/run.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
isScenarioRetryAllowed,
7373
} from "../healing/classify.js";
7474
import { spawnScenarioWorker, cleanupRunDirHeartbeats } from "./subprocess.js";
75+
import { ParallelScenarioDisplay } from "./parallel-display.js";
7576
import {
7677
buildReport,
7778
createRunId,
@@ -128,21 +129,27 @@ function logScenarioFailureReason(result: ScenarioResult): void {
128129
}
129130
}
130131

131-
function logRunSummary(report: {
132-
results: ScenarioResult[];
133-
summary: {
134-
total: number;
135-
passed: number;
136-
failed: number;
137-
errors: number;
138-
skipped: number;
139-
};
140-
}): void {
132+
function logRunSummary(
133+
report: {
134+
results: ScenarioResult[];
135+
summary: {
136+
total: number;
137+
passed: number;
138+
failed: number;
139+
errors: number;
140+
skipped: number;
141+
};
142+
},
143+
options?: { compact?: boolean },
144+
): void {
141145
if (report.results.length === 0) {
142146
return;
143147
}
144148
console.log("\nRun summary:");
145149
for (const result of report.results) {
150+
if (options?.compact && result.status === "pass") {
151+
continue;
152+
}
146153
const duration = `${(result.durationMs / 1000).toFixed(1)}s`;
147154
if (result.status === "pass") {
148155
console.log(chalk.green(` ✓ ${result.scenario} — pass (${duration})`));
@@ -679,25 +686,22 @@ export async function executeRun(
679686
workerHeartbeatIntervalMs: config.agent.workerHeartbeatIntervalMs,
680687
};
681688

689+
const display = new ParallelScenarioDisplay();
690+
682691
const partial = await mapWithConcurrency(
683692
selectedSummaries,
684693
parallel,
685694
async (summary) => {
686695
const name = summary.frontmatter.name;
687-
console.log(`[${name}] running...`);
688-
const result = await spawnScenarioWorker({
696+
display.start(name);
697+
const { result, bufferedLines } = await spawnScenarioWorker({
689698
scenarioFilePath: summary.filePath,
690699
scenarioName: name,
691700
runDir,
692701
cwd,
693702
options: workerOptions,
694703
});
695-
if (result.status === "pass") {
696-
console.log(chalk.green(`[${name}] passed`));
697-
} else {
698-
console.log(chalk.red(`[${name}] ${result.status}`));
699-
logScenarioFailureReason(result);
700-
}
704+
display.finish(name, result, bufferedLines, logScenarioFailureReason);
701705
return result;
702706
},
703707
{
@@ -789,7 +793,7 @@ export async function executeRun(
789793
const report = buildReport(runId, startedAt, results);
790794
writeReport(runDir, report, redactor);
791795

792-
logRunSummary(report);
796+
logRunSummary(report, { compact: parallel !== undefined });
793797

794798
const reportPath = finalizeRunReport(runDir, zipDestination);
795799
console.log(`\nReport: ${reportPath}`);

src/cli/subprocess.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,15 @@ function readWorkerResult(
146146
return JSON.parse(raw) as ScenarioResult;
147147
}
148148

149+
export interface ScenarioWorkerOutcome {
150+
result: ScenarioResult;
151+
/** Prefixed worker log lines, buffered until the scenario completes. */
152+
bufferedLines: string[];
153+
}
154+
149155
export async function spawnScenarioWorker(
150156
request: ScenarioWorkerRequest,
151-
): Promise<ScenarioResult> {
157+
): Promise<ScenarioWorkerOutcome> {
152158
const { command, baseArgs: _baseArgs } = resolveCliInvocation();
153159
const args = buildWorkerArgs(request);
154160
const name = request.scenarioName;
@@ -203,15 +209,17 @@ export async function spawnScenarioWorker(
203209
heartbeatWatchdogs.set(child.pid!, watchTimer);
204210
heartbeatFilePaths.set(child.pid!, heartbeatFile);
205211

212+
const bufferedLines: string[] = [];
213+
206214
child.stdout?.on("data", (chunk: Buffer) => {
207215
for (const line of chunk.toString().split("\n")) {
208-
if (line.trim()) process.stdout.write(`[${name}] ${line}\n`);
216+
if (line.trim()) bufferedLines.push(`[${name}] ${line}`);
209217
}
210218
});
211219

212220
child.stderr?.on("data", (chunk: Buffer) => {
213221
for (const line of chunk.toString().split("\n")) {
214-
if (line.trim()) process.stderr.write(`[${name}] ${line}\n`);
222+
if (line.trim()) bufferedLines.push(`[${name}] ${line}`);
215223
}
216224
});
217225

@@ -246,7 +254,7 @@ export async function spawnScenarioWorker(
246254
}
247255
try {
248256
const result = readWorkerResult(request.runDir, name);
249-
resolve(result);
257+
resolve({ result, bufferedLines });
250258
} catch (err) {
251259
reject(
252260
new Error(

0 commit comments

Comments
 (0)