Skip to content

Commit 13e855d

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

29 files changed

Lines changed: 1182 additions & 129 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: 78 additions & 7 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"))
@@ -550,8 +582,47 @@ extension RunnerTests {
550582
if pattern != "one-way" && pattern != "ping-pong" {
551583
return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
552584
}
553-
let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
554585
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
586+
var fallback: GestureFallback?
587+
if command.synthesized == true {
588+
let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
589+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
590+
var outcome = RunnerInteractionOutcome.performed
591+
runSeries(count: count, pauseMs: pauseMs) { idx in
592+
guard case .performed = outcome else {
593+
return
594+
}
595+
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
596+
if reverse {
597+
outcome = synthesizedDragAt(
598+
app: activeApp,
599+
x: dragPoints.x2,
600+
y: dragPoints.y2,
601+
x2: dragPoints.x,
602+
y2: dragPoints.y,
603+
durationMs: durationMs
604+
)
605+
} else {
606+
outcome = synthesizedDragAt(
607+
app: activeApp,
608+
x: dragPoints.x,
609+
y: dragPoints.y,
610+
x2: dragPoints.x2,
611+
y2: dragPoints.y2,
612+
durationMs: durationMs
613+
)
614+
}
615+
}
616+
return outcome
617+
}
618+
if case .performed = outcome {
619+
return gestureResponse(message: "drag series", timing: timing)
620+
}
621+
fallback = gestureFallback(strategy: "xctest-coordinate-drag-series", from: outcome)
622+
}
623+
let holdDuration = command.synthesized == true
624+
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
625+
: min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
555626
let (timing, outcome) = performGesture(activeApp) {
556627
var outcome = RunnerInteractionOutcome.performed
557628
runSeries(count: count, pauseMs: pauseMs) { idx in
@@ -584,7 +655,7 @@ extension RunnerTests {
584655
if let response = unsupportedResponse(for: outcome) {
585656
return response
586657
}
587-
return gestureResponse(message: "drag series", timing: timing)
658+
return gestureResponse(message: "drag series", timing: timing, fallback: fallback)
588659
case .remotePress:
589660
guard let button = tvRemoteButton(from: command.remoteButton) else {
590661
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)