Skip to content

Commit d2954f6

Browse files
fix: missing long-press on iOS simulator (#26)
* feat: for self-healing e2e tests; assertions * 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> * restore attempts * refactor: unify iOS interactor, remove dead input stubs Since runnerContext is always passed and the capability matrix ensures iOS only runs on simulators in v1, the two iOS interactor code paths collapse into one: shared methods (open, close, screenshot) plus runner overrides spread on top. No branch needed. This deletes 7 dead iOS input stubs from ios/index.ts (pressIos, longPressIos, focusIos, typeIos, fillIos, scrollIos, scrollIntoViewIos) that only ever threw UNSUPPORTED_OPERATION errors. Co-authored-by: Cursor <cursoragent@cursor.com> * cleanup * fixup save-script * update --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1ecbec9 commit d2954f6

5 files changed

Lines changed: 134 additions & 168 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: 15 additions & 90 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,63 +152,21 @@ 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-
}
229-
await interactor.scrollIntoView(text);
168+
const result = await interactor.scrollIntoView(text);
169+
if (result?.attempts) return { text, attempts: result.attempts };
230170
return { text };
231171
}
232172
case 'pinch': {
@@ -339,18 +279,3 @@ export async function dispatchCommand(
339279
throw new AppError('INVALID_ARGS', `Unknown command: ${command}`);
340280
}
341281
}
342-
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/index.ts

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -83,68 +83,6 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise<void
8383
]);
8484
}
8585

86-
export async function pressIos(device: DeviceInfo, _x: number, _y: number): Promise<void> {
87-
ensureSimulator(device, 'press');
88-
throw new AppError(
89-
'UNSUPPORTED_OPERATION',
90-
'simctl io tap is not available; use the XCTest runner for input',
91-
);
92-
}
93-
94-
export async function longPressIos(
95-
device: DeviceInfo,
96-
_x: number,
97-
_y: number,
98-
_durationMs = 800,
99-
): Promise<void> {
100-
ensureSimulator(device, 'long-press');
101-
throw new AppError(
102-
'UNSUPPORTED_OPERATION',
103-
'long-press is not supported on iOS simulators without XCTest runner support',
104-
);
105-
}
106-
107-
export async function focusIos(device: DeviceInfo, x: number, y: number): Promise<void> {
108-
await pressIos(device, x, y);
109-
}
110-
111-
export async function typeIos(device: DeviceInfo, _text: string): Promise<void> {
112-
ensureSimulator(device, 'type');
113-
throw new AppError(
114-
'UNSUPPORTED_OPERATION',
115-
'simctl io keyboard is not available; use the XCTest runner for input',
116-
);
117-
}
118-
119-
export async function fillIos(
120-
device: DeviceInfo,
121-
x: number,
122-
y: number,
123-
text: string,
124-
): Promise<void> {
125-
await focusIos(device, x, y);
126-
await typeIos(device, text);
127-
}
128-
129-
export async function scrollIos(
130-
device: DeviceInfo,
131-
_direction: string,
132-
_amount = 0.6,
133-
): Promise<void> {
134-
ensureSimulator(device, 'scroll');
135-
throw new AppError(
136-
'UNSUPPORTED_OPERATION',
137-
'simctl io swipe is not available; use the XCTest runner for input',
138-
);
139-
}
140-
141-
export async function scrollIntoViewIos(text: string): Promise<void> {
142-
throw new AppError(
143-
'UNSUPPORTED_OPERATION',
144-
`scrollintoview is not supported on iOS without UI automation (${text})`,
145-
);
146-
}
147-
14886
export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
14987
if (device.kind === 'simulator') {
15088
await ensureBootedSimulator(device);

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;

0 commit comments

Comments
 (0)