Skip to content

Commit 11b69f3

Browse files
committed
fix(ios): type characters individually to prevent dropped keystrokes
The default WDA `/wda/keys` path pushes the whole string in a single request, so XCUITest fires keystrokes back-to-back with ~30-50ms gaps. If the input's onChange handler blocks for longer than that gap (RN re-render, predictive bar, autocorrect), every keystroke that lands inside the blocking window is dropped, producing contiguous gaps such as "Al is amazing" arriving as "Al mazing". Send characters one at a time with an inter-key delay so the gap exceeds the typical app reaction window. The new `keyboardTypeDelay` option on `IOSDeviceInputOpt` defaults to 80ms and can be set to 0 to restore the legacy one-shot behavior.
1 parent b4ac9ab commit 11b69f3

6 files changed

Lines changed: 178 additions & 18 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ export type AndroidDeviceOpt = {
112112
export type IOSDeviceInputOpt = {
113113
/** Automatically dismiss the keyboard after input is completed */
114114
autoDismissKeyboard?: boolean;
115+
/**
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).
122+
* @default 80
123+
*/
124+
keyboardTypeDelay?: number;
115125
};
116126

117127
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe('Device Options Type Definitions', () => {
8585
test('IOSDeviceInputOpt should include keyboard options', () => {
8686
const inputOptions: IOSDeviceInputOpt = {
8787
autoDismissKeyboard: true,
88+
keyboardTypeDelay: 80,
8889
};
8990

9091
expect(inputOptions).toBeDefined();

packages/ios/src/device.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,12 +468,15 @@ ScreenSize: ${size.width}x${size.height} (DPR: ${size.scale})
468468
const shouldAutoDismissKeyboard =
469469
options?.autoDismissKeyboard ?? this.options?.autoDismissKeyboard ?? true;
470470

471-
debugDevice(`Typing text: "${text}"`);
471+
const delayMs =
472+
options?.keyboardTypeDelay ?? this.options?.keyboardTypeDelay ?? 80;
473+
474+
debugDevice(`Typing text: "${text}" (delayMs=${delayMs})`);
472475

473476
try {
474477
// Wait a bit to ensure keyboard is ready
475478
await sleep(200);
476-
await this.wdaBackend.typeText(text);
479+
await this.wdaBackend.typeText(text, { delayMs });
477480
await sleep(300); // Give more time for text to appear
478481
} catch (error) {
479482
debugDevice(`Failed to type text with WDA: ${error}`);

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

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sleep } from '@midscene/core/utils';
12
import { getDebug } from '@midscene/shared/logger';
23
import { WebDriverClient } from '@midscene/webdriver';
34

@@ -304,17 +305,42 @@ export class IOSWebDriverClient extends WebDriverClient {
304305
}
305306
}
306307

307-
async typeText(text: string): Promise<void> {
308+
async typeText(text: string, options?: { delayMs?: number }): Promise<void> {
308309
this.ensureSession();
309310

311+
// Clean the text to avoid unwanted trailing spaces
312+
const cleanText = text.trim();
313+
if (!cleanText) {
314+
return;
315+
}
316+
317+
const chars = Array.from(cleanText);
318+
const delayMs = options?.delayMs ?? 0;
319+
310320
try {
311-
// Clean the text to avoid unwanted trailing spaces
312-
const cleanText = text.trim();
313-
// Use WebDriverAgent's keys endpoint with array value
314-
await this.makeRequest('POST', `/session/${this.sessionId}/wda/keys`, {
315-
value: cleanText.split(''), // Must be an array of characters
316-
});
317-
debugIOS(`Typed text: "${text}"`);
321+
if (delayMs > 0) {
322+
// Per-character sends with an inter-key gap prevent a single app-side
323+
// reaction window (re-render, predictive bar, autocorrect) from
324+
// swallowing a contiguous block of keystrokes.
325+
for (let i = 0; i < chars.length; i++) {
326+
await this.makeRequest(
327+
'POST',
328+
`/session/${this.sessionId}/wda/keys`,
329+
{
330+
value: [chars[i]],
331+
},
332+
);
333+
if (i < chars.length - 1) {
334+
await sleep(delayMs);
335+
}
336+
}
337+
} else {
338+
// Use WebDriverAgent's keys endpoint with array value
339+
await this.makeRequest('POST', `/session/${this.sessionId}/wda/keys`, {
340+
value: chars, // Must be an array of characters
341+
});
342+
}
343+
debugIOS(`Typed text: "${text}" (delayMs=${delayMs})`);
318344
} catch (error) {
319345
debugIOS(`Failed to type text "${text}": ${error}`);
320346
throw new Error(`Failed to type text: ${error}`);

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

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,43 @@ describe('IOSDevice', () => {
236236
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(1, 10, 20);
237237
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(2, 30, 40);
238238
expect(mockWdaClient.clearActiveElement).toHaveBeenCalledTimes(2);
239-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(1, 'from action');
240-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(2, 'from pointer');
239+
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(1, 'from action', {
240+
delayMs: 80,
241+
});
242+
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(
243+
2,
244+
'from pointer',
245+
{ delayMs: 80 },
246+
);
247+
});
248+
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);
254+
255+
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('per call', {
256+
delayMs: 25,
257+
});
258+
});
259+
260+
it('falls back to device-level keyboardTypeDelay when the call omits it', async () => {
261+
const deviceWithDelay = new IOSDevice({
262+
wdaPort: DEFAULT_WDA_PORT,
263+
wdaHost: 'localhost',
264+
keyboardTypeDelay: 0,
265+
});
266+
267+
await deviceWithDelay.inputPrimitives.keyboard.typeText('default delay', {
268+
autoDismissKeyboard: false,
269+
} as any);
270+
271+
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('default delay', {
272+
delayMs: 0,
273+
});
274+
275+
await deviceWithDelay.destroy();
241276
});
242277
});
243278

@@ -383,8 +418,10 @@ describe('IOSDevice', () => {
383418
it('should type text', async () => {
384419
await device.connect();
385420

386-
await device.typeText('Hello World');
387-
expect(mockWdaClient.typeText).toHaveBeenCalledWith('Hello World');
421+
await (device as any).typeText('Hello World');
422+
expect(mockWdaClient.typeText).toHaveBeenCalledWith('Hello World', {
423+
delayMs: 80,
424+
});
388425
});
389426

390427
it('should press home button', async () => {
@@ -521,7 +558,9 @@ describe('IOSDevice', () => {
521558
.fn()
522559
.mockRejectedValue(new Error('Type text failed'));
523560

524-
await expect(device.typeText('test')).rejects.toThrow('Type text failed');
561+
await expect((device as any).typeText('test')).rejects.toThrow(
562+
'Type text failed',
563+
);
525564
});
526565
});
527566

@@ -584,10 +623,12 @@ describe('IOSDevice', () => {
584623
});
585624

586625
await deviceWithAutoDismiss.connect();
587-
await deviceWithAutoDismiss.typeText('test text');
626+
await (deviceWithAutoDismiss as any).typeText('test text');
588627

589628
// Should call typeText and swipe (for keyboard dismiss)
590-
expect(mockBackend.typeText).toHaveBeenCalledWith('test text');
629+
expect(mockBackend.typeText).toHaveBeenCalledWith('test text', {
630+
delayMs: 80,
631+
});
591632
expect(mockBackend.swipe).toHaveBeenCalled();
592633
});
593634
});

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

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,84 @@
11
import { DEFAULT_WDA_PORT } from '@midscene/shared/constants';
2-
import { describe, expect, it } from 'vitest';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { IOSWebDriverClient } from '../../src/ios-webdriver-client';
4+
5+
function createClientWithSession() {
6+
const client = new IOSWebDriverClient({
7+
port: DEFAULT_WDA_PORT,
8+
host: 'localhost',
9+
});
10+
// Bypass createSession() — we only want to observe outbound HTTP calls.
11+
(client as any).sessionId = 'session-under-test';
12+
const makeRequest = vi
13+
.spyOn(client as any, 'makeRequest')
14+
.mockResolvedValue(undefined);
15+
return { client, makeRequest };
16+
}
17+
18+
describe('IOSWebDriverClient.typeText delivery modes', () => {
19+
it('sends the whole string in one /wda/keys request when delayMs is 0 (default)', async () => {
20+
const { client, makeRequest } = createClientWithSession();
21+
22+
await client.typeText('Al is amazing');
23+
24+
expect(makeRequest).toHaveBeenCalledTimes(1);
25+
expect(makeRequest).toHaveBeenCalledWith(
26+
'POST',
27+
'/session/session-under-test/wda/keys',
28+
{
29+
value: [
30+
'A',
31+
'l',
32+
' ',
33+
'i',
34+
's',
35+
' ',
36+
'a',
37+
'm',
38+
'a',
39+
'z',
40+
'i',
41+
'n',
42+
'g',
43+
],
44+
},
45+
);
46+
});
47+
48+
it('emits one /wda/keys request per character when delayMs > 0', async () => {
49+
const { client, makeRequest } = createClientWithSession();
50+
51+
await client.typeText('Hi!', { delayMs: 1 });
52+
53+
expect(makeRequest).toHaveBeenCalledTimes(3);
54+
expect(makeRequest).toHaveBeenNthCalledWith(
55+
1,
56+
'POST',
57+
'/session/session-under-test/wda/keys',
58+
{ value: ['H'] },
59+
);
60+
expect(makeRequest).toHaveBeenNthCalledWith(
61+
2,
62+
'POST',
63+
'/session/session-under-test/wda/keys',
64+
{ value: ['i'] },
65+
);
66+
expect(makeRequest).toHaveBeenNthCalledWith(
67+
3,
68+
'POST',
69+
'/session/session-under-test/wda/keys',
70+
{ value: ['!'] },
71+
);
72+
});
73+
74+
it('trims surrounding whitespace and skips empty input', async () => {
75+
const { client, makeRequest } = createClientWithSession();
76+
77+
await client.typeText(' ');
78+
79+
expect(makeRequest).not.toHaveBeenCalled();
80+
});
81+
});
382

483
describe('IOSWebDriverClient - Simple Tests', () => {
584
describe('Module Structure', () => {

0 commit comments

Comments
 (0)