Skip to content

Commit 7f0d67e

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

9 files changed

Lines changed: 188 additions & 31 deletions

File tree

src/__tests__/cli-network.test.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { runCliCapture } from './cli-capture.ts';
88
function makeFailedReplayResult() {
99
return {
1010
file: '/tmp/02-fail.ad',
11+
title: 'Checkout failure',
1112
session: 'default:test:suite:2',
1213
status: 'failed',
1314
durationMs: 5,
@@ -106,11 +107,14 @@ test('test command prints suite summary and exits non-zero on failures', async (
106107
assert.equal(result.calls[0]?.meta?.requestProgress, 'replay-test');
107108
assert.match(result.stderr, /Running replay suite\.\.\./);
108109
assert.doesNotMatch(result.stdout, /PASS \/tmp\/01-pass\.ad/);
109-
assert.match(result.stdout, /FAIL \/tmp\/02-fail\.ad after 2 attempts \(5ms\)/);
110+
assert.match(
111+
result.stdout,
112+
/FAIL "Checkout failure" in 02-fail\.ad after 2 attempts \(total 0\.005s\)/,
113+
);
110114
assert.match(result.stdout, /Replay failed at step 1 \(open Demo\): boom/);
111115
assert.match(result.stdout, /artifacts: \/tmp\/test-artifacts\/02-fail/);
112116
assert.doesNotMatch(result.stdout, /SKIP \/tmp\/03-skip\.ad/);
113-
assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 25ms/);
117+
assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 0\.025s/);
114118
});
115119

116120
test('test command --verbose prints all test statuses', async () => {
@@ -120,8 +124,8 @@ test('test command --verbose prints all test statuses', async () => {
120124

121125
assert.equal(result.code, 1);
122126
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/);
127+
assert.match(result.stdout, /PASS 01-pass\.ad \(0\.01s\)/);
128+
assert.match(result.stdout, /SKIP 03-skip\.ad/);
125129
});
126130

127131
test('test command reports flaky passed-on-retry cases in the default summary', async () => {
@@ -138,20 +142,38 @@ test('test command reports flaky passed-on-retry cases in the default summary',
138142
failures: [],
139143
tests: [
140144
{
141-
file: '/tmp/01-flaky.ad',
145+
file: '/tmp/auth-flow.yml',
146+
title: 'Authentication flow',
142147
session: 'default:test:suite:1',
143148
status: 'passed',
144-
durationMs: 10,
149+
durationMs: 112151,
150+
finalAttemptDurationMs: 17492,
145151
attempts: 2,
152+
attemptFailures: [
153+
{
154+
attempt: 1,
155+
message: 'Replay failed at step 3 (tapOn "Log in"): selector not found',
156+
durationMs: 94659,
157+
},
158+
],
146159
},
147160
],
148161
},
149162
}));
150163

151164
assert.equal(result.code, null);
152165
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/);
166+
assert.doesNotMatch(result.stdout, /FLAKY/);
167+
assert.match(result.stdout, /Test summary: 1 passed, 0 failed, 1 flaky in 0\.025s/);
168+
assert.match(result.stdout, /Flaky tests:/);
169+
assert.match(
170+
result.stdout,
171+
/PASS "Authentication flow" after 2 attempts \(passed attempt 17\.5s, total 112\.2s\)/,
172+
);
173+
assert.match(
174+
result.stdout,
175+
/attempt 1 failed \(94\.7s\): Replay failed at step 3 \(tapOn "Log in"\): selector not found/,
176+
);
155177
});
156178

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

src/cli-test.ts

Lines changed: 91 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,18 @@ 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(
76+
`FAIL ${replayFailedTestDisplayName(result)}${attemptSuffix}${durationSuffix}\n`,
77+
);
7878
process.stdout.write(` ${result.error?.message ?? 'Unknown test failure'}\n`);
7979
for (const line of replayFailureConsoleLines(result)) {
8080
process.stdout.write(` ${line}\n`);
8181
}
8282
}
8383

8484
function replayResultPrefix(result: ReplaySuiteTestResult): string {
85-
if (result.status === 'passed') return result.attempts > 1 ? 'FLAKY' : 'PASS';
85+
if (result.status === 'passed') return 'PASS';
8686
if (result.status === 'skipped') return 'SKIP';
8787
return 'INFO';
8888
}
@@ -98,17 +98,83 @@ function replayFailureConsoleLines(
9898
].filter(Boolean);
9999
}
100100

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-
106101
function isFlakyReplayTestResult(
107102
result: ReplaySuiteTestResult,
108103
): result is Extract<ReplaySuiteTestResult, { status: 'passed' }> {
109104
return result.status === 'passed' && result.attempts > 1;
110105
}
111106

107+
function renderFlakyTestSummary(
108+
results: Array<Extract<ReplaySuiteTestResult, { status: 'passed' }>>,
109+
): void {
110+
if (results.length === 0) return;
111+
process.stdout.write('Flaky tests:\n');
112+
for (const result of results) {
113+
process.stdout.write(
114+
` PASS ${replayTestDisplayName(result)} after ${result.attempts} attempts${formatFlakyReplayDurationSuffix(result)}\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 = replayTestTitle(result);
130+
if (title && title.length > 0) return JSON.stringify(title);
131+
return path.basename(result.file);
132+
}
133+
134+
function replayFailedTestDisplayName(
135+
result: Extract<ReplaySuiteTestResult, { status: 'failed' }>,
136+
): string {
137+
const title = replayTestTitle(result);
138+
const filename = path.basename(result.file);
139+
return title && title.length > 0 ? `${JSON.stringify(title)} in ${filename}` : filename;
140+
}
141+
142+
function replayTestCaseName(result: ReplaySuiteTestResult): string {
143+
return replayTestTitle(result) ?? path.basename(result.file);
144+
}
145+
146+
function replayTestTitle(result: ReplaySuiteTestResult): string | undefined {
147+
const title = result.title?.trim();
148+
return title && title.length > 0 ? title : undefined;
149+
}
150+
151+
function formatReplayTestDurationSuffix(result: ReplaySuiteTestResult): string {
152+
if (result.status === 'passed' && result.attempts > 1) {
153+
return formatFlakyReplayDurationSuffix(result);
154+
}
155+
if (result.status === 'failed' && result.attempts > 1 && result.durationMs > 0) {
156+
return ` (total ${formatDurationSeconds(result.durationMs)})`;
157+
}
158+
159+
const durationMs =
160+
result.status === 'passed' && typeof result.finalAttemptDurationMs === 'number'
161+
? result.finalAttemptDurationMs
162+
: result.durationMs;
163+
return durationMs > 0 ? ` (${formatDurationSeconds(durationMs)})` : '';
164+
}
165+
166+
function formatFlakyReplayDurationSuffix(
167+
result: Extract<ReplaySuiteTestResult, { status: 'passed' }>,
168+
): string {
169+
const timings = [
170+
typeof result.finalAttemptDurationMs === 'number'
171+
? `passed attempt ${formatDurationSeconds(result.finalAttemptDurationMs)}`
172+
: '',
173+
result.durationMs > 0 ? `total ${formatDurationSeconds(result.durationMs)}` : '',
174+
].filter(Boolean);
175+
return timings.length > 0 ? ` (${timings.join(', ')})` : '';
176+
}
177+
112178
function getReplayTestExitCode(data: ReplaySuiteResult): number {
113179
return data.failed > 0 ? 1 : 0;
114180
}
@@ -144,7 +210,7 @@ function buildReplayJunitXml(suite: ReplaySuiteResult): string {
144210
}
145211

146212
function renderJUnitTestCase(test: ReplaySuiteTestResult): string[] {
147-
const name = xmlEscape(path.basename(test.file));
213+
const name = xmlEscape(replayTestCaseName(test));
148214
const className = xmlEscape(
149215
path.dirname(test.file) === '.' ? test.file : path.dirname(test.file),
150216
);
@@ -239,6 +305,13 @@ function formatJUnitSeconds(durationMs: number): string {
239305
return (Math.max(0, durationMs) / 1000).toFixed(3);
240306
}
241307

308+
function formatDurationSeconds(durationMs: number): string {
309+
const seconds = Math.max(0, durationMs) / 1000;
310+
if (seconds >= 10) return `${seconds.toFixed(1)}s`;
311+
if (seconds >= 1) return `${seconds.toFixed(2)}s`;
312+
return `${seconds.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}s`;
313+
}
314+
242315
function xmlEscape(value: string): string {
243316
return value
244317
.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: Bottom Tabs - Dynamic\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, 'Bottom Tabs - Dynamic');
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
}

0 commit comments

Comments
 (0)