Skip to content

Commit f6f3f7a

Browse files
thymikeecursoragent
andcommitted
fix: support long-press on iOS simulators via XCTest runner
The long-press command was failing on iOS simulators because dispatch.ts called interactor.longPress() which mapped to a stub in ios/index.ts that always threw UNSUPPORTED_OPERATION. Unlike press, type, fill, etc., the long-press case was missing the iOS simulator routing through the XCTest runner. Rather than adding yet another platform branch in dispatch.ts, this refactors the Interactor abstraction to absorb runner routing internally. getInteractor() now accepts an optional RunnerContext; when the device is an iOS simulator, it returns an interactor whose tap/longPress/focus/ type/fill/scroll/scrollIntoView methods route through runIosRunnerCommand. This removes 7 scattered if-ios-simulator branches from dispatch.ts, making it impossible to forget runner routing for future commands. Changes: - Swift runner: add longPress command type + longPressAt helper using XCUICoordinate.press(forDuration:) - runner-client.ts: add longPress to RunnerCommand type + durationMs field - interactors.ts: add RunnerContext type, createIosSimulatorInteractor() that routes through the XCTest runner, move invertScrollDirection here - dispatch.ts: pass RunnerContext to getInteractor, remove all iOS sim branching for interactor commands (-90 lines) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 09e7ef2 commit f6f3f7a

4 files changed

Lines changed: 137 additions & 90 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,13 @@ final class RunnerTests: XCTestCase {
232232
return Response(ok: true, data: DataPayload(message: "tapped"))
233233
}
234234
return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
235+
case .longPress:
236+
guard let x = command.x, let y = command.y else {
237+
return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
238+
}
239+
let duration = (command.durationMs ?? 800) / 1000.0
240+
longPressAt(app: activeApp, x: x, y: y, duration: duration)
241+
return Response(ok: true, data: DataPayload(message: "long pressed"))
235242
case .type:
236243
guard let text = command.text else {
237244
return Response(ok: false, error: ErrorPayload(message: "type requires text"))
@@ -411,6 +418,12 @@ final class RunnerTests: XCTestCase {
411418
coordinate.tap()
412419
}
413420

421+
private func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) {
422+
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
423+
let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
424+
coordinate.press(forDuration: duration)
425+
}
426+
414427
private func swipe(app: XCUIApplication, direction: SwipeDirection) {
415428
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
416429
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
@@ -792,6 +805,7 @@ private func resolveRunnerPort() -> UInt16 {
792805

793806
enum CommandType: String, Codable {
794807
case tap
808+
case longPress
795809
case type
796810
case swipe
797811
case findText
@@ -820,6 +834,7 @@ struct Command: Codable {
820834
let action: String?
821835
let x: Double?
822836
let y: Double?
837+
let durationMs: Double?
823838
let direction: SwipeDirection?
824839
let scale: Double?
825840
let interactiveOnly: Bool?

src/core/dispatch.ts

Lines changed: 13 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
snapshotAndroid,
1313
} from '../platforms/android/index.ts';
1414
import { listIosDevices } from '../platforms/ios/devices.ts';
15-
import { getInteractor } from '../utils/interactors.ts';
15+
import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
1616
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
1717
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
1818
import { setIosSetting } from '../platforms/ios/index.ts';
@@ -92,7 +92,13 @@ export async function dispatchCommand(
9292
snapshotBackend?: 'ax' | 'xctest';
9393
},
9494
): Promise<Record<string, unknown> | void> {
95-
const interactor = getInteractor(device);
95+
const runnerCtx: RunnerContext = {
96+
appBundleId: context?.appBundleId,
97+
verbose: context?.verbose,
98+
logPath: context?.logPath,
99+
traceLogPath: context?.traceLogPath,
100+
};
101+
const interactor = getInteractor(device, runnerCtx);
96102
switch (command) {
97103
case 'open': {
98104
const app = positionals[0];
@@ -114,15 +120,7 @@ export async function dispatchCommand(
114120
case 'press': {
115121
const [x, y] = positionals.map(Number);
116122
if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y');
117-
if (device.platform === 'ios' && device.kind === 'simulator') {
118-
await runIosRunnerCommand(
119-
device,
120-
{ command: 'tap', x, y, appBundleId: context?.appBundleId },
121-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
122-
);
123-
} else {
124-
await interactor.tap(x, y);
125-
}
123+
await interactor.tap(x, y);
126124
return { x, y };
127125
}
128126
case 'long-press': {
@@ -138,29 +136,13 @@ export async function dispatchCommand(
138136
case 'focus': {
139137
const [x, y] = positionals.map(Number);
140138
if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'focus requires x y');
141-
if (device.platform === 'ios' && device.kind === 'simulator') {
142-
await runIosRunnerCommand(
143-
device,
144-
{ command: 'tap', x, y, appBundleId: context?.appBundleId },
145-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
146-
);
147-
} else {
148-
await interactor.focus(x, y);
149-
}
139+
await interactor.focus(x, y);
150140
return { x, y };
151141
}
152142
case 'type': {
153143
const text = positionals.join(' ');
154144
if (!text) throw new AppError('INVALID_ARGS', 'type requires text');
155-
if (device.platform === 'ios' && device.kind === 'simulator') {
156-
await runIosRunnerCommand(
157-
device,
158-
{ command: 'type', text, appBundleId: context?.appBundleId },
159-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
160-
);
161-
} else {
162-
await interactor.type(text);
163-
}
145+
await interactor.type(text);
164146
return { text };
165147
}
166148
case 'fill': {
@@ -170,62 +152,19 @@ export async function dispatchCommand(
170152
if (Number.isNaN(x) || Number.isNaN(y) || !text) {
171153
throw new AppError('INVALID_ARGS', 'fill requires x y text');
172154
}
173-
if (device.platform === 'ios' && device.kind === 'simulator') {
174-
await runIosRunnerCommand(
175-
device,
176-
{ command: 'tap', x, y, appBundleId: context?.appBundleId },
177-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
178-
);
179-
await runIosRunnerCommand(
180-
device,
181-
{ command: 'type', text, clearFirst: true, appBundleId: context?.appBundleId },
182-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
183-
);
184-
} else {
185-
await interactor.fill(x, y, text);
186-
}
155+
await interactor.fill(x, y, text);
187156
return { x, y, text };
188157
}
189158
case 'scroll': {
190159
const direction = positionals[0];
191160
const amount = positionals[1] ? Number(positionals[1]) : undefined;
192161
if (!direction) throw new AppError('INVALID_ARGS', 'scroll requires direction');
193-
if (device.platform === 'ios' && device.kind === 'simulator') {
194-
if (!['up', 'down', 'left', 'right'].includes(direction)) {
195-
throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`);
196-
}
197-
const inverted = invertScrollDirection(direction as 'up' | 'down' | 'left' | 'right');
198-
await runIosRunnerCommand(
199-
device,
200-
{ command: 'swipe', direction: inverted, appBundleId: context?.appBundleId },
201-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
202-
);
203-
} else {
204-
await interactor.scroll(direction, amount);
205-
}
162+
await interactor.scroll(direction, amount);
206163
return { direction, amount };
207164
}
208165
case 'scrollintoview': {
209166
const text = positionals.join(' ').trim();
210167
if (!text) throw new AppError('INVALID_ARGS', 'scrollintoview requires text');
211-
if (device.platform === 'ios' && device.kind === 'simulator') {
212-
const maxAttempts = 8;
213-
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
214-
const found = (await runIosRunnerCommand(
215-
device,
216-
{ command: 'findText', text, appBundleId: context?.appBundleId },
217-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
218-
)) as { found?: boolean };
219-
if (found?.found) return { text, attempts: attempt + 1 };
220-
await runIosRunnerCommand(
221-
device,
222-
{ command: 'swipe', direction: 'up', appBundleId: context?.appBundleId },
223-
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
224-
);
225-
await new Promise((resolve) => setTimeout(resolve, 300));
226-
}
227-
throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`);
228-
}
229168
await interactor.scrollIntoView(text);
230169
return { text };
231170
}
@@ -340,17 +279,3 @@ export async function dispatchCommand(
340279
}
341280
}
342281

343-
function invertScrollDirection(direction: 'up' | 'down' | 'left' | 'right'): 'up' | 'down' | 'left' | 'right' {
344-
switch (direction) {
345-
case 'up':
346-
return 'down';
347-
case 'down':
348-
return 'up';
349-
case 'left':
350-
return 'right';
351-
case 'right':
352-
return 'left';
353-
}
354-
}
355-
356-
// Runner-only input on iOS simulators (simctl io input is not supported).

src/platforms/ios/runner-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import net from 'node:net';
1111
export type RunnerCommand = {
1212
command:
1313
| 'tap'
14+
| 'longPress'
1415
| 'type'
1516
| 'swipe'
1617
| 'findText'
@@ -27,6 +28,7 @@ export type RunnerCommand = {
2728
action?: 'get' | 'accept' | 'dismiss';
2829
x?: number;
2930
y?: number;
31+
durationMs?: number;
3032
direction?: 'up' | 'down' | 'left' | 'right';
3133
scale?: number;
3234
interactiveOnly?: boolean;

src/utils/interactors.ts

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ import {
2626
screenshotIos,
2727
typeIos,
2828
} from '../platforms/ios/index.ts';
29+
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
30+
31+
export type RunnerContext = {
32+
appBundleId?: string;
33+
verbose?: boolean;
34+
logPath?: string;
35+
traceLogPath?: string;
36+
};
2937

3038
export type Interactor = {
3139
open(app: string, options?: { activity?: string }): Promise<void>;
@@ -41,7 +49,7 @@ export type Interactor = {
4149
screenshot(outPath: string): Promise<void>;
4250
};
4351

44-
export function getInteractor(device: DeviceInfo): Interactor {
52+
export function getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor {
4553
switch (device.platform) {
4654
case 'android':
4755
return {
@@ -57,7 +65,10 @@ export function getInteractor(device: DeviceInfo): Interactor {
5765
scrollIntoView: (text) => scrollIntoViewAndroid(device, text),
5866
screenshot: (outPath) => screenshotAndroid(device, outPath),
5967
};
60-
case 'ios':
68+
case 'ios': {
69+
if (device.kind === 'simulator' && runnerContext) {
70+
return createIosSimulatorInteractor(device, runnerContext);
71+
}
6172
return {
6273
open: (app) => openIosApp(device, app),
6374
openDevice: () => openIosDevice(device),
@@ -71,7 +82,101 @@ export function getInteractor(device: DeviceInfo): Interactor {
7182
scrollIntoView: (text) => scrollIntoViewIos(text),
7283
screenshot: (outPath) => screenshotIos(device, outPath),
7384
};
85+
}
7486
default:
7587
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`);
7688
}
7789
}
90+
91+
function createIosSimulatorInteractor(device: DeviceInfo, ctx: RunnerContext): Interactor {
92+
const runnerOpts = { verbose: ctx.verbose, logPath: ctx.logPath, traceLogPath: ctx.traceLogPath };
93+
94+
return {
95+
open: (app) => openIosApp(device, app),
96+
openDevice: () => openIosDevice(device),
97+
close: (app) => closeIosApp(device, app),
98+
tap: async (x, y) => {
99+
await runIosRunnerCommand(
100+
device,
101+
{ command: 'tap', x, y, appBundleId: ctx.appBundleId },
102+
runnerOpts,
103+
);
104+
},
105+
longPress: async (x, y, durationMs) => {
106+
await runIosRunnerCommand(
107+
device,
108+
{ command: 'longPress', x, y, durationMs, appBundleId: ctx.appBundleId },
109+
runnerOpts,
110+
);
111+
},
112+
focus: async (x, y) => {
113+
await runIosRunnerCommand(
114+
device,
115+
{ command: 'tap', x, y, appBundleId: ctx.appBundleId },
116+
runnerOpts,
117+
);
118+
},
119+
type: async (text) => {
120+
await runIosRunnerCommand(
121+
device,
122+
{ command: 'type', text, appBundleId: ctx.appBundleId },
123+
runnerOpts,
124+
);
125+
},
126+
fill: async (x, y, text) => {
127+
await runIosRunnerCommand(
128+
device,
129+
{ command: 'tap', x, y, appBundleId: ctx.appBundleId },
130+
runnerOpts,
131+
);
132+
await runIosRunnerCommand(
133+
device,
134+
{ command: 'type', text, clearFirst: true, appBundleId: ctx.appBundleId },
135+
runnerOpts,
136+
);
137+
},
138+
scroll: async (direction, _amount) => {
139+
if (!['up', 'down', 'left', 'right'].includes(direction)) {
140+
throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`);
141+
}
142+
const inverted = invertScrollDirection(direction as 'up' | 'down' | 'left' | 'right');
143+
await runIosRunnerCommand(
144+
device,
145+
{ command: 'swipe', direction: inverted, appBundleId: ctx.appBundleId },
146+
runnerOpts,
147+
);
148+
},
149+
scrollIntoView: async (text) => {
150+
const maxAttempts = 8;
151+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
152+
const found = (await runIosRunnerCommand(
153+
device,
154+
{ command: 'findText', text, appBundleId: ctx.appBundleId },
155+
runnerOpts,
156+
)) as { found?: boolean };
157+
if (found?.found) return;
158+
await runIosRunnerCommand(
159+
device,
160+
{ command: 'swipe', direction: 'up', appBundleId: ctx.appBundleId },
161+
runnerOpts,
162+
);
163+
await new Promise((resolve) => setTimeout(resolve, 300));
164+
}
165+
throw new AppError('COMMAND_FAILED', `scrollintoview could not find text: ${text}`);
166+
},
167+
screenshot: (outPath) => screenshotIos(device, outPath),
168+
};
169+
}
170+
171+
function invertScrollDirection(direction: 'up' | 'down' | 'left' | 'right'): 'up' | 'down' | 'left' | 'right' {
172+
switch (direction) {
173+
case 'up':
174+
return 'down';
175+
case 'down':
176+
return 'up';
177+
case 'left':
178+
return 'right';
179+
case 'right':
180+
return 'left';
181+
}
182+
}

0 commit comments

Comments
 (0)