Skip to content

Commit 3412898

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

33 files changed

Lines changed: 1373 additions & 319 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: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,43 @@ 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+
54+
private func performDragSeries(
55+
count: Int,
56+
pauseMs: Double,
57+
pattern: String,
58+
points: DragPoints,
59+
_ drag: (_ x: Double, _ y: Double, _ x2: Double, _ y2: Double) -> RunnerInteractionOutcome
60+
) -> RunnerInteractionOutcome {
61+
var outcome = RunnerInteractionOutcome.performed
62+
runSeries(count: count, pauseMs: pauseMs) { idx in
63+
guard case .performed = outcome else {
64+
return
65+
}
66+
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
67+
let startX = reverse ? points.x2 : points.x
68+
let startY = reverse ? points.y2 : points.y
69+
let endX = reverse ? points.x : points.x2
70+
let endY = reverse ? points.y : points.y2
71+
outcome = drag(startX, startY, endX, endY)
72+
}
73+
return outcome
74+
}
75+
3976
/// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
4077
/// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
4178
/// gestures (pinch/rotate/transform) pass `false` because RunnerSynthesizedGesture governs its
@@ -64,15 +101,19 @@ extension RunnerTests {
64101
private func gestureResponse(
65102
message: String,
66103
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
67-
frame: GestureFrame = .none
104+
frame: GestureFrame = .none,
105+
fallback: GestureFallback? = nil
68106
) -> Response {
69107
let data: DataPayload
70108
switch frame {
71109
case .none:
72110
data = DataPayload(
73111
message: message,
74112
gestureStartUptimeMs: timing.gestureStartUptimeMs,
75-
gestureEndUptimeMs: timing.gestureEndUptimeMs
113+
gestureEndUptimeMs: timing.gestureEndUptimeMs,
114+
gestureFallback: fallback?.strategy,
115+
gestureFallbackMessage: fallback?.message,
116+
gestureFallbackHint: fallback?.hint
76117
)
77118
case .touch(let f):
78119
data = DataPayload(
@@ -82,7 +123,10 @@ extension RunnerTests {
82123
x: f?.x,
83124
y: f?.y,
84125
referenceWidth: f?.referenceWidth,
85-
referenceHeight: f?.referenceHeight
126+
referenceHeight: f?.referenceHeight,
127+
gestureFallback: fallback?.strategy,
128+
gestureFallbackMessage: fallback?.message,
129+
gestureFallbackHint: fallback?.hint
86130
)
87131
case .drag(let f):
88132
data = DataPayload(
@@ -94,7 +138,10 @@ extension RunnerTests {
94138
x2: f.x2,
95139
y2: f.y2,
96140
referenceWidth: f.referenceWidth,
97-
referenceHeight: f.referenceHeight
141+
referenceHeight: f.referenceHeight,
142+
gestureFallback: fallback?.strategy,
143+
gestureFallbackMessage: fallback?.message,
144+
gestureFallbackHint: fallback?.hint
98145
)
99146
}
100147
return Response(ok: true, data: data)
@@ -507,6 +554,7 @@ extension RunnerTests {
507554
x2: dragPoints.x2,
508555
y2: dragPoints.y2
509556
)
557+
var fallback: GestureFallback?
510558
if command.synthesized == true {
511559
let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
512560
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
@@ -522,6 +570,7 @@ extension RunnerTests {
522570
if case .performed = outcome {
523571
return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
524572
}
573+
fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
525574
}
526575
let holdDuration = command.synthesized == true
527576
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
@@ -539,7 +588,12 @@ extension RunnerTests {
539588
if let response = unsupportedResponse(for: outcome) {
540589
return response
541590
}
542-
return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
591+
return gestureResponse(
592+
message: "dragged",
593+
timing: timing,
594+
frame: .drag(dragFrame),
595+
fallback: fallback
596+
)
543597
case .dragSeries:
544598
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
545599
return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
@@ -550,41 +604,56 @@ extension RunnerTests {
550604
if pattern != "one-way" && pattern != "ping-pong" {
551605
return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
552606
}
553-
let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
554607
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
555-
let (timing, outcome) = performGesture(activeApp) {
556-
var outcome = RunnerInteractionOutcome.performed
557-
runSeries(count: count, pauseMs: pauseMs) { idx in
558-
guard case .performed = outcome else {
559-
return
560-
}
561-
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
562-
if reverse {
563-
outcome = dragAt(
564-
app: activeApp,
565-
x: dragPoints.x2,
566-
y: dragPoints.y2,
567-
x2: dragPoints.x,
568-
y2: dragPoints.y,
569-
holdDuration: holdDuration
570-
)
571-
} else {
572-
outcome = dragAt(
608+
var fallback: GestureFallback?
609+
if command.synthesized == true {
610+
let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
611+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
612+
performDragSeries(
613+
count: count,
614+
pauseMs: pauseMs,
615+
pattern: pattern,
616+
points: dragPoints
617+
) { startX, startY, endX, endY in
618+
synthesizedDragAt(
573619
app: activeApp,
574-
x: dragPoints.x,
575-
y: dragPoints.y,
576-
x2: dragPoints.x2,
577-
y2: dragPoints.y2,
578-
holdDuration: holdDuration
620+
x: startX,
621+
y: startY,
622+
x2: endX,
623+
y2: endY,
624+
durationMs: durationMs
579625
)
580626
}
581627
}
582-
return outcome
628+
if case .performed = outcome {
629+
return gestureResponse(message: "drag series", timing: timing)
630+
}
631+
fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome)
632+
}
633+
let holdDuration = command.synthesized == true
634+
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
635+
: min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
636+
let (timing, outcome) = performGesture(activeApp) {
637+
performDragSeries(
638+
count: count,
639+
pauseMs: pauseMs,
640+
pattern: pattern,
641+
points: dragPoints
642+
) { startX, startY, endX, endY in
643+
dragAt(
644+
app: activeApp,
645+
x: startX,
646+
y: startY,
647+
x2: endX,
648+
y2: endY,
649+
holdDuration: holdDuration
650+
)
651+
}
583652
}
584653
if let response = unsupportedResponse(for: outcome) {
585654
return response
586655
}
587-
return gestureResponse(message: "drag series", timing: timing)
656+
return gestureResponse(message: "drag series", timing: timing, fallback: fallback)
588657
case .remotePress:
589658
guard let button = tvRemoteButton(from: command.remoteButton) else {
590659
return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))

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,

0 commit comments

Comments
 (0)