Skip to content

Commit e59290e

Browse files
committed
fix(ios): paste text through WDA clipboard
1 parent 08f87a0 commit e59290e

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
@@ -62,6 +62,7 @@ describe('Device Options Type Definitions', () => {
6262
sessionId: 'external-session-id',
6363
useWDA: true,
6464
autoDismissKeyboard: true,
65+
keyboardInputStrategy: 'paste',
6566
};
6667

6768
// Type check - this will fail at compile time if types are incorrect
@@ -87,6 +88,7 @@ describe('Device Options Type Definitions', () => {
8788
test('IOSDeviceInputOpt should include keyboard options', () => {
8889
const inputOptions: IOSDeviceInputOpt = {
8990
autoDismissKeyboard: true,
91+
keyboardInputStrategy: 'type',
9092
keyboardTypeDelay: 80,
9193
};
9294

@@ -161,6 +163,7 @@ describe('Device Options Type Definitions', () => {
161163
sessionId: 'external-session-id',
162164
useWDA: true,
163165
autoDismissKeyboard: true,
166+
keyboardInputStrategy: 'paste',
164167

165168
// YAML-specific
166169
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
@@ -39,6 +39,7 @@ describe('IOSDevice', () => {
3939
longPress: vi.fn().mockResolvedValue(undefined),
4040
swipe: vi.fn().mockResolvedValue(undefined),
4141
pinch: vi.fn().mockResolvedValue(undefined),
42+
pasteText: vi.fn().mockResolvedValue(undefined),
4243
typeText: vi.fn().mockResolvedValue(undefined),
4344
clearActiveElement: vi.fn().mockResolvedValue(true),
4445
pressKey: vi.fn().mockResolvedValue(undefined),
@@ -248,31 +249,42 @@ describe('IOSDevice', () => {
248249
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(1, 10, 20);
249250
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(2, 30, 40);
250251
expect(mockWdaClient.clearActiveElement).toHaveBeenCalledTimes(2);
251-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(1, 'from action', {
252-
delayMs: 80,
253-
});
254-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(
252+
expect(mockWdaClient.pasteText).toHaveBeenNthCalledWith(1, 'from action');
253+
expect(mockWdaClient.pasteText).toHaveBeenNthCalledWith(
255254
2,
256255
'from pointer',
257-
{ delayMs: 80 },
258256
);
257+
expect(mockWdaClient.typeText).not.toHaveBeenCalled();
259258
});
260259

261-
it('forwards keyboardTypeDelay from per-call options to the WDA backend', async () => {
262-
await device.inputPrimitives.keyboard.typeText('per call', {
263-
autoDismissKeyboard: false,
264-
keyboardTypeDelay: 25,
265-
} as any);
260+
it('forwards keyboardTypeDelay from per-call options when using type strategy', async () => {
261+
const deviceWithTypeStrategy = new IOSDevice({
262+
wdaPort: DEFAULT_WDA_PORT,
263+
wdaHost: 'localhost',
264+
keyboardInputStrategy: 'type',
265+
});
266+
267+
await deviceWithTypeStrategy.inputPrimitives.keyboard.typeText(
268+
'per call',
269+
{
270+
autoDismissKeyboard: false,
271+
keyboardTypeDelay: 25,
272+
} as any,
273+
);
266274

267275
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('per call', {
268276
delayMs: 25,
269277
});
278+
expect(mockWdaClient.pasteText).not.toHaveBeenCalled();
279+
280+
await deviceWithTypeStrategy.destroy();
270281
});
271282

272-
it('falls back to device-level keyboardTypeDelay when the call omits it', async () => {
283+
it('falls back to device-level keyboardTypeDelay when using type strategy', async () => {
273284
const deviceWithDelay = new IOSDevice({
274285
wdaPort: DEFAULT_WDA_PORT,
275286
wdaHost: 'localhost',
287+
keyboardInputStrategy: 'type',
276288
keyboardTypeDelay: 0,
277289
});
278290

@@ -286,6 +298,21 @@ describe('IOSDevice', () => {
286298

287299
await deviceWithDelay.destroy();
288300
});
301+
302+
it('falls back to WDA typing when paste input is unavailable', async () => {
303+
mockWdaClient.pasteText = vi
304+
.fn()
305+
.mockRejectedValue(new Error('Paste not supported'));
306+
307+
await device.inputPrimitives.keyboard.typeText('fallback text', {
308+
autoDismissKeyboard: false,
309+
} as any);
310+
311+
expect(mockWdaClient.pasteText).toHaveBeenCalledWith('fallback text');
312+
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('fallback text', {
313+
delayMs: 80,
314+
});
315+
});
289316
});
290317

291318
describe('Device Operations', () => {
@@ -427,13 +454,12 @@ describe('IOSDevice', () => {
427454
expect(mockWdaClient.swipe).toHaveBeenCalledWith(100, 200, 300, 400, 500);
428455
});
429456

430-
it('should type text', async () => {
457+
it('should paste text by default', async () => {
431458
await device.connect();
432459

433460
await getInternalTextInput(device).typeText('Hello World');
434-
expect(mockWdaClient.typeText).toHaveBeenCalledWith('Hello World', {
435-
delayMs: 80,
436-
});
461+
expect(mockWdaClient.pasteText).toHaveBeenCalledWith('Hello World');
462+
expect(mockWdaClient.typeText).not.toHaveBeenCalled();
437463
});
438464

439465
it('should press home button', async () => {
@@ -566,6 +592,9 @@ describe('IOSDevice', () => {
566592

567593
it('should handle text input failure', async () => {
568594
await device.connect();
595+
mockWdaClient.pasteText = vi
596+
.fn()
597+
.mockRejectedValue(new Error('Paste text failed'));
569598
mockWdaClient.typeText = vi
570599
.fn()
571600
.mockRejectedValue(new Error('Type text failed'));
@@ -619,6 +648,7 @@ describe('IOSDevice', () => {
619648
const mockBackend = {
620649
...mockWdaClient,
621650
createSession: vi.fn().mockResolvedValue({ sessionId: 'test-session' }),
651+
pasteText: vi.fn().mockResolvedValue(undefined),
622652
typeText: vi.fn().mockResolvedValue(undefined),
623653
dismissKeyboard: vi
624654
.fn()
@@ -637,10 +667,9 @@ describe('IOSDevice', () => {
637667
await deviceWithAutoDismiss.connect();
638668
await getInternalTextInput(deviceWithAutoDismiss).typeText('test text');
639669

640-
// Should call typeText and swipe (for keyboard dismiss)
641-
expect(mockBackend.typeText).toHaveBeenCalledWith('test text', {
642-
delayMs: 80,
643-
});
670+
// Should call pasteText and swipe (for keyboard dismiss)
671+
expect(mockBackend.pasteText).toHaveBeenCalledWith('test text');
672+
expect(mockBackend.typeText).not.toHaveBeenCalled();
644673
expect(mockBackend.swipe).toHaveBeenCalled();
645674
});
646675
});

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

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

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

0 commit comments

Comments
 (0)