Skip to content

Commit ee57e1b

Browse files
authored
fix: improve Maestro test suite replay (#601)
1 parent 136d313 commit ee57e1b

15 files changed

Lines changed: 741 additions & 71 deletions

src/__tests__/cli-network.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,44 @@ test('test command reports flaky passed-on-retry cases in the default summary',
153153
assert.match(result.stdout, /Test summary: 1 passed, 0 failed, 1 flaky in 25ms/);
154154
});
155155

156+
test('test --maestro forwards Maestro backend and platform for directory suites', async () => {
157+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-cli-maestro-suite-'));
158+
await fs.writeFile(
159+
path.join(tmpDir, 'auth-flow.yml'),
160+
['appId: demo.app', '---', '- launchApp', ''].join('\n'),
161+
);
162+
163+
try {
164+
const result = await runCliCapture(
165+
['test', '--maestro', '--platform', 'android', tmpDir],
166+
async () => ({
167+
ok: true,
168+
data: {
169+
total: 1,
170+
executed: 1,
171+
passed: 1,
172+
failed: 0,
173+
skipped: 0,
174+
notRun: 0,
175+
durationMs: 5,
176+
failures: [],
177+
tests: [],
178+
},
179+
}),
180+
);
181+
182+
assert.equal(result.code, null);
183+
assert.equal(result.calls.length, 1);
184+
assert.equal(result.calls[0]?.command, 'test');
185+
assert.deepEqual(result.calls[0]?.positionals, [tmpDir]);
186+
assert.equal(result.calls[0]?.flags?.replayBackend, 'maestro');
187+
assert.equal(result.calls[0]?.flags?.platform, 'android');
188+
assert.match(result.stderr, /Running replay suite\.\.\./);
189+
} finally {
190+
await fs.rm(tmpDir, { recursive: true, force: true });
191+
}
192+
});
193+
156194
test('test command writes JUnit report with failure metadata', async () => {
157195
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-junit-test-'));
158196
const reportPath = path.join(tmpDir, 'replays.junit.xml');
@@ -179,10 +217,12 @@ test('test command writes JUnit report with failure metadata', async () => {
179217
attempts: 2,
180218
artifactsDir: '/tmp/test-artifacts/02-fail',
181219
error: {
220+
code: 'COMMAND_FAILED',
182221
message: 'Replay failed at step 1 (open Demo): boom',
183222
hint: 'retry me',
184223
diagnosticId: 'diag-123',
185224
logPath: '/tmp/diag.ndjson',
225+
details: { command: 'open', reason: 'selector_not_found' },
186226
},
187227
},
188228
],
@@ -204,10 +244,12 @@ test('test command writes JUnit report with failure metadata', async () => {
204244
attempts: 2,
205245
artifactsDir: '/tmp/test-artifacts/02-fail',
206246
error: {
247+
code: 'COMMAND_FAILED',
207248
message: 'Replay failed at step 1 (open Demo): boom',
208249
hint: 'retry me',
209250
diagnosticId: 'diag-123',
210251
logPath: '/tmp/diag.ndjson',
252+
details: { command: 'open', reason: 'selector_not_found' },
211253
},
212254
},
213255
{
@@ -236,6 +278,12 @@ test('test command writes JUnit report with failure metadata', async () => {
236278
assert.match(xml, /diagnosticId: diag-123/);
237279
assert.match(xml, /logPath: \/tmp\/diag\.ndjson/);
238280
assert.match(xml, /artifactsDir: \/tmp\/test-artifacts\/02-fail/);
281+
assert.match(xml, /errorCode: COMMAND_FAILED/);
282+
assert.match(xml, /errorMessage: Replay failed at step 1 \(open Demo\): boom/);
283+
assert.match(
284+
xml,
285+
/details: \{"command":"open","reason":"selector_not_found"\}/,
286+
);
239287
assert.match(xml, /flaky: true/);
240288
assert.match(xml, /<skipped message="not runnable" \/>/);
241289
} finally {

src/__tests__/client.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,45 @@ test('replay.test keeps backend alias for suite discovery', async () => {
418418
assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro');
419419
});
420420

421+
test('structured replay.test command forwards Maestro backend for suite discovery', async () => {
422+
const setup = createTransport(async () => ({ ok: true, data: {} }));
423+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
424+
425+
await runCommand(client, 'test', {
426+
paths: ['./e2e/maestro'],
427+
backend: 'maestro',
428+
platform: 'android',
429+
});
430+
431+
assert.equal(setup.calls.length, 1);
432+
assert.equal(setup.calls[0]?.command, 'test');
433+
assert.deepEqual(setup.calls[0]?.positionals, ['./e2e/maestro']);
434+
assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro');
435+
assert.equal(setup.calls[0]?.flags?.platform, 'android');
436+
});
437+
438+
test('structured replay commands keep deprecated Maestro boolean alias', async () => {
439+
const setup = createTransport(async () => ({ ok: true, data: {} }));
440+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
441+
442+
await runCommand(client, 'replay', {
443+
path: './flows/login.yaml',
444+
maestro: true,
445+
});
446+
await runCommand(client, 'test', {
447+
paths: ['./e2e/maestro'],
448+
maestro: true,
449+
platform: 'android',
450+
});
451+
452+
assert.equal(setup.calls.length, 2);
453+
assert.equal(setup.calls[0]?.command, 'replay');
454+
assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro');
455+
assert.equal(setup.calls[1]?.command, 'test');
456+
assert.equal(setup.calls[1]?.flags?.replayBackend, 'maestro');
457+
assert.equal(setup.calls[1]?.flags?.platform, 'android');
458+
});
459+
421460
test('client.command.wait prepares selector options and rejects invalid selectors', async () => {
422461
const setup = createTransport(async () => ({
423462
ok: true,

src/cli-test.ts

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,7 @@ function renderVerboseTestResult(result: ReplaySuiteTestResult): void {
5959
return;
6060
}
6161

62-
const prefix =
63-
result.status === 'passed'
64-
? isFlakyReplayTestResult(result)
65-
? 'FLAKY'
66-
: 'PASS'
67-
: result.status === 'skipped'
68-
? 'SKIP'
69-
: 'INFO';
62+
const prefix = replayResultPrefix(result);
7063
const attemptSuffix =
7164
'attempts' in result && result.attempts > 1 ? ` after ${result.attempts} attempts` : '';
7265
const durationSuffix = result.durationMs > 0 ? ` (${result.durationMs}ms)` : '';
@@ -83,14 +76,28 @@ function renderFailedTestResult(
8376
const durationSuffix = result.durationMs > 0 ? ` (${result.durationMs}ms)` : '';
8477
process.stdout.write(`FAIL ${result.file}${attemptSuffix}${durationSuffix}\n`);
8578
process.stdout.write(` ${result.error?.message ?? 'Unknown test failure'}\n`);
86-
if (result.error?.hint) process.stdout.write(` hint: ${result.error.hint}\n`);
87-
if (result.artifactsDir) process.stdout.write(` artifacts: ${result.artifactsDir}\n`);
88-
if (result.error?.logPath) process.stdout.write(` log: ${result.error.logPath}\n`);
89-
if (result.error?.diagnosticId) {
90-
process.stdout.write(` diagnostic: ${result.error.diagnosticId}\n`);
79+
for (const line of replayFailureConsoleLines(result)) {
80+
process.stdout.write(` ${line}\n`);
9181
}
9282
}
9383

84+
function replayResultPrefix(result: ReplaySuiteTestResult): string {
85+
if (result.status === 'passed') return result.attempts > 1 ? 'FLAKY' : 'PASS';
86+
if (result.status === 'skipped') return 'SKIP';
87+
return 'INFO';
88+
}
89+
90+
function replayFailureConsoleLines(
91+
result: Extract<ReplaySuiteTestResult, { status: 'failed' }>,
92+
): string[] {
93+
return [
94+
result.error?.hint ? `hint: ${result.error.hint}` : '',
95+
result.artifactsDir ? `artifacts: ${result.artifactsDir}` : '',
96+
result.error?.logPath ? `log: ${result.error.logPath}` : '',
97+
result.error?.diagnosticId ? `diagnostic: ${result.error.diagnosticId}` : '',
98+
].filter(Boolean);
99+
}
100+
94101
function renderFlakyTestResult(result: Extract<ReplaySuiteTestResult, { status: 'passed' }>): void {
95102
const durationSuffix = result.durationMs > 0 ? ` (${result.durationMs}ms)` : '';
96103
process.stdout.write(`FLAKY ${result.file} after ${result.attempts} attempts${durationSuffix}\n`);
@@ -166,26 +173,68 @@ function renderJUnitTestCase(test: ReplaySuiteTestResult): string[] {
166173

167174
function buildFailureDetails(test: Extract<ReplaySuiteTestResult, { status: 'failed' }>): string {
168175
const lines = [test.error.message];
169-
if (test.error.hint) lines.push(`hint: ${test.error.hint}`);
170-
if (test.error.diagnosticId) lines.push(`diagnosticId: ${test.error.diagnosticId}`);
171-
if (test.error.logPath) lines.push(`logPath: ${test.error.logPath}`);
176+
appendReplayErrorMetadata(lines, test.error, { includeDetails: false });
172177
if (test.artifactsDir) lines.push(`artifactsDir: ${test.artifactsDir}`);
173-
const details = test.error.details ? JSON.stringify(test.error.details, null, 2) : undefined;
174-
if (details) lines.push(`details: ${details}`);
178+
appendReplayErrorDetails(lines, test.error, 2);
175179
return lines.join('\n');
176180
}
177181

178182
function buildSystemOut(test: ReplaySuiteTestResult): string {
179183
const lines = [`status: ${test.status}`, `durationMs: ${test.durationMs}`];
180-
if ('attempts' in test) lines.push(`attempts: ${test.attempts}`);
181-
if ('session' in test) lines.push(`session: ${test.session}`);
182-
if ('replayed' in test) lines.push(`replayed: ${test.replayed}`);
183-
if ('healed' in test) lines.push(`healed: ${test.healed}`);
184-
if ('artifactsDir' in test && test.artifactsDir) lines.push(`artifactsDir: ${test.artifactsDir}`);
185-
if (test.status === 'passed' && test.attempts > 1) lines.push('flaky: true');
184+
appendReplaySystemOutMetadata(lines, test);
186185
return lines.join('\n');
187186
}
188187

188+
function appendReplaySystemOutMetadata(lines: string[], test: ReplaySuiteTestResult): void {
189+
appendOptionalLine(lines, 'attempts' in test ? `attempts: ${test.attempts}` : undefined);
190+
appendOptionalLine(lines, 'session' in test ? `session: ${test.session}` : undefined);
191+
appendOptionalLine(lines, 'replayed' in test ? `replayed: ${test.replayed}` : undefined);
192+
appendOptionalLine(lines, 'healed' in test ? `healed: ${test.healed}` : undefined);
193+
appendOptionalLine(
194+
lines,
195+
'artifactsDir' in test && test.artifactsDir ? `artifactsDir: ${test.artifactsDir}` : undefined,
196+
);
197+
if (test.status === 'failed') {
198+
appendReplayFailureSystemOut(lines, test);
199+
}
200+
appendOptionalLine(lines, isFlakyReplayTestResult(test) ? 'flaky: true' : undefined);
201+
}
202+
203+
function appendReplayFailureSystemOut(
204+
lines: string[],
205+
test: Extract<ReplaySuiteTestResult, { status: 'failed' }>,
206+
): void {
207+
lines.push(`errorCode: ${test.error.code}`);
208+
appendReplayErrorMetadata(lines, test.error, { includeMessage: true });
209+
}
210+
211+
function appendReplayErrorMetadata(
212+
lines: string[],
213+
error: Extract<ReplaySuiteTestResult, { status: 'failed' }>['error'],
214+
options: { includeMessage?: boolean; includeDetails?: boolean; detailsIndent?: number } = {},
215+
): void {
216+
if (options.includeMessage) lines.push(`errorMessage: ${error.message}`);
217+
if (error.hint) lines.push(`hint: ${error.hint}`);
218+
if (error.diagnosticId) lines.push(`diagnosticId: ${error.diagnosticId}`);
219+
if (error.logPath) lines.push(`logPath: ${error.logPath}`);
220+
if (options.includeDetails !== false) {
221+
appendReplayErrorDetails(lines, error, options.detailsIndent);
222+
}
223+
}
224+
225+
function appendReplayErrorDetails(
226+
lines: string[],
227+
error: Extract<ReplaySuiteTestResult, { status: 'failed' }>['error'],
228+
detailsIndent?: number,
229+
): void {
230+
const details = error.details ? JSON.stringify(error.details, null, detailsIndent) : undefined;
231+
if (details) lines.push(`details: ${details}`);
232+
}
233+
234+
function appendOptionalLine(lines: string[], line: string | undefined): void {
235+
if (line) lines.push(line);
236+
}
237+
189238
function formatJUnitSeconds(durationMs: number): string {
190239
return (Math.max(0, durationMs) / 1000).toFixed(3);
191240
}

src/commands/client-command-contracts.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,16 +255,19 @@ export const clientCommandDefinitions = [
255255
path: requiredField(stringField()),
256256
update: booleanField(),
257257
backend: stringField(),
258+
maestro: booleanField(),
258259
env: stringArrayField(),
259260
},
260261
(client, input) => client.replay.run(input),
261262
),
262263
defineFieldCommand(
263264
'test',
264-
'Run one or more .ad scripts.',
265+
'Run one or more replay scripts.',
265266
{
266267
paths: requiredField(stringArrayField()),
267268
update: booleanField(),
269+
backend: stringField(),
270+
maestro: booleanField(),
268271
env: stringArrayField(),
269272
failFast: booleanField(),
270273
timeoutMs: integerField(),

0 commit comments

Comments
 (0)