Skip to content

Commit 08f87a0

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 ad9088d commit 08f87a0

6 files changed

Lines changed: 173 additions & 15 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
@@ -87,6 +87,7 @@ describe('Device Options Type Definitions', () => {
8787
test('IOSDeviceInputOpt should include keyboard options', () => {
8888
const inputOptions: IOSDeviceInputOpt = {
8989
autoDismissKeyboard: true,
90+
keyboardTypeDelay: 80,
9091
};
9192

9293
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: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,43 @@ describe('IOSDevice', () => {
248248
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(1, 10, 20);
249249
expect(mockWdaClient.tap).toHaveBeenNthCalledWith(2, 30, 40);
250250
expect(mockWdaClient.clearActiveElement).toHaveBeenCalledTimes(2);
251-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(1, 'from action');
252-
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(2, 'from pointer');
251+
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(1, 'from action', {
252+
delayMs: 80,
253+
});
254+
expect(mockWdaClient.typeText).toHaveBeenNthCalledWith(
255+
2,
256+
'from pointer',
257+
{ delayMs: 80 },
258+
);
259+
});
260+
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);
266+
267+
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('per call', {
268+
delayMs: 25,
269+
});
270+
});
271+
272+
it('falls back to device-level keyboardTypeDelay when the call omits it', async () => {
273+
const deviceWithDelay = new IOSDevice({
274+
wdaPort: DEFAULT_WDA_PORT,
275+
wdaHost: 'localhost',
276+
keyboardTypeDelay: 0,
277+
});
278+
279+
await deviceWithDelay.inputPrimitives.keyboard.typeText('default delay', {
280+
autoDismissKeyboard: false,
281+
} as any);
282+
283+
expect(mockWdaClient.typeText).toHaveBeenLastCalledWith('default delay', {
284+
delayMs: 0,
285+
});
286+
287+
await deviceWithDelay.destroy();
253288
});
254289
});
255290

@@ -396,7 +431,9 @@ describe('IOSDevice', () => {
396431
await device.connect();
397432

398433
await getInternalTextInput(device).typeText('Hello World');
399-
expect(mockWdaClient.typeText).toHaveBeenCalledWith('Hello World');
434+
expect(mockWdaClient.typeText).toHaveBeenCalledWith('Hello World', {
435+
delayMs: 80,
436+
});
400437
});
401438

402439
it('should press home button', async () => {
@@ -601,7 +638,9 @@ describe('IOSDevice', () => {
601638
await getInternalTextInput(deviceWithAutoDismiss).typeText('test text');
602639

603640
// Should call typeText and swipe (for keyboard dismiss)
604-
expect(mockBackend.typeText).toHaveBeenCalledWith('test text');
641+
expect(mockBackend.typeText).toHaveBeenCalledWith('test text', {
642+
delayMs: 80,
643+
});
605644
expect(mockBackend.swipe).toHaveBeenCalled();
606645
});
607646
});

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

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,86 @@
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';
34
import type { IOSWebDriverClient as IOSWebDriverClientType } from '../../src/ios-webdriver-client';
45

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

0 commit comments

Comments
 (0)