Skip to content

Commit 402f5f0

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

24 files changed

Lines changed: 931 additions & 91 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

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ extension RunnerTests {
3636
case drag(DragVisualizationFrame)
3737
}
3838

39+
struct GestureFallback {
40+
let strategy: String
41+
let message: String
42+
let hint: String?
43+
}
44+
45+
private func gestureFallback(strategy: String, from outcome: RunnerInteractionOutcome) -> GestureFallback? {
46+
switch outcome {
47+
case .performed:
48+
return nil
49+
case .unsupported(let message, let hint):
50+
return GestureFallback(strategy: strategy, message: message, hint: hint)
51+
}
52+
}
53+
3954
/// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
4055
/// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
4156
/// gestures (pinch/rotate/transform) pass `false` because RunnerSynthesizedGesture governs its
@@ -64,15 +79,19 @@ extension RunnerTests {
6479
private func gestureResponse(
6580
message: String,
6681
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
67-
frame: GestureFrame = .none
82+
frame: GestureFrame = .none,
83+
fallback: GestureFallback? = nil
6884
) -> Response {
6985
let data: DataPayload
7086
switch frame {
7187
case .none:
7288
data = DataPayload(
7389
message: message,
7490
gestureStartUptimeMs: timing.gestureStartUptimeMs,
75-
gestureEndUptimeMs: timing.gestureEndUptimeMs
91+
gestureEndUptimeMs: timing.gestureEndUptimeMs,
92+
gestureFallback: fallback?.strategy,
93+
gestureFallbackMessage: fallback?.message,
94+
gestureFallbackHint: fallback?.hint
7695
)
7796
case .touch(let f):
7897
data = DataPayload(
@@ -82,7 +101,10 @@ extension RunnerTests {
82101
x: f?.x,
83102
y: f?.y,
84103
referenceWidth: f?.referenceWidth,
85-
referenceHeight: f?.referenceHeight
104+
referenceHeight: f?.referenceHeight,
105+
gestureFallback: fallback?.strategy,
106+
gestureFallbackMessage: fallback?.message,
107+
gestureFallbackHint: fallback?.hint
86108
)
87109
case .drag(let f):
88110
data = DataPayload(
@@ -94,7 +116,10 @@ extension RunnerTests {
94116
x2: f.x2,
95117
y2: f.y2,
96118
referenceWidth: f.referenceWidth,
97-
referenceHeight: f.referenceHeight
119+
referenceHeight: f.referenceHeight,
120+
gestureFallback: fallback?.strategy,
121+
gestureFallbackMessage: fallback?.message,
122+
gestureFallbackHint: fallback?.hint
98123
)
99124
}
100125
return Response(ok: true, data: data)
@@ -507,6 +532,7 @@ extension RunnerTests {
507532
x2: dragPoints.x2,
508533
y2: dragPoints.y2
509534
)
535+
var fallback: GestureFallback?
510536
if command.synthesized == true {
511537
let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
512538
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
@@ -522,6 +548,7 @@ extension RunnerTests {
522548
if case .performed = outcome {
523549
return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
524550
}
551+
fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
525552
}
526553
let holdDuration = command.synthesized == true
527554
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
@@ -539,7 +566,12 @@ extension RunnerTests {
539566
if let response = unsupportedResponse(for: outcome) {
540567
return response
541568
}
542-
return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
569+
return gestureResponse(
570+
message: "dragged",
571+
timing: timing,
572+
frame: .drag(dragFrame),
573+
fallback: fallback
574+
)
543575
case .dragSeries:
544576
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
545577
return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ struct DataPayload: Codable {
190190
let wasVisible: Bool?
191191
let dismissed: Bool?
192192
let orientation: String?
193+
let gestureFallback: String?
194+
let gestureFallbackMessage: String?
195+
let gestureFallbackHint: String?
193196

194197
init(
195198
message: String? = nil,
@@ -218,7 +221,10 @@ struct DataPayload: Codable {
218221
visible: Bool? = nil,
219222
wasVisible: Bool? = nil,
220223
dismissed: Bool? = nil,
221-
orientation: String? = nil
224+
orientation: String? = nil,
225+
gestureFallback: String? = nil,
226+
gestureFallbackMessage: String? = nil,
227+
gestureFallbackHint: String? = nil
222228
) {
223229
self.message = message
224230
self.text = text
@@ -247,6 +253,9 @@ struct DataPayload: Codable {
247253
self.wasVisible = wasVisible
248254
self.dismissed = dismissed
249255
self.orientation = orientation
256+
self.gestureFallback = gestureFallback
257+
self.gestureFallbackMessage = gestureFallbackMessage
258+
self.gestureFallbackHint = gestureFallbackHint
250259
}
251260
}
252261

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-
}

0 commit comments

Comments
 (0)