Skip to content

Commit 500f4f3

Browse files
authored
refactor: deepen replay test attempt module (#715)
1 parent 39e4682 commit 500f4f3

5 files changed

Lines changed: 547 additions & 420 deletions

File tree

src/cli-test-trace.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import type { ReplaySuiteTestResult } from './daemon/types.ts';
4+
import { formatDurationSeconds } from './utils/duration-format.ts';
5+
6+
type ReplayActionStartTrace = {
7+
type: 'replay_action_start';
8+
step: number;
9+
line?: number;
10+
command?: string;
11+
positionals?: unknown[];
12+
};
13+
14+
type ReplayActionStopTrace = {
15+
type: 'replay_action_stop';
16+
step: number;
17+
line?: number;
18+
command?: string;
19+
ok?: boolean;
20+
durationMs?: number;
21+
errorCode?: string;
22+
resultTiming?: Record<string, unknown>;
23+
};
24+
25+
export function replayTestStepLines(result: ReplaySuiteTestResult): string[] {
26+
if (result.status === 'skipped') return [];
27+
const tracePath = replayTestTimingTracePath(result);
28+
if (!tracePath) return [];
29+
const events = readReplayTimingTrace(tracePath);
30+
if (events.length === 0) return [];
31+
32+
const starts: ReplayActionStartTrace[] = [];
33+
const stops: Array<{ stop: ReplayActionStopTrace; start: ReplayActionStartTrace | undefined }> =
34+
[];
35+
for (const event of events) {
36+
if (isReplayActionStartTrace(event)) {
37+
starts.push(event);
38+
continue;
39+
}
40+
if (isReplayActionStopTrace(event)) {
41+
stops.push({ stop: event, start: consumeReplayActionStart(starts, event) });
42+
}
43+
}
44+
if (stops.length === 0) return [];
45+
46+
return [
47+
result.attempts > 1 ? `steps (attempt ${result.attempts}):` : 'steps:',
48+
...stops.map(({ stop, start }) => renderReplayStepTrace(stop, start)),
49+
];
50+
}
51+
52+
function consumeReplayActionStart(
53+
starts: ReplayActionStartTrace[],
54+
stop: ReplayActionStopTrace,
55+
): ReplayActionStartTrace | undefined {
56+
const stopCommand = stop.command;
57+
const matchingIndex = starts.findIndex(
58+
(start) =>
59+
start.step === stop.step &&
60+
(stopCommand === undefined || start.command === undefined || start.command === stopCommand),
61+
);
62+
if (matchingIndex < 0) return undefined;
63+
return starts.splice(matchingIndex, 1)[0];
64+
}
65+
66+
function replayTestTimingTracePath(
67+
result: Extract<ReplaySuiteTestResult, { status: 'passed' | 'failed' }>,
68+
): string | undefined {
69+
return result.artifactsDir
70+
? path.join(result.artifactsDir, `attempt-${result.attempts}`, 'replay-timing.ndjson')
71+
: undefined;
72+
}
73+
74+
function readReplayTimingTrace(tracePath: string): Record<string, unknown>[] {
75+
try {
76+
return fs
77+
.readFileSync(tracePath, 'utf8')
78+
.split(/\r?\n/)
79+
.filter((line) => line.trim().length > 0)
80+
.flatMap((line) => {
81+
try {
82+
const parsed = JSON.parse(line) as unknown;
83+
return isPlainRecord(parsed) ? [parsed] : [];
84+
} catch {
85+
return [];
86+
}
87+
});
88+
} catch {
89+
return [];
90+
}
91+
}
92+
93+
function isReplayActionStartTrace(event: Record<string, unknown>): event is ReplayActionStartTrace {
94+
return (
95+
event.type === 'replay_action_start' &&
96+
hasTraceStep(event) &&
97+
hasOptionalNumber(event, 'line') &&
98+
hasOptionalString(event, 'command') &&
99+
(event.positionals === undefined || Array.isArray(event.positionals))
100+
);
101+
}
102+
103+
function isReplayActionStopTrace(event: Record<string, unknown>): event is ReplayActionStopTrace {
104+
return allChecksPass([
105+
event.type === 'replay_action_stop' &&
106+
hasTraceStep(event) &&
107+
hasOptionalNumber(event, 'line') &&
108+
hasOptionalString(event, 'command'),
109+
hasOptionalBoolean(event, 'ok'),
110+
hasOptionalNumber(event, 'durationMs'),
111+
hasOptionalString(event, 'errorCode'),
112+
event.resultTiming === undefined || isPlainRecord(event.resultTiming),
113+
]);
114+
}
115+
116+
function hasTraceStep(event: Record<string, unknown>): boolean {
117+
return typeof event.step === 'number';
118+
}
119+
120+
function hasOptionalNumber(event: Record<string, unknown>, key: string): boolean {
121+
return event[key] === undefined || typeof event[key] === 'number';
122+
}
123+
124+
function hasOptionalString(event: Record<string, unknown>, key: string): boolean {
125+
return event[key] === undefined || typeof event[key] === 'string';
126+
}
127+
128+
function hasOptionalBoolean(event: Record<string, unknown>, key: string): boolean {
129+
return event[key] === undefined || typeof event[key] === 'boolean';
130+
}
131+
132+
function allChecksPass(checks: boolean[]): boolean {
133+
return checks.every(Boolean);
134+
}
135+
136+
function renderReplayStepTrace(
137+
stop: ReplayActionStopTrace,
138+
start: ReplayActionStartTrace | undefined,
139+
): string {
140+
const failed = stop.ok === false;
141+
const status = failed ? '[FAIL] ' : stop.ok === true ? '' : '[info] ';
142+
return ` ${status}${formatReplayStepCommand(start, stop)}${formatReplayStepDetails(stop, start)}`;
143+
}
144+
145+
function formatReplayStepDetails(
146+
stop: ReplayActionStopTrace,
147+
start: ReplayActionStartTrace | undefined,
148+
): string {
149+
const line = start?.line ?? stop.line;
150+
const details = [
151+
typeof line === 'number' ? `line ${line}` : '',
152+
typeof stop.durationMs === 'number' ? formatDurationSeconds(stop.durationMs) : '',
153+
stop.errorCode ?? '',
154+
stop.resultTiming ? `timing ${JSON.stringify(stop.resultTiming)}` : '',
155+
].filter(Boolean);
156+
return details.length > 0 ? ` (${details.join(', ')})` : '';
157+
}
158+
159+
function formatReplayStepCommand(
160+
start: ReplayActionStartTrace | undefined,
161+
stop: ReplayActionStopTrace,
162+
): string {
163+
const command = formatReplayStepCommandName(start?.command ?? stop.command);
164+
const positionals = start?.positionals ?? [];
165+
return [command, ...positionals.map(formatReplayStepArg)].join(' ');
166+
}
167+
168+
function formatReplayStepCommandName(command: string | undefined): string {
169+
if (!command) return 'unknown';
170+
if (!command.startsWith('__maestro')) return command;
171+
const name = command.slice('__maestro'.length);
172+
return name.length > 0 ? name[0]!.toLowerCase() + name.slice(1) : command;
173+
}
174+
175+
function formatReplayStepArg(value: unknown): string {
176+
if (typeof value === 'string') return JSON.stringify(value);
177+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
178+
return JSON.stringify(value);
179+
}
180+
181+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
182+
return !!value && typeof value === 'object' && !Array.isArray(value);
183+
}

0 commit comments

Comments
 (0)