Skip to content

Commit 945a780

Browse files
authored
fix: stabilize iOS runner gestures (#800)
* fix: avoid stale iOS window viewport queries * fix: synthesize iOS swipe gestures * fix: honor iOS drag gesture durations * test: update iOS gesture provider transcript * fix: align sequence drag fallback timing
1 parent 8de4ddd commit 945a780

10 files changed

Lines changed: 138 additions & 43 deletions

File tree

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,9 @@ extension RunnerTests {
4949
return nil
5050
}
5151

52-
// The public windows query backing safeSnapshotViewport can fail on the same apps that
53-
// need this fallback, degrading to an infinite viewport that marks off-screen content
54-
// (e.g. closed drawer menus at negative x) as visible and tappable. The private root's
55-
// own frame is the reliable screen rect here.
52+
// If the app frame is unavailable, the private root's own frame is the reliable screen
53+
// rect here. Avoid public window queries: stale transient windows can record XCTest
54+
// failures after the runner already returned a successful command response.
5655
var viewport = safeSnapshotViewport(app: app)
5756
let rootFrame = privateAXRect(root["frame"])
5857
if viewport.isInfinite || viewport.isNull || viewport.isEmpty, !rootFrame.isEmpty {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ extension RunnerTests {
1313
return (gestureStartUptimeMs, currentUptimeMs())
1414
}
1515

16-
private func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval {
16+
func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval {
1717
min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
1818
}
1919

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ struct SequenceStep: Codable {
157157
let y2: Double?
158158
let durationMs: Double?
159159
let pauseMs: Double?
160-
/// For `tap` steps on iOS non-tv: use the synthesized HID tap fast path (synthesizedTapAt)
161-
/// instead of the drag-based XCUICoordinate tapAt, matching the individual `tap` command.
160+
/// For `tap`/`drag` steps on iOS non-tv: use the synthesized HID fast path instead of the
161+
/// drag-based XCUICoordinate path, matching the individual command behavior.
162162
let synthesized: Bool?
163163
}
164164

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,50 @@ extension RunnerTests {
164164
}
165165
// Synthesis unsupported (e.g. macOS) — fall through to the drag-based tapAt below.
166166
}
167+
if step.kind == "drag", step.synthesized == true {
168+
let dragPoints = keyboardAvoidingDragPoints(
169+
app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
170+
let durationMs = min(max(step.durationMs ?? 250, 16), 10000)
171+
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
172+
synthesizedDragAt(
173+
app: activeApp,
174+
x: dragPoints.x,
175+
y: dragPoints.y,
176+
x2: dragPoints.x2,
177+
y2: dragPoints.y2,
178+
durationMs: durationMs
179+
)
180+
}
181+
if case .performed = outcome {
182+
if let pauseMs = step.pauseMs, pauseMs > 0 {
183+
sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
184+
}
185+
return SequenceStepOutcome(
186+
outcome: outcome,
187+
gestureStartUptimeMs: timing.gestureStartUptimeMs,
188+
gestureEndUptimeMs: timing.gestureEndUptimeMs
189+
)
190+
}
191+
let fallbackHoldDuration = synthesizedSwipeFallbackHoldDuration(durationMs: step.durationMs ?? 250)
192+
let (fallbackTiming, fallbackOutcome) = performGesture(activeApp) {
193+
dragAt(
194+
app: activeApp,
195+
x: dragPoints.x,
196+
y: dragPoints.y,
197+
x2: dragPoints.x2,
198+
y2: dragPoints.y2,
199+
holdDuration: fallbackHoldDuration
200+
)
201+
}
202+
if case .performed = fallbackOutcome, let pauseMs = step.pauseMs, pauseMs > 0 {
203+
sleepFor(min(max(pauseMs, 0), 10000) / 1000.0)
204+
}
205+
return SequenceStepOutcome(
206+
outcome: fallbackOutcome,
207+
gestureStartUptimeMs: fallbackTiming.gestureStartUptimeMs,
208+
gestureEndUptimeMs: fallbackTiming.gestureEndUptimeMs
209+
)
210+
}
167211
let (timing, outcome) = performGesture(activeApp) {
168212
switch step.kind {
169213
case "doubleTap":
@@ -173,9 +217,9 @@ extension RunnerTests {
173217
let duration = min(max(step.durationMs ?? 800, 16), 10000) / 1000.0
174218
return longPressAt(app: activeApp, x: x, y: y, duration: duration)
175219
case "drag":
176-
// Route through keyboardAvoidingDragPoints for parity with the individual `.drag` command
177-
// (RunnerTests+CommandExecution.swift). durationMs is intentionally ignored on this
178-
// coordinate-drag path, matching that command's non-synthesized branch.
220+
// Route through keyboardAvoidingDragPoints for parity with the individual `.drag` command.
221+
// The non-synthesized coordinate-drag path ignores durationMs, matching that command's
222+
// non-synthesized branch.
179223
let dragPoints = keyboardAvoidingDragPoints(
180224
app: activeApp, x: x, y: y, x2: step.x2 ?? x, y2: step.y2 ?? y)
181225
return dragAt(

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

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -739,15 +739,6 @@ extension RunnerTests {
739739
}
740740

741741
private func snapshotViewport(app: XCUIApplication) -> CGRect {
742-
let windows = app.windows.allElementsBoundByIndex
743-
let windowFrames = windows
744-
.filter { $0.exists && !$0.frame.isNull && !$0.frame.isEmpty }
745-
.map(\.frame)
746-
if let largestWindowFrame = windowFrames.max(by: { left, right in
747-
left.width * left.height < right.width * right.height
748-
}) {
749-
return largestWindowFrame
750-
}
751742
let appFrame = app.frame
752743
if !appFrame.isNull && !appFrame.isEmpty {
753744
return appFrame

src/core/__tests__/dispatch-interactions.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,25 @@ test('handleSwipeCommand fuses repeated swipes into sequence drag steps with pin
197197
steps: [
198198
// Ping-pong is unrolled daemon-side: odd indices swap endpoints, replacing the
199199
// runner-side pattern handling of the retired dragSeries command.
200-
{ kind: 'drag', x: 100, y: 650, x2: 100, y2: 450, durationMs: 120, pauseMs: 50 },
201-
{ kind: 'drag', x: 100, y: 450, x2: 100, y2: 650, durationMs: 120 },
200+
{
201+
kind: 'drag',
202+
x: 100,
203+
y: 650,
204+
x2: 100,
205+
y2: 450,
206+
durationMs: 120,
207+
synthesized: true,
208+
pauseMs: 50,
209+
},
210+
{
211+
kind: 'drag',
212+
x: 100,
213+
y: 450,
214+
x2: 100,
215+
y2: 650,
216+
durationMs: 120,
217+
synthesized: true,
218+
},
202219
],
203220
appBundleId: 'com.example.App',
204221
});

src/core/dispatch-interactions.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,10 @@ function buildPressSequenceSteps(
427427

428428
// Unrolls a swipe series into `sequence` drag steps, replacing the retired `dragSeries` runner
429429
// command. Ping-pong becomes per-step endpoint swapping (odd indices reversed), matching the
430-
// runner-side performDragSeries the daemon no longer invokes. durationMs is carried for wire
431-
// fidelity and budget estimation; the runner's coordinate-drag path ignores it, exactly as the
432-
// daemon-sent (non-synthesized) dragSeries did.
430+
// runner-side performDragSeries the daemon no longer invokes. iOS touch targets request the same
431+
// synthesized, duration-aware drag path as one-shot swipe; macOS/tvOS keep coordinate drag.
433432
function buildSwipeSequenceSteps(params: {
433+
device: DeviceInfo;
434434
x1: number;
435435
y1: number;
436436
x2: number;
@@ -440,7 +440,8 @@ function buildSwipeSequenceSteps(params: {
440440
pattern: string;
441441
effectiveDurationMs: number;
442442
}): RunnerSequenceStep[] {
443-
const { x1, y1, x2, y2, count, pauseMs, pattern, effectiveDurationMs } = params;
443+
const { device, x1, y1, x2, y2, count, pauseMs, pattern, effectiveDurationMs } = params;
444+
const synthesized = device.platform === 'ios' && device.target !== 'tv';
444445
return Array.from({ length: count }, (_, index) => {
445446
const reverse = pattern === 'ping-pong' && index % 2 === 1;
446447
const isLast = index === count - 1;
@@ -451,6 +452,7 @@ function buildSwipeSequenceSteps(params: {
451452
x2: reverse ? x1 : x2,
452453
y2: reverse ? y1 : y2,
453454
durationMs: effectiveDurationMs,
455+
...(synthesized ? { synthesized: true } : {}),
454456
...(!isLast && pauseMs > 0 ? { pauseMs } : {}),
455457
};
456458
});
@@ -608,7 +610,17 @@ async function runSwipeCoordinates(params: {
608610
if (shouldUseIosDragSeries(device, count)) {
609611
const aggregated = await runIosSequenceChunks(
610612
device,
611-
buildSwipeSequenceSteps({ x1, y1, x2, y2, count, pauseMs, pattern, effectiveDurationMs }),
613+
buildSwipeSequenceSteps({
614+
device,
615+
x1,
616+
y1,
617+
x2,
618+
y2,
619+
count,
620+
pauseMs,
621+
pattern,
622+
effectiveDurationMs,
623+
}),
612624
context,
613625
);
614626
return {

src/platforms/ios/__tests__/index.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ test('resolveMacOsHelperPackageRootFrom finds helper package from source and dis
184184
}
185185
});
186186

187-
test('iosRunnerOverrides gives fling a short default XCUITest drag hold', async () => {
187+
test('iosRunnerOverrides maps iOS fling duration to synthesized drag', async () => {
188188
mockRunIosRunnerCommand.mockResolvedValue({});
189189

190190
const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, {
@@ -200,6 +200,7 @@ test('iosRunnerOverrides gives fling a short default XCUITest drag hold', async
200200
x2: 180,
201201
y2: 200,
202202
durationMs: 16,
203+
synthesized: true,
203204
appBundleId: 'com.example.App',
204205
});
205206
});
@@ -252,7 +253,7 @@ for (const [name, device] of [
252253
});
253254
}
254255

255-
test('iosRunnerOverrides maps swipe to XCTest iOS drag duration', async () => {
256+
test('iosRunnerOverrides maps iOS swipe and pan durations to synthesized drag', async () => {
256257
mockRunIosRunnerCommand.mockResolvedValue({});
257258

258259
const { overrides } = iosRunnerOverrides(IOS_TEST_SIMULATOR, {
@@ -270,6 +271,7 @@ test('iosRunnerOverrides maps swipe to XCTest iOS drag duration', async () => {
270271
x2: 180,
271272
y2: 200,
272273
durationMs: 300,
274+
synthesized: true,
273275
appBundleId: 'com.example.App',
274276
});
275277
assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], {
@@ -279,6 +281,7 @@ test('iosRunnerOverrides maps swipe to XCTest iOS drag duration', async () => {
279281
x2: 180,
280282
y2: 200,
281283
durationMs: 250,
284+
synthesized: true,
282285
appBundleId: 'com.example.App',
283286
});
284287
assert.deepEqual(mockRunIosRunnerCommand.mock.calls[2]?.[1], {
@@ -288,6 +291,7 @@ test('iosRunnerOverrides maps swipe to XCTest iOS drag duration', async () => {
288291
x2: 180,
289292
y2: 200,
290293
durationMs: 300,
294+
synthesized: true,
291295
appBundleId: 'com.example.App',
292296
});
293297
});
@@ -296,14 +300,16 @@ for (const [name, device] of [
296300
['macOS', MACOS_TEST_DEVICE],
297301
['tvOS', TVOS_TEST_SIMULATOR],
298302
] as const) {
299-
test(`iosRunnerOverrides keeps ${name} swipes on the standard drag path`, async () => {
303+
test(`iosRunnerOverrides keeps ${name} drag gestures on the standard path`, async () => {
300304
mockRunIosRunnerCommand.mockResolvedValue({});
301305

302306
const { overrides } = iosRunnerOverrides(device, {
303307
appBundleId: 'com.example.App',
304308
});
305309

306310
await overrides.swipe(100, 200, 180, 200, 300);
311+
await overrides.pan(100, 200, 180, 200, 300);
312+
await overrides.fling(100, 200, 180, 200, 300);
307313

308314
assert.deepEqual(mockRunIosRunnerCommand.mock.calls[0]?.[1], {
309315
command: 'drag',
@@ -314,6 +320,24 @@ for (const [name, device] of [
314320
durationMs: 300,
315321
appBundleId: 'com.example.App',
316322
});
323+
assert.deepEqual(mockRunIosRunnerCommand.mock.calls[1]?.[1], {
324+
command: 'drag',
325+
x: 100,
326+
y: 200,
327+
x2: 180,
328+
y2: 200,
329+
durationMs: 300,
330+
appBundleId: 'com.example.App',
331+
});
332+
assert.deepEqual(mockRunIosRunnerCommand.mock.calls[2]?.[1], {
333+
command: 'drag',
334+
x: 100,
335+
y: 200,
336+
x2: 180,
337+
y2: 200,
338+
durationMs: 300,
339+
appBundleId: 'com.example.App',
340+
});
317341
});
318342
}
319343

src/platforms/ios/interactions.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ type NormalizedScrollOptions = {
2929
preferProvidedPixels?: boolean;
3030
};
3131

32+
type IosDragCommandOptions = {
33+
defaultDurationMs: number;
34+
legacyDefaultDurationMs?: number;
35+
synthesized?: boolean;
36+
};
37+
3238
type IosRunnerOverrides = Pick<
3339
Interactor,
3440
| 'tap'
@@ -101,6 +107,7 @@ export function iosRunnerOverrides(
101107
device,
102108
iosDragCommand(device, ctx, x1, y1, x2, y2, durationMs, {
103109
defaultDurationMs: IOS_SWIPE_DEFAULT_DURATION_MS,
110+
synthesized: shouldUseSynthesizedIosGesture(device),
104111
}),
105112
runnerOpts,
106113
);
@@ -111,22 +118,19 @@ export function iosRunnerOverrides(
111118
iosDragCommand(device, ctx, x1, y1, x2, y2, durationMs, {
112119
defaultDurationMs: 500,
113120
legacyDefaultDurationMs: 500,
121+
synthesized: shouldUseSynthesizedIosGesture(device),
114122
}),
115123
runnerOpts,
116124
);
117125
},
118126
fling: async (x1, y1, x2, y2, durationMs) => {
119127
return await runIosRunnerCommand(
120128
device,
121-
{
122-
command: 'drag',
123-
x: x1,
124-
y: y1,
125-
x2,
126-
y2,
127-
durationMs: durationMs ?? 16,
128-
appBundleId: ctx.appBundleId,
129-
},
129+
iosDragCommand(device, ctx, x1, y1, x2, y2, durationMs, {
130+
defaultDurationMs: 16,
131+
legacyDefaultDurationMs: 16,
132+
synthesized: shouldUseSynthesizedIosGesture(device),
133+
}),
130134
runnerOpts,
131135
);
132136
},
@@ -252,11 +256,15 @@ function iosTapCommand(
252256
command: 'tap',
253257
x,
254258
y,
255-
...(device.platform === 'ios' && device.target !== 'tv' ? { synthesized: true } : {}),
259+
...(shouldUseSynthesizedIosGesture(device) ? { synthesized: true } : {}),
256260
appBundleId: ctx.appBundleId,
257261
};
258262
}
259263

264+
function shouldUseSynthesizedIosGesture(device: DeviceInfo): boolean {
265+
return device.platform === 'ios' && device.target !== 'tv';
266+
}
267+
260268
function iosDragCommand(
261269
device: DeviceInfo,
262270
ctx: RunnerContext,
@@ -265,10 +273,7 @@ function iosDragCommand(
265273
x2: number,
266274
y2: number,
267275
durationMs: number | undefined,
268-
options: {
269-
defaultDurationMs: number;
270-
legacyDefaultDurationMs?: number;
271-
},
276+
options: IosDragCommandOptions,
272277
): RunnerCommand {
273278
const normalizedDurationMs =
274279
device.platform === 'ios' && device.target !== 'tv'
@@ -281,6 +286,7 @@ function iosDragCommand(
281286
x2,
282287
y2,
283288
...(normalizedDurationMs !== undefined ? { durationMs: normalizedDurationMs } : {}),
289+
...(options.synthesized === true ? { synthesized: true } : {}),
284290
appBundleId: ctx.appBundleId,
285291
};
286292
}

test/integration/provider-scenarios/ios-world.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export async function createIosSettingsWorld(): Promise<IosSettingsWorld> {
7676
x2: 276,
7777
y2: 122,
7878
durationMs: 500,
79+
synthesized: true,
7980
appBundleId: 'com.apple.Preferences',
8081
},
8182
result: { dragged: true },
@@ -91,6 +92,7 @@ export async function createIosSettingsWorld(): Promise<IosSettingsWorld> {
9192
x2: 376,
9293
y2: 122,
9394
durationMs: 50,
95+
synthesized: true,
9496
appBundleId: 'com.apple.Preferences',
9597
},
9698
result: { flung: true },

0 commit comments

Comments
 (0)