Skip to content

Commit f65c6f9

Browse files
committed
fix: harden maestro replay smoke tests
1 parent 78761d1 commit f65c6f9

20 files changed

Lines changed: 820 additions & 84 deletions

.github/workflows/ios.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,13 @@ jobs:
5454
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
5555
preferred-device-name: iPhone 17 Pro
5656

57-
- name: Run iOS simulator smoke replay
57+
- name: Prepare iOS runner
5858
run: |
5959
pnpm clean:daemon
60+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
61+
62+
- name: Run iOS simulator smoke replay
63+
run: |
6064
node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator/01-settings.ad --retries 2 --report-junit test/artifacts/replays-ios-simulator-smoke.junit.xml
6165
6266
- name: Run iOS physical device smoke replay

.github/workflows/perf-nightly.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ jobs:
6060
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
6161
preferred-device-name: iPhone 17 Pro
6262

63-
- name: Run iOS command perf benchmark
63+
- name: Prepare iOS runner
6464
run: |
6565
pnpm clean:daemon
66+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
67+
68+
- name: Run iOS command perf benchmark
69+
run: |
6670
node --experimental-strip-types scripts/perf/run.ts \
6771
--platform ios \
6872
--device "iPhone 17 Pro" \

.github/workflows/replays-nightly.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ jobs:
7777
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
7878
preferred-device-name: iPhone 17 Pro
7979

80+
- name: Prepare iOS runner
81+
run: |
82+
pnpm clean:daemon
83+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
84+
8085
- name: Run iOS simulator replay suite
8186
run: node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator --retries 2 --report-junit test/artifacts/replays-ios-simulator.junit.xml
8287

src/__tests__/cli-network.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,114 @@ test('test command --verbose prints step telemetry for passing tests without deb
212212
}
213213
});
214214

215+
test('test command --verbose keeps nested retry and open step telemetry distinct', async () => {
216+
const tmpDir = await fs.mkdtemp(
217+
path.join(os.tmpdir(), 'agent-device-cli-test-verbose-retry-'),
218+
);
219+
const artifactsDir = path.join(tmpDir, 'material-top-tabs');
220+
const attemptDir = path.join(artifactsDir, 'attempt-1');
221+
await fs.mkdir(attemptDir, { recursive: true });
222+
await fs.writeFile(
223+
path.join(attemptDir, 'replay-timing.ndjson'),
224+
[
225+
{
226+
type: 'replay_action_start',
227+
step: 2,
228+
line: 4,
229+
command: 'retry',
230+
positionals: ['3'],
231+
},
232+
{
233+
type: 'replay_action_start',
234+
step: 2,
235+
line: 4,
236+
command: 'open',
237+
positionals: ['org.reactnavigation.playground', 'rne://material-top-tabs-basic'],
238+
},
239+
{
240+
type: 'replay_action_stop',
241+
step: 2,
242+
line: 4,
243+
command: 'open',
244+
ok: true,
245+
durationMs: 727,
246+
},
247+
{
248+
type: 'replay_action_start',
249+
step: 2.001,
250+
line: 4,
251+
command: '__maestroAssertVisible',
252+
positionals: ['label="Chat" || text="Chat" || id="Chat"', '60000'],
253+
},
254+
{
255+
type: 'replay_action_stop',
256+
step: 2.001,
257+
line: 4,
258+
command: '__maestroAssertVisible',
259+
ok: true,
260+
durationMs: 2580,
261+
},
262+
{
263+
type: 'replay_action_stop',
264+
step: 2,
265+
line: 4,
266+
command: 'retry',
267+
ok: true,
268+
durationMs: 3310,
269+
},
270+
]
271+
.map((entry) => JSON.stringify(entry))
272+
.join('\n'),
273+
);
274+
275+
try {
276+
const result = await runCliCapture(['test', './suite', '--verbose'], async () => ({
277+
ok: true,
278+
data: {
279+
total: 1,
280+
executed: 1,
281+
passed: 1,
282+
failed: 0,
283+
skipped: 0,
284+
notRun: 0,
285+
durationMs: 3310,
286+
failures: [],
287+
tests: [
288+
{
289+
file: '/tmp/material-top-tabs.yml',
290+
title: 'Material Top Tabs - Basic',
291+
session: 'default:test:suite:1',
292+
status: 'passed',
293+
durationMs: 3310,
294+
finalAttemptDurationMs: 3310,
295+
attempts: 1,
296+
artifactsDir,
297+
replayed: 1,
298+
healed: 0,
299+
},
300+
],
301+
},
302+
}));
303+
304+
assert.equal(result.code, null);
305+
assert.match(
306+
result.stdout,
307+
/open "org\.reactnavigation\.playground" "rne:\/\/material-top-tabs-basic" \(line 4, 0\.727s\)/,
308+
);
309+
assert.match(
310+
result.stdout,
311+
/assertVisible "label=\\"Chat\\" \|\| text=\\"Chat\\" \|\| id=\\"Chat\\"" "60000" \(line 4, 2\.58s\)/,
312+
);
313+
assert.match(result.stdout, /retry "3" \(line 4, 3\.31s\)/);
314+
assert.doesNotMatch(
315+
result.stdout,
316+
/open "org\.reactnavigation\.playground" "rne:\/\/material-top-tabs-basic" \(line 4, 3\.31s\)/,
317+
);
318+
} finally {
319+
await fs.rm(tmpDir, { recursive: true, force: true });
320+
}
321+
});
322+
215323
test('test command reports flaky passed-on-retry cases in the default summary', async () => {
216324
const result = await runCliCapture(['test', './suite'], async () => ({
217325
ok: true,

src/cli-test.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33
import type { ReplaySuiteResult, ReplaySuiteTestResult } from './daemon/types.ts';
4+
import { formatDurationSeconds } from './utils/duration-format.ts';
45
import { AppError } from './utils/errors.ts';
56
import { printJson } from './utils/output.ts';
67

@@ -142,20 +143,40 @@ function replayTestStepLines(result: ReplaySuiteTestResult): string[] {
142143
const events = readReplayTimingTrace(tracePath);
143144
if (events.length === 0) return [];
144145

145-
const starts = new Map<number, ReplayActionStartTrace>();
146-
const stops: ReplayActionStopTrace[] = [];
146+
const starts: ReplayActionStartTrace[] = [];
147+
const stops: Array<{ stop: ReplayActionStopTrace; start: ReplayActionStartTrace | undefined }> =
148+
[];
147149
for (const event of events) {
148-
if (isReplayActionStartTrace(event)) starts.set(event.step, event);
149-
if (isReplayActionStopTrace(event)) stops.push(event);
150+
if (isReplayActionStartTrace(event)) {
151+
starts.push(event);
152+
continue;
153+
}
154+
if (isReplayActionStopTrace(event)) {
155+
stops.push({ stop: event, start: consumeReplayActionStart(starts, event) });
156+
}
150157
}
151158
if (stops.length === 0) return [];
152159

153160
return [
154161
result.attempts > 1 ? `steps (attempt ${result.attempts}):` : 'steps:',
155-
...stops.map((stop) => renderReplayStepTrace(stop, starts.get(stop.step))),
162+
...stops.map(({ stop, start }) => renderReplayStepTrace(stop, start)),
156163
];
157164
}
158165

166+
function consumeReplayActionStart(
167+
starts: ReplayActionStartTrace[],
168+
stop: ReplayActionStopTrace,
169+
): ReplayActionStartTrace | undefined {
170+
const stopCommand = stop.command;
171+
const matchingIndex = starts.findIndex(
172+
(start) =>
173+
start.step === stop.step &&
174+
(stopCommand === undefined || start.command === undefined || start.command === stopCommand),
175+
);
176+
if (matchingIndex < 0) return undefined;
177+
return starts.splice(matchingIndex, 1)[0];
178+
}
179+
159180
function replayTestTimingTracePath(
160181
result: Extract<ReplaySuiteTestResult, { status: 'passed' | 'failed' }>,
161182
): string | undefined {
@@ -474,13 +495,6 @@ function formatJUnitSeconds(durationMs: number): string {
474495
return (Math.max(0, durationMs) / 1000).toFixed(3);
475496
}
476497

477-
function formatDurationSeconds(durationMs: number): string {
478-
const seconds = Math.max(0, durationMs) / 1000;
479-
if (seconds >= 10) return `${seconds.toFixed(1)}s`;
480-
if (seconds >= 1) return `${seconds.toFixed(2)}s`;
481-
return `${seconds.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}s`;
482-
}
483-
484498
function xmlEscape(value: string): string {
485499
return value
486500
.replaceAll('&', '&amp;')

src/commands/interaction-targeting.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { centerOfRect } from '../utils/snapshot.ts';
33
import { containsPoint, pickLargestRect } from '../utils/rect-visibility.ts';
44
import { findNearestHittableAncestor, normalizeType } from '../utils/snapshot-processing.ts';
55
import { normalizeRect, resolveRectCenter } from '../utils/rect-center.ts';
6+
import { intersectArea } from '../utils/screenshot-geometry.ts';
67

78
const SEMANTIC_TOUCH_ROLE_FRAGMENTS = [
89
'button',
@@ -142,7 +143,7 @@ function resolveRootViewportRect(nodes: SnapshotNode[], targetRect: Rect): Rect
142143
}
143144

144145
function isRectViewportSized(rect: Rect, viewportRect: Rect): boolean {
145-
const overlapArea = intersectionArea(rect, viewportRect);
146+
const overlapArea = intersectArea(rect, viewportRect);
146147
const rectArea = rect.width * rect.height;
147148
const viewportArea = viewportRect.width * viewportRect.height;
148149
if (overlapArea <= 0 || rectArea <= 0 || viewportArea <= 0) return false;
@@ -151,15 +152,3 @@ function isRectViewportSized(rect: Rect, viewportRect: Rect): boolean {
151152
const rectCoverage = overlapArea / rectArea;
152153
return viewportCoverage >= 0.9 && rectCoverage >= 0.8;
153154
}
154-
155-
function intersectionArea(left: Rect, right: Rect): number {
156-
const xOverlap = Math.max(
157-
0,
158-
Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x),
159-
);
160-
const yOverlap = Math.max(
161-
0,
162-
Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y),
163-
);
164-
return xOverlap * yOverlap;
165-
}

src/compat/maestro/__tests__/runtime-assertions.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,85 @@ test('invokeMaestroAssertVisible uses snapshot resolution for short iOS assertio
151151
assert.deepEqual(calls, [['snapshot', []]]);
152152
});
153153

154+
test('invokeMaestroAssertVisible falls back to raw snapshot shaping when optimized snapshot misses', async () => {
155+
const snapshotFlags: Array<DaemonRequest['flags']> = [];
156+
const response = await invokeMaestroAssertVisible({
157+
baseReq: {
158+
token: 't',
159+
session: 's',
160+
flags: { platform: 'ios' },
161+
},
162+
positionals: ['id="chat"', '1000'],
163+
invoke: async (req): Promise<DaemonResponse> => {
164+
if (req.command === 'snapshot') {
165+
snapshotFlags.push(req.flags);
166+
return {
167+
ok: true,
168+
data:
169+
req.flags?.snapshotRaw === true
170+
? snapshot([node('Chat', { identifier: 'chat' })])
171+
: snapshot([]),
172+
};
173+
}
174+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
175+
},
176+
});
177+
178+
assert.equal(response.ok, true);
179+
assert.equal(snapshotFlags.length, 2);
180+
assert.equal(snapshotFlags[0]?.snapshotRaw, undefined);
181+
assert.equal(snapshotFlags[1]?.snapshotRaw, true);
182+
assert.equal(snapshotFlags[1]?.snapshotForceFull, undefined);
183+
});
184+
185+
test('invokeMaestroAssertVisible does not use raw fallback for Android identifiers', async () => {
186+
const snapshotFlags: Array<DaemonRequest['flags']> = [];
187+
const response = await invokeMaestroAssertVisible({
188+
baseReq: {
189+
token: 't',
190+
session: 's',
191+
flags: { platform: 'android' },
192+
},
193+
positionals: ['id="album-0"', '1000'],
194+
invoke: async (req): Promise<DaemonResponse> => {
195+
if (req.command === 'snapshot') {
196+
snapshotFlags.push(req.flags);
197+
return {
198+
ok: true,
199+
data: snapshot([node('Album item', { identifier: 'album-0' })]),
200+
};
201+
}
202+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
203+
},
204+
});
205+
206+
assert.equal(response.ok, true);
207+
assert.equal(snapshotFlags.length, 1);
208+
assert.equal(snapshotFlags[0]?.snapshotRaw, undefined);
209+
});
210+
211+
test('invokeMaestroAssertVisible does not use Android raw fallback for generated text selectors', async () => {
212+
const snapshotFlags: Array<DaemonRequest['flags']> = [];
213+
const response = await invokeMaestroAssertVisible({
214+
baseReq: {
215+
token: 't',
216+
session: 's',
217+
flags: { platform: 'android' },
218+
},
219+
positionals: ['label="Chat" || text="Chat" || id="Chat"', '0'],
220+
invoke: async (req): Promise<DaemonResponse> => {
221+
if (req.command === 'snapshot') {
222+
snapshotFlags.push(req.flags);
223+
return { ok: true, data: snapshot([]) };
224+
}
225+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
226+
},
227+
});
228+
229+
assert.equal(response.ok, false);
230+
assert.equal(snapshotFlags.some((flags) => flags?.snapshotRaw === true), false);
231+
});
232+
154233
test('invokeMaestroAssertVisible treats an elapsed ellipsis loading gate as already past loading', async () => {
155234
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(250);
156235

src/compat/maestro/__tests__/runtime-geometry.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,31 @@ test('pointForMaestroTapOnTarget biases large scroll-area text containers toward
1515
frame: { referenceWidth: 402, referenceHeight: 874 },
1616
},
1717
true,
18+
{ allowLargeContainerBias: true },
1819
);
1920

2021
expect(point).toEqual({ x: 84, y: 141 });
2122
});
2223

24+
test('pointForMaestroTapOnTarget centers optimized broad text containers by default', () => {
25+
const point = pointForMaestroTapOnTarget(
26+
{
27+
node: {
28+
index: 5,
29+
ref: 'e5',
30+
type: 'scroll-area',
31+
label: 'Article',
32+
rect: { x: 0, y: 117, width: 402, height: 180 },
33+
},
34+
rect: { x: 0, y: 117, width: 402, height: 180 },
35+
frame: { referenceWidth: 402, referenceHeight: 874 },
36+
},
37+
true,
38+
);
39+
40+
expect(point).toEqual({ x: 201, y: 207 });
41+
});
42+
2343
test('pointForMaestroTapOnTarget centers tall Android bottom-tab containers', () => {
2444
const point = pointForMaestroTapOnTarget(
2545
{

0 commit comments

Comments
 (0)