Skip to content

Commit af20059

Browse files
committed
fix(ios): paste text through WDA clipboard
1 parent 099171f commit af20059

6 files changed

Lines changed: 195 additions & 28 deletions

File tree

packages/core/src/device/device-options.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,18 @@ export type IOSDeviceInputOpt = {
113113
/** Automatically dismiss the keyboard after input is completed */
114114
autoDismissKeyboard?: boolean;
115115
/**
116-
* Per-character delay (ms) used when typing into iOS input fields. When set
117-
* to a positive number, characters are dispatched to WebDriverAgent one at a
118-
* time with this gap between them, which prevents app-side reaction windows
119-
* (re-render, predictive bar, autocorrect) from swallowing a contiguous
120-
* block of keystrokes. Set to 0 to send the whole string in a single
121-
* `/wda/keys` request (faster, but lossy on slow-reacting inputs).
116+
* Strategy used to enter text into iOS input fields. `paste` writes the text
117+
* to the iOS pasteboard and triggers a paste shortcut, avoiding occasional
118+
* character loss from WebDriverAgent key typing on longer inputs. `type`
119+
* keeps the old `/wda/keys` delivery path.
120+
* @default 'paste'
121+
*/
122+
keyboardInputStrategy?: 'paste' | 'type';
123+
/**
124+
* Per-character delay (ms) used by the `type` strategy and by the fallback
125+
* path when paste is not supported by the active WebDriverAgent/iOS runtime.
126+
* When set to a positive number, characters are dispatched to
127+
* WebDriverAgent one at a time with this gap between them.
122128
* @default 80
123129
*/
124130
keyboardTypeDelay?: number;

packages/core/tests/unit-test/device-options.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ describe('Device Options Type Definitions', () => {
6060
sessionId: 'external-session-id',
6161
useWDA: true,
6262
autoDismissKeyboard: true,
63+
keyboardInputStrategy: 'paste',
6364
};
6465

6566
// Type check - this will fail at compile time if types are incorrect
@@ -85,6 +86,7 @@ describe('Device Options Type Definitions', () => {
8586
test('IOSDeviceInputOpt should include keyboard options', () => {
8687
const inputOptions: IOSDeviceInputOpt = {
8788
autoDismissKeyboard: true,
89+
keyboardInputStrategy: 'type',
8890
keyboardTypeDelay: 80,
8991
};
9092

@@ -137,6 +139,7 @@ describe('Device Options Type Definitions', () => {
137139
sessionId: 'external-session-id',
138140
useWDA: true,
139141
autoDismissKeyboard: true,
142+
keyboardInputStrategy: 'paste',
140143

141144
// YAML-specific
142145
launch: 'com.example.app',

packages/ios/src/device.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,16 +470,33 @@ ScreenSize: ${size.width}x${size.height} (DPR: ${size.scale})
470470

471471
const delayMs =
472472
options?.keyboardTypeDelay ?? this.options?.keyboardTypeDelay ?? 80;
473+
const keyboardInputStrategy =
474+
options?.keyboardInputStrategy ??
475+
this.options?.keyboardInputStrategy ??
476+
'paste';
473477

474-
debugDevice(`Typing text: "${text}" (delayMs=${delayMs})`);
478+
debugDevice(
479+
`Input text: "${text}" (strategy=${keyboardInputStrategy}, delayMs=${delayMs})`,
480+
);
475481

476482
try {
477483
// Wait a bit to ensure keyboard is ready
478484
await sleep(200);
479-
await this.wdaBackend.typeText(text, { delayMs });
485+
if (keyboardInputStrategy === 'type') {
486+
await this.wdaBackend.typeText(text, { delayMs });
487+
} else {
488+
try {
489+
await this.wdaBackend.pasteText(text);
490+
} catch (pasteError) {
491+
debugDevice(
492+
`Failed to paste text with WDA, falling back to typing: ${pasteError}`,
493+
);
494+
await this.wdaBackend.typeText(text, { delayMs });
495+
}
496+
}
480497
await sleep(300); // Give more time for text to appear
481498
} catch (error) {
482-
debugDevice(`Failed to type text with WDA: ${error}`);
499+
debugDevice(`Failed to input text with WDA: ${error}`);
483500
throw error;
484501
}
485502

packages/ios/src/ios-webdriver-client.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const debugIOS = getDebug('webdriver:ios');
88
const WDA_MJPEG_SCREENSHOT_QUALITY = 50;
99
const WDA_MJPEG_FRAMERATE = 30;
1010
const WDA_MJPEG_SCALING_FACTOR = 50;
11+
const XCUI_KEY_MODIFIER_COMMAND = 1 << 4;
1112

1213
export class IOSWebDriverClient extends WebDriverClient {
1314
async launchApp(bundleId: string): Promise<void> {
@@ -305,6 +306,56 @@ export class IOSWebDriverClient extends WebDriverClient {
305306
}
306307
}
307308

309+
async pasteText(text: string): Promise<void> {
310+
this.ensureSession();
311+
312+
// Keep the same surrounding-whitespace behavior as typeText.
313+
const cleanText = text.trim();
314+
if (!cleanText) {
315+
return;
316+
}
317+
318+
try {
319+
await this.setPasteboardText(cleanText);
320+
await this.sendPasteShortcut();
321+
debugIOS(`Pasted text: "${text}"`);
322+
} catch (error) {
323+
debugIOS(`Failed to paste text "${text}": ${error}`);
324+
throw new Error(`Failed to paste text: ${error}`);
325+
}
326+
}
327+
328+
private async setPasteboardText(text: string): Promise<void> {
329+
this.ensureSession();
330+
331+
await this.makeRequest(
332+
'POST',
333+
`/session/${this.sessionId}/wda/setPasteboard`,
334+
{
335+
content: Buffer.from(text, 'utf8').toString('base64'),
336+
contentType: 'plaintext',
337+
},
338+
);
339+
}
340+
341+
private async sendPasteShortcut(): Promise<void> {
342+
this.ensureSession();
343+
344+
const elementId = (await this.getActiveElement()) ?? '0';
345+
await this.makeRequest(
346+
'POST',
347+
`/session/${this.sessionId}/wda/element/${elementId}/keyboardInput`,
348+
{
349+
keys: [
350+
{
351+
key: 'v',
352+
modifierFlags: XCUI_KEY_MODIFIER_COMMAND,
353+
},
354+
],
355+
},
356+
);
357+
}
358+
308359
async typeText(text: string, options?: { delayMs?: number }): Promise<void> {
309360
this.ensureSession();
310361

packages/ios/tests/unit-test/device.test.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe('IOSDevice', () => {
3232
longPress: vi.fn().mockResolvedValue(undefined),
3333
swipe: vi.fn().mockResolvedValue(undefined),
3434
pinch: vi.fn().mockResolvedValue(undefined),
35+
pasteText: vi.fn().mockResolvedValue(undefined),
3536
typeText: vi.fn().mockResolvedValue(undefined),
3637
clearActiveElement: vi.fn().mockResolvedValue(true),
3738
pressKey: vi.fn().mockResolvedValue(undefined),
@@ -236,31 +237,42 @@ describe('IOSDevice', () => {
236237
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(1, 10, 20);
237238
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(2, 30, 40);
238239
expect(mockWdaClient.clearActiveElement).toHaveBeenCalledTimes(2);
239-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(1, 'from action', {
240-
delayMs: 80,
241-
});
242-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(
240+
expect(mockWdaClient.pasteText).toHaveBeenNthCalledWith(1, 'from action');
241+
expect(mockWdaClient.pasteText).toHaveBeenNthCalledWith(
243242
2,
244243
'from pointer',
245-
{ delayMs: 80 },
246244
);
245+
expect(mockWdaClient.typeText).not.toHaveBeenCalled();
247246
});
248247

249-
it('forwards keyboardTypeDelay from per-call options to the WDA backend', async () => {
250-
await device.inputPrimitives.keyboard.typeText('per call', {
251-
autoDismissKeyboard: false,
252-
keyboardTypeDelay: 25,
253-
} as any);
248+
it('forwards keyboardTypeDelay from per-call options when using type strategy', async () => {
249+
const deviceWithTypeStrategy = new IOSDevice({
250+
wdaPort: DEFAULT_WDA_PORT,
251+
wdaHost: 'localhost',
252+
keyboardInputStrategy: 'type',
253+
});
254+
255+
await deviceWithTypeStrategy.inputPrimitives.keyboard.typeText(
256+
'per call',
257+
{
258+
autoDismissKeyboard: false,
259+
keyboardTypeDelay: 25,
260+
} as any,
261+
);
254262

255263
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('per call', {
256264
delayMs: 25,
257265
});
266+
expect(mockWdaClient.pasteText).not.toHaveBeenCalled();
267+
268+
await deviceWithTypeStrategy.destroy();
258269
});
259270

260-
it('falls back to device-level keyboardTypeDelay when the call omits it', async () => {
271+
it('falls back to device-level keyboardTypeDelay when using type strategy', async () => {
261272
const deviceWithDelay = new IOSDevice({
262273
wdaPort: DEFAULT_WDA_PORT,
263274
wdaHost: 'localhost',
275+
keyboardInputStrategy: 'type',
264276
keyboardTypeDelay: 0,
265277
});
266278

@@ -274,6 +286,21 @@ describe('IOSDevice', () => {
274286

275287
await deviceWithDelay.destroy();
276288
});
289+
290+
it('falls back to WDA typing when paste input is unavailable', async () => {
291+
mockWdaClient.pasteText = vi
292+
.fn()
293+
.mockRejectedValue(new Error('Paste not supported'));
294+
295+
await device.inputPrimitives.keyboard.typeText('fallback text', {
296+
autoDismissKeyboard: false,
297+
} as any);
298+
299+
expect(mockWdaClient.pasteText).toHaveBeenCalledWith('fallback text');
300+
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('fallback text', {
301+
delayMs: 80,
302+
});
303+
});
277304
});
278305

279306
describe('Device Operations', () => {
@@ -415,13 +442,12 @@ describe('IOSDevice', () => {
415442
expect(mockWdaClient.swipe).toHaveBeenCalledWith(100, 200, 300, 400, 500);
416443
});
417444

418-
it('should type text', async () => {
445+
it('should paste text by default', async () => {
419446
await device.connect();
420447

421448
await (device as any).typeText('Hello World');
422-
expect(mockWdaClient.typeText).toHaveBeenCalledWith('Hello World', {
423-
delayMs: 80,
424-
});
449+
expect(mockWdaClient.pasteText).toHaveBeenCalledWith('Hello World');
450+
expect(mockWdaClient.typeText).not.toHaveBeenCalled();
425451
});
426452

427453
it('should press home button', async () => {
@@ -554,6 +580,9 @@ describe('IOSDevice', () => {
554580

555581
it('should handle text input failure', async () => {
556582
await device.connect();
583+
mockWdaClient.pasteText = vi
584+
.fn()
585+
.mockRejectedValue(new Error('Paste text failed'));
557586
mockWdaClient.typeText = vi
558587
.fn()
559588
.mockRejectedValue(new Error('Type text failed'));
@@ -607,6 +636,7 @@ describe('IOSDevice', () => {
607636
const mockBackend = {
608637
...mockWdaClient,
609638
createSession: vi.fn().mockResolvedValue({ sessionId: 'test-session' }),
639+
pasteText: vi.fn().mockResolvedValue(undefined),
610640
typeText: vi.fn().mockResolvedValue(undefined),
611641
dismissKeyboard: vi
612642
.fn()
@@ -625,10 +655,9 @@ describe('IOSDevice', () => {
625655
await deviceWithAutoDismiss.connect();
626656
await (deviceWithAutoDismiss as any).typeText('test text');
627657

628-
// Should call typeText and swipe (for keyboard dismiss)
629-
expect(mockBackend.typeText).toHaveBeenCalledWith('test text', {
630-
delayMs: 80,
631-
});
658+
// Should call pasteText and swipe (for keyboard dismiss)
659+
expect(mockBackend.pasteText).toHaveBeenCalledWith('test text');
660+
expect(mockBackend.typeText).not.toHaveBeenCalled();
632661
expect(mockBackend.swipe).toHaveBeenCalled();
633662
});
634663
});

packages/ios/tests/unit-test/wda-backend.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,66 @@ describe('IOSWebDriverClient.typeText delivery modes', () => {
8080
});
8181
});
8282

83+
describe('IOSWebDriverClient.pasteText', () => {
84+
it('sets the iOS pasteboard and sends Command+V to the active element', async () => {
85+
const { client, makeRequest } = createClientWithSession();
86+
makeRequest.mockImplementation(async (_method, path) => {
87+
if (path === '/session/session-under-test/element/active') {
88+
return { value: { ELEMENT: 'active-element-id' } };
89+
}
90+
return undefined;
91+
});
92+
93+
await client.pasteText('Hello 世界');
94+
95+
expect(makeRequest).toHaveBeenCalledTimes(3);
96+
expect(makeRequest).toHaveBeenNthCalledWith(
97+
1,
98+
'POST',
99+
'/session/session-under-test/wda/setPasteboard',
100+
{
101+
content: Buffer.from('Hello 世界', 'utf8').toString('base64'),
102+
contentType: 'plaintext',
103+
},
104+
);
105+
expect(makeRequest).toHaveBeenNthCalledWith(
106+
2,
107+
'GET',
108+
'/session/session-under-test/element/active',
109+
);
110+
expect(makeRequest).toHaveBeenNthCalledWith(
111+
3,
112+
'POST',
113+
'/session/session-under-test/wda/element/active-element-id/keyboardInput',
114+
{
115+
keys: [{ key: 'v', modifierFlags: 16 }],
116+
},
117+
);
118+
});
119+
120+
it('falls back to the active application when no active element is available', async () => {
121+
const { client, makeRequest } = createClientWithSession();
122+
123+
await client.pasteText('Hello');
124+
125+
expect(makeRequest).toHaveBeenLastCalledWith(
126+
'POST',
127+
'/session/session-under-test/wda/element/0/keyboardInput',
128+
{
129+
keys: [{ key: 'v', modifierFlags: 16 }],
130+
},
131+
);
132+
});
133+
134+
it('trims surrounding whitespace and skips empty input', async () => {
135+
const { client, makeRequest } = createClientWithSession();
136+
137+
await client.pasteText(' ');
138+
139+
expect(makeRequest).not.toHaveBeenCalled();
140+
});
141+
});
142+
83143
describe('IOSWebDriverClient - Simple Tests', () => {
84144
describe('Module Structure', () => {
85145
it('should export IOSWebDriverClient class', async () => {
@@ -116,6 +176,7 @@ describe('IOSWebDriverClient - Simple Tests', () => {
116176
'tap',
117177
'swipe',
118178
'typeText',
179+
'pasteText',
119180
'pressKey',
120181
'launchApp',
121182
'openUrl',

0 commit comments

Comments
 (0)