Skip to content

Commit 1f87dd5

Browse files
committed
fix: improve maestro test output
1 parent 096785b commit 1f87dd5

9 files changed

Lines changed: 144 additions & 31 deletions

File tree

src/__tests__/cli-network.test.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ test('test command prints suite summary and exits non-zero on failures', async (
106106
assert.equal(result.calls[0]?.meta?.requestProgress, 'replay-test');
107107
assert.match(result.stderr, /Running replay suite\.\.\./);
108108
assert.doesNotMatch(result.stdout, /PASS \/tmp\/01-pass\.ad/);
109-
assert.match(result.stdout, /FAIL \/tmp\/02-fail\.ad after 2 attempts \(5ms\)/);
109+
assert.match(result.stdout, /FAIL 02-fail\.ad after 2 attempts \(0\.005s\)/);
110110
assert.match(result.stdout, /Replay failed at step 1 \(open Demo\): boom/);
111111
assert.match(result.stdout, /artifacts: \/tmp\/test-artifacts\/02-fail/);
112112
assert.doesNotMatch(result.stdout, /SKIP \/tmp\/03-skip\.ad/);
113-
assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 25ms/);
113+
assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 0\.025s/);
114114
});
115115

116116
test('test command --verbose prints all test statuses', async () => {
@@ -120,8 +120,8 @@ test('test command --verbose prints all test statuses', async () => {
120120

121121
assert.equal(result.code, 1);
122122
assert.match(result.stderr, /Running replay suite\.\.\./);
123-
assert.match(result.stdout, /PASS \/tmp\/01-pass\.ad \(10ms\)/);
124-
assert.match(result.stdout, /SKIP \/tmp\/03-skip\.ad/);
123+
assert.match(result.stdout, /PASS 01-pass\.ad \(0\.01s\)/);
124+
assert.match(result.stdout, /SKIP 03-skip\.ad/);
125125
});
126126

127127
test('test command reports flaky passed-on-retry cases in the default summary', async () => {
@@ -138,20 +138,35 @@ test('test command reports flaky passed-on-retry cases in the default summary',
138138
failures: [],
139139
tests: [
140140
{
141-
file: '/tmp/01-flaky.ad',
141+
file: '/tmp/auth-flow.yml',
142+
title: 'Authentication flow',
142143
session: 'default:test:suite:1',
143144
status: 'passed',
144-
durationMs: 10,
145+
durationMs: 112151,
146+
finalAttemptDurationMs: 17492,
145147
attempts: 2,
148+
attemptFailures: [
149+
{
150+
attempt: 1,
151+
message: 'Replay failed at step 3 (tapOn "Log in"): selector not found',
152+
durationMs: 94659,
153+
},
154+
],
146155
},
147156
],
148157
},
149158
}));
150159

151160
assert.equal(result.code, null);
152161
assert.match(result.stderr, /Running replay suite\.\.\./);
153-
assert.match(result.stdout, /FLAKY \/tmp\/01-flaky\.ad after 2 attempts \(10ms\)/);
154-
assert.match(result.stdout, /Test summary: 1 passed, 0 failed, 1 flaky in 25ms/);
162+
assert.doesNotMatch(result.stdout, /FLAKY/);
163+
assert.match(result.stdout, /Test summary: 1 passed, 0 failed, 1 flaky in 0\.025s/);
164+
assert.match(result.stdout, /Flaky tests:/);
165+
assert.match(result.stdout, /PASS Authentication flow after 2 attempts, total 112\.2s/);
166+
assert.match(
167+
result.stdout,
168+
/attempt 1 failed \(94\.7s\): Replay failed at step 3 \(tapOn "Log in"\): selector not found/,
169+
);
155170
});
156171

157172
test('test --maestro forwards Maestro backend and platform for directory suites', async () => {

src/cli-test.ts

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,15 @@ function renderReplayTestSummary(
4040
for (const entry of data.failures) {
4141
renderFailedTestResult(entry);
4242
}
43-
for (const entry of flaky) {
44-
renderFlakyTestResult(entry);
45-
}
4643
}
4744

4845
const durationMs = typeof data.durationMs === 'number' ? data.durationMs : undefined;
4946
const flakySuffix = flaky.length > 0 ? `, ${flaky.length} flaky` : '';
47+
const durationSuffix = durationMs !== undefined ? ` in ${formatDurationSeconds(durationMs)}` : '';
5048
process.stdout.write(
51-
`Test summary: ${data.passed} passed, ${data.failed} failed${flakySuffix}${durationMs !== undefined ? ` in ${durationMs}ms` : ''}\n`,
49+
`Test summary: ${data.passed} passed, ${data.failed} failed${flakySuffix}${durationSuffix}\n`,
5250
);
51+
renderFlakyTestSummary(flaky);
5352
return getReplayTestExitCode(data);
5453
}
5554

@@ -59,11 +58,10 @@ function renderVerboseTestResult(result: ReplaySuiteTestResult): void {
5958
return;
6059
}
6160

62-
const prefix = replayResultPrefix(result);
63-
const attemptSuffix =
64-
'attempts' in result && result.attempts > 1 ? ` after ${result.attempts} attempts` : '';
65-
const durationSuffix = result.durationMs > 0 ? ` (${result.durationMs}ms)` : '';
66-
process.stdout.write(`${prefix} ${result.file}${attemptSuffix}${durationSuffix}\n`);
61+
const durationSuffix = formatReplayTestDurationSuffix(result);
62+
process.stdout.write(
63+
`${replayResultPrefix(result)} ${replayTestDisplayName(result)}${durationSuffix}\n`,
64+
);
6765
if (result.status === 'skipped') {
6866
process.stdout.write(` ${result.message ?? 'skipped'}\n`);
6967
}
@@ -73,16 +71,16 @@ function renderFailedTestResult(
7371
result: Extract<ReplaySuiteTestResult, { status: 'failed' }>,
7472
): void {
7573
const attemptSuffix = result.attempts > 1 ? ` after ${result.attempts} attempts` : '';
76-
const durationSuffix = result.durationMs > 0 ? ` (${result.durationMs}ms)` : '';
77-
process.stdout.write(`FAIL ${result.file}${attemptSuffix}${durationSuffix}\n`);
74+
const durationSuffix = formatReplayTestDurationSuffix(result);
75+
process.stdout.write(`FAIL ${replayTestDisplayName(result)}${attemptSuffix}${durationSuffix}\n`);
7876
process.stdout.write(` ${result.error?.message ?? 'Unknown test failure'}\n`);
7977
for (const line of replayFailureConsoleLines(result)) {
8078
process.stdout.write(` ${line}\n`);
8179
}
8280
}
8381

8482
function replayResultPrefix(result: ReplaySuiteTestResult): string {
85-
if (result.status === 'passed') return result.attempts > 1 ? 'FLAKY' : 'PASS';
83+
if (result.status === 'passed') return 'PASS';
8684
if (result.status === 'skipped') return 'SKIP';
8785
return 'INFO';
8886
}
@@ -98,17 +96,48 @@ function replayFailureConsoleLines(
9896
].filter(Boolean);
9997
}
10098

101-
function renderFlakyTestResult(result: Extract<ReplaySuiteTestResult, { status: 'passed' }>): void {
102-
const durationSuffix = result.durationMs > 0 ? ` (${result.durationMs}ms)` : '';
103-
process.stdout.write(`FLAKY ${result.file} after ${result.attempts} attempts${durationSuffix}\n`);
104-
}
105-
10699
function isFlakyReplayTestResult(
107100
result: ReplaySuiteTestResult,
108101
): result is Extract<ReplaySuiteTestResult, { status: 'passed' }> {
109102
return result.status === 'passed' && result.attempts > 1;
110103
}
111104

105+
function renderFlakyTestSummary(
106+
results: Array<Extract<ReplaySuiteTestResult, { status: 'passed' }>>,
107+
): void {
108+
if (results.length === 0) return;
109+
process.stdout.write('Flaky tests:\n');
110+
for (const result of results) {
111+
const durationSuffix =
112+
result.durationMs > 0 ? `, total ${formatDurationSeconds(result.durationMs)}` : '';
113+
process.stdout.write(
114+
` PASS ${replayTestDisplayName(result)} after ${result.attempts} attempts${durationSuffix}\n`,
115+
);
116+
for (const failure of result.attemptFailures ?? []) {
117+
const attemptDuration =
118+
typeof failure.durationMs === 'number'
119+
? ` (${formatDurationSeconds(failure.durationMs)})`
120+
: '';
121+
process.stdout.write(
122+
` attempt ${failure.attempt} failed${attemptDuration}: ${failure.message}\n`,
123+
);
124+
}
125+
}
126+
}
127+
128+
function replayTestDisplayName(result: ReplaySuiteTestResult): string {
129+
const title = result.title?.trim();
130+
return title && title.length > 0 ? title : path.basename(result.file);
131+
}
132+
133+
function formatReplayTestDurationSuffix(result: ReplaySuiteTestResult): string {
134+
const durationMs =
135+
result.status === 'passed' && typeof result.finalAttemptDurationMs === 'number'
136+
? result.finalAttemptDurationMs
137+
: result.durationMs;
138+
return durationMs > 0 ? ` (${formatDurationSeconds(durationMs)})` : '';
139+
}
140+
112141
function getReplayTestExitCode(data: ReplaySuiteResult): number {
113142
return data.failed > 0 ? 1 : 0;
114143
}
@@ -144,7 +173,7 @@ function buildReplayJunitXml(suite: ReplaySuiteResult): string {
144173
}
145174

146175
function renderJUnitTestCase(test: ReplaySuiteTestResult): string[] {
147-
const name = xmlEscape(path.basename(test.file));
176+
const name = xmlEscape(replayTestDisplayName(test));
148177
const className = xmlEscape(
149178
path.dirname(test.file) === '.' ? test.file : path.dirname(test.file),
150179
);
@@ -239,6 +268,13 @@ function formatJUnitSeconds(durationMs: number): string {
239268
return (Math.max(0, durationMs) / 1000).toFixed(3);
240269
}
241270

271+
function formatDurationSeconds(durationMs: number): string {
272+
const seconds = Math.max(0, durationMs) / 1000;
273+
if (seconds >= 10) return `${seconds.toFixed(1)}s`;
274+
if (seconds >= 1) return `${seconds.toFixed(2)}s`;
275+
return `${seconds.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}s`;
276+
}
277+
242278
function xmlEscape(value: string): string {
243279
return value
244280
.replaceAll('&', '&amp;')

src/compat/maestro/replay-flow.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export function parseMaestroReplayFlow(
2121
return parseMaestroReplayFlowInternal(script, createParseContext(options));
2222
}
2323

24+
export function readMaestroFlowName(script: string): string | undefined {
25+
const values = parseYamlDocuments(script);
26+
const { config } = splitMaestroDocuments(values);
27+
return config.name;
28+
}
29+
2430
function parseMaestroReplayFlowInternal(
2531
script: string,
2632
context: MaestroParseContext,
@@ -259,6 +265,7 @@ function normalizeConfig(value: unknown): MaestroFlowConfig {
259265
throw new AppError('INVALID_ARGS', 'Maestro flow config must be a YAML map.');
260266
}
261267
return {
268+
...(typeof value.name === 'string' && value.name.length > 0 ? { name: value.name } : {}),
262269
...(typeof value.appId === 'string' && value.appId.length > 0 ? { appId: value.appId } : {}),
263270
...(isPlainRecord(value.env) ? { env: readEnvMap(value.env, 'env') } : {}),
264271
...(Array.isArray(value.onFlowStart)

src/compat/maestro/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ParsedReplayScript, ReplayScriptMetadata } from '../../replay/script.ts';
22

33
export type MaestroFlowConfig = {
4+
name?: string;
45
appId?: string;
56
env?: Record<string, string>;
67
onFlowStart?: MaestroCommand[];

src/daemon/handlers/__tests__/session-test-discovery.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ test('discoverReplayTestEntries rejects empty post-filter suites', () => {
5757

5858
test('discoverReplayTestEntries includes Maestro yaml flows for Maestro test suites', () => {
5959
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-discovery-maestro-'));
60-
fs.writeFileSync(path.join(root, '01-flow.yaml'), 'appId: demo\n---\n- launchApp\n');
60+
fs.writeFileSync(
61+
path.join(root, '01-flow.yaml'),
62+
'appId: demo\nname: Authentication flow\n---\n- launchApp\n',
63+
);
6164
fs.writeFileSync(path.join(root, '02-flow.yml'), 'appId: demo\n---\n- launchApp\n');
6265
fs.writeFileSync(path.join(root, '03-flow.ad'), 'open "Demo"\n');
6366

@@ -76,4 +79,8 @@ test('discoverReplayTestEntries includes Maestro yaml flows for Maestro test sui
7679
entries.map((entry) => entry.kind),
7780
['run', 'run', 'run'],
7881
);
82+
assert.equal(entries[0]?.kind, 'run');
83+
if (entries[0]?.kind === 'run') {
84+
assert.equal(entries[0].title, 'Authentication flow');
85+
}
7986
});

src/daemon/handlers/__tests__/session-test-suite.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ test('test discovers Maestro YAML suites when replay backend is set', async () =
6565
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-maestro-'));
6666
fs.writeFileSync(
6767
path.join(root, 'auth-flow.yml'),
68-
['appId: demo.app', '---', '- launchApp', ''].join('\n'),
68+
['appId: demo.app', 'name: Authentication flow', '---', '- launchApp', ''].join('\n'),
6969
);
7070

7171
const invoked: DaemonRequest[] = [];
@@ -91,6 +91,7 @@ test('test discovers Maestro YAML suites when replay backend is set', async () =
9191
expect(invoked.map((req) => [req.command, req.positionals])).toEqual([['open', ['demo.app']]]);
9292
expect(data.passed).toBe(1);
9393
expect(data.failed).toBe(0);
94+
expect((data.tests as Array<Record<string, unknown>>)[0]?.title).toBe('Authentication flow');
9495
});
9596

9697
test('test emits progress when attempts retry and pass', async () => {
@@ -130,6 +131,13 @@ test('test emits progress when attempts retry and pass', async () => {
130131

131132
const data = expectOkData(response);
132133
expect(data.passed).toBe(1);
134+
expect((data.tests as Array<Record<string, unknown>>)[0]?.attemptFailures).toEqual([
135+
{
136+
attempt: 1,
137+
message: 'Replay failed at step 1 (open "Demo"): first attempt failed',
138+
durationMs: expect.any(Number),
139+
},
140+
]);
133141
expect(events.map((event) => event.status)).toEqual(['fail', 'pass']);
134142
expect(events[0]).toMatchObject({
135143
type: 'replay-test',

src/daemon/handlers/session-test-discovery.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PlatformSelector } from '../../utils/device.ts';
55
import { resolveRequestTrackingId } from '../request-cancel.ts';
66
import { SessionStore } from '../session-store.ts';
77
import { readReplayScriptMetadata, type ReplayScriptMetadata } from '../../replay/script.ts';
8+
import { readMaestroFlowName } from '../../compat/maestro/replay-flow.ts';
89

910
const GLOB_PATTERN_CHARS = /[*?[\]{}]/;
1011

@@ -14,6 +15,7 @@ export type ReplayTestDiscoveryEntry =
1415
| {
1516
kind: 'run';
1617
path: string;
18+
title?: string;
1719
metadata: ReplayScriptMetadata;
1820
}
1921
| {
@@ -42,13 +44,14 @@ export function discoverReplayTestEntries(params: {
4244
for (const filePath of filePaths) {
4345
const script = fs.readFileSync(filePath, 'utf8');
4446
const metadata = readReplayScriptMetadata(script);
47+
const title = readReplayTestTitle(script, filePath, replayBackend);
4548
if (!platformFilter) {
46-
entries.push({ kind: 'run', path: filePath, metadata });
49+
entries.push({ kind: 'run', path: filePath, title, metadata });
4750
continue;
4851
}
4952
if (!metadata.platform) {
5053
if (isMaestroReplayBackend(replayBackend)) {
51-
entries.push({ kind: 'run', path: filePath, metadata });
54+
entries.push({ kind: 'run', path: filePath, title, metadata });
5255
} else {
5356
entries.push({
5457
kind: 'skip',
@@ -62,7 +65,7 @@ export function discoverReplayTestEntries(params: {
6265
if (!matchesPlatformFilter(platformFilter, metadata.platform)) {
6366
continue;
6467
}
65-
entries.push({ kind: 'run', path: filePath, metadata });
68+
entries.push({ kind: 'run', path: filePath, title, metadata });
6669
}
6770

6871
const runnableCount = entries.filter((entry) => entry.kind === 'run').length;
@@ -177,6 +180,16 @@ function isMaestroReplayBackend(replayBackend: string | undefined): boolean {
177180
return replayBackend === 'maestro';
178181
}
179182

183+
function readReplayTestTitle(
184+
script: string,
185+
filePath: string,
186+
replayBackend: string | undefined,
187+
): string | undefined {
188+
return isMaestroReplayBackend(replayBackend) && path.extname(filePath) !== '.ad'
189+
? readMaestroFlowName(script)
190+
: undefined;
191+
}
192+
180193
function looksLikeGlob(value: string): boolean {
181194
return GLOB_PATTERN_CHARS.test(value);
182195
}

src/daemon/handlers/session-test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,14 @@ async function runReplayTestCase(
150150
let finalResponse: DaemonResponse | undefined;
151151
let finalSessionName = '';
152152
let attempts = 0;
153+
let finalAttemptDurationMs = 0;
154+
const attemptFailures: NonNullable<
155+
Extract<ReplaySuiteTestResult, { status: 'passed' }>['attemptFailures']
156+
> = [];
153157

154158
for (let attemptIndex = 0; attemptIndex <= retries; attemptIndex += 1) {
155159
attempts = attemptIndex + 1;
160+
const attemptStartedAt = Date.now();
156161
const testSessionName = buildReplayTestSessionName(
157162
sessionName,
158163
suiteInvocationId,
@@ -181,6 +186,7 @@ async function runReplayTestCase(
181186
runReplay,
182187
cleanupSession,
183188
});
189+
finalAttemptDurationMs = Date.now() - attemptStartedAt;
184190
materializeReplayTestAttemptArtifacts({
185191
response,
186192
filePath: entry.path,
@@ -192,6 +198,11 @@ async function runReplayTestCase(
192198
finalResponse = response;
193199
finalSessionName = testSessionName;
194200
if (response.ok) break;
201+
attemptFailures.push({
202+
attempt: attempts,
203+
message: response.error.message,
204+
durationMs: finalAttemptDurationMs,
205+
});
195206
if (isReplayInfrastructureFailure(response)) break;
196207
if (attemptIndex >= retries) break;
197208
emitRequestProgress({
@@ -222,13 +233,16 @@ async function runReplayTestCase(
222233
});
223234
return {
224235
file: entry.path,
236+
title: entry.title,
225237
session: finalSessionName,
226238
status: 'passed',
227239
durationMs,
240+
finalAttemptDurationMs,
228241
attempts,
229242
artifactsDir: testArtifactsDir,
230243
replayed: typeof finalResponse.data?.replayed === 'number' ? finalResponse.data.replayed : 0,
231244
healed: typeof finalResponse.data?.healed === 'number' ? finalResponse.data.healed : 0,
245+
...(attemptFailures.length > 0 ? { attemptFailures } : {}),
232246
};
233247
}
234248

@@ -252,6 +266,7 @@ async function runReplayTestCase(
252266
});
253267
return {
254268
file: entry.path,
269+
title: entry.title,
255270
session: finalSessionName,
256271
status: 'failed',
257272
durationMs,

0 commit comments

Comments
 (0)