From f37c0ed16f59211c5904901c1c4629b8aecd7966 Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 16 Oct 2026 10:52:07 -0400 Subject: [PATCH 1/3] fix(maestro): type inputText with a 75 ms per-character delay on Android Maestro 2.5.1 types inputText one character at a time with a 75 ms sleep. The daemon port sent `type` with no delay, so Android's IME path committed the whole string at once and debounced handlers fired once. Own the delay in the Maestro compatibility policy and pass it from the runtime port on Android only. iOS is unchanged. Fixes #2159 --- ...mon-runtime-port-input-text-pacing.test.ts | 51 +++++++++++++++++++ .../daemon-runtime-public-operation.test.ts | 4 ++ .../src/daemon-port/daemon-runtime-port.ts | 11 +++- .../daemon-runtime-public-operation.ts | 8 ++- .../src/internal/compatibility-policy.ts | 2 + .../src/internal/facade-runtime-port.ts | 2 + .../android-test-suite.test.ts | 4 +- 7 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts new file mode 100644 index 0000000000..51986c3590 --- /dev/null +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; +import type { MaestroDaemonOperationRequest } from '../daemon-runtime-public-operation.ts'; +import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; +import { makeRuntimeEnvelope, makeDependencies } from './daemon-runtime-port-fixtures.ts'; + +async function typeRequestFor(platform: 'android' | 'ios'): Promise { + const requests: MaestroDaemonOperationRequest[] = []; + const port = createDaemonMaestroRuntimePort({ + ...makeRuntimeEnvelope({ flags: { platform, replayBackend: 'maestro' } }), + invoke: async (request) => { + requests.push(request); + return request.command === 'snapshot' + ? { + ok: true, + data: { + createdAt: requests.length, + nodes: [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 400, height: 800 } }, + ], + }, + } + : { ok: true, data: {} }; + }, + dependencies: makeDependencies(), + platform, + }); + await port.execute({ + command: { kind: 'inputText', source: { line: 2 }, text: 'hello' }, + generation: 0, + env: {}, + invalidateObservation() {}, + }); + const request = requests.find((candidate) => candidate.command === 'type'); + if (!request) throw new Error('inputText did not reach the type command'); + return request; +} + +const MAESTRO_2_5_1_ANDROID_INPUT_TEXT_DELAY_MS = 75; + +describe('Maestro inputText pacing', () => { + test('Android types with the pinned Maestro per-character delay', async () => { + const request = await typeRequestFor('android'); + expect(request.positionals).toEqual(['hello']); + expect(request.flags?.delayMs).toBe(MAESTRO_2_5_1_ANDROID_INPUT_TEXT_DELAY_MS); + }); + + test('iOS types without a delay', async () => { + const request = await typeRequestFor('ios'); + expect(request.flags).not.toHaveProperty('delayMs'); + }); +}); diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts index 1f45660a6a..15ec97c117 100644 --- a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts @@ -94,6 +94,10 @@ describe('Maestro public operation projection', () => { operation: { kind: 'typeText', text: 'hello' }, expected: { command: 'type', positionals: ['hello'] }, }, + { + operation: { kind: 'typeText', text: 'hello', delayMs: 75 }, + expected: { command: 'type', positionals: ['hello'], flags: { delayMs: 75 } }, + }, { operation: { kind: 'gestureViewport' }, expected: { command: 'runtime', positionals: ['gesture-viewport'] }, diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port.ts b/packages/maestro/src/daemon-port/daemon-runtime-port.ts index 65788203ff..62ecf79bad 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port.ts @@ -132,7 +132,16 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper context: MaestroRuntimeOperationContext, ): Promise => { registerDiagnosticSensitiveValue(text); - await invokeMutation({ kind: 'typeText', text }, context); + await invokeMutation( + { + kind: 'typeText', + text, + ...(platform === 'android' + ? { delayMs: MAESTRO_RUNTIME_ADAPTER_POLICY.androidInputTextCharacterDelayMs } + : {}), + }, + context, + ); const stable = await waitForTypedSnapshotStability({ timeoutMs: MAESTRO_RUNTIME_ADAPTER_POLICY.settleTimeoutMs, context, diff --git a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts index 579e697fab..ea37d33abc 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts @@ -31,7 +31,7 @@ export type MaestroPublicOperation = mode?: 'full' | 'limited'; } | { kind: 'openLink'; appId?: string; link: string; prewarmRunner: boolean } - | { kind: 'typeText'; text: string } + | { kind: 'typeText'; text: string; delayMs?: number } | { kind: 'clickSelector'; selector: MaestroDispatchSelector; @@ -288,7 +288,11 @@ function projectInputOperation(operation: MaestroInputOperation): MaestroDaemonO case 'gestureViewport': return { command: 'runtime', positionals: ['gesture-viewport'] }; case 'typeText': - return { command: 'type', positionals: [operation.text] }; + return { + command: 'type', + positionals: [operation.text], + ...(operation.delayMs === undefined ? {} : { flags: { delayMs: operation.delayMs } }), + }; case 'clickSelector': return projectSelectorClick(operation); case 'clickPoint': diff --git a/packages/maestro/src/internal/compatibility-policy.ts b/packages/maestro/src/internal/compatibility-policy.ts index be77e9c8cd..88db82ef7c 100644 --- a/packages/maestro/src/internal/compatibility-policy.ts +++ b/packages/maestro/src/internal/compatibility-policy.ts @@ -25,6 +25,8 @@ export const MAESTRO_COMPATIBILITY_PRESETS = { scrollUntilVisibleSpeed: 40, scrollUntilVisiblePercentage: 100, eraseTextMaxCharacters: 50, + // MaestroDriverService.kt types inputText one character at a time on Android. + androidInputTextCharacterDelayMs: 75, }, // ScreenshotUtils.waitForAppToSettle and MAX_TIMEOUT_WAIT_TO_SETTLE_MS. observation: { diff --git a/packages/maestro/src/internal/facade-runtime-port.ts b/packages/maestro/src/internal/facade-runtime-port.ts index 1aa7381889..427f4789eb 100644 --- a/packages/maestro/src/internal/facade-runtime-port.ts +++ b/packages/maestro/src/internal/facade-runtime-port.ts @@ -48,6 +48,8 @@ export const MAESTRO_RUNTIME_ADAPTER_POLICY = { retryTapMaxAttempts: MAESTRO_COMPATIBILITY_PRESETS.command.retryTapMaxAttempts, longPressDurationMs: MAESTRO_COMPATIBILITY_PRESETS.command.longPressDurationMs, eraseTextMaxCharacters: MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, + androidInputTextCharacterDelayMs: + MAESTRO_COMPATIBILITY_PRESETS.command.androidInputTextCharacterDelayMs, scrollUntilVisiblePercentage: MAESTRO_COMPATIBILITY_PRESETS.command.scrollUntilVisiblePercentage, animationWaitTimeoutMs: MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, animationDifferencePercent: diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 53b5c2d84a..b0991f0982 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -247,7 +247,7 @@ test( { action: 'type', text: 'Łódź café', - delayMs: 0, + delayMs: 75, }, ]); assert.deepEqual( @@ -313,7 +313,7 @@ test( { action: 'type', text: 'Łódź café', - delayMs: 0, + delayMs: 75, }, ]); assert.equal( From 4331a7ff18b09dbff38637e2806abc6cfa30786a Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 16 Oct 2026 11:20:28 -0400 Subject: [PATCH 2/3] chore(maestro): drop redundant comment --- packages/maestro/src/internal/compatibility-policy.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/maestro/src/internal/compatibility-policy.ts b/packages/maestro/src/internal/compatibility-policy.ts index 88db82ef7c..7ce055c59f 100644 --- a/packages/maestro/src/internal/compatibility-policy.ts +++ b/packages/maestro/src/internal/compatibility-policy.ts @@ -25,7 +25,6 @@ export const MAESTRO_COMPATIBILITY_PRESETS = { scrollUntilVisibleSpeed: 40, scrollUntilVisiblePercentage: 100, eraseTextMaxCharacters: 50, - // MaestroDriverService.kt types inputText one character at a time on Android. androidInputTextCharacterDelayMs: 75, }, // ScreenshotUtils.waitForAppToSettle and MAX_TIMEOUT_WAIT_TO_SETTLE_MS. From 327c9e2291e099fa45c6816b828148ea3e17a9ba Mon Sep 17 00:00:00 2001 From: Adam Rowe Date: Fri, 16 Oct 2026 11:57:19 -0400 Subject: [PATCH 3/3] fix(maestro): keep Android eraseText free of the inputText delay eraseText sends its backspaces through the same type helper as inputText, so it inherited the 75 ms per-character delay and a default 50-character erase gained about 3.75 s. Pass the delay only from the inputText path. --- ...mon-runtime-port-input-text-pacing.test.ts | 28 +++++++++++++++---- .../src/daemon-port/daemon-runtime-port.ts | 16 +++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts index 51986c3590..0af37d2f0e 100644 --- a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts @@ -3,7 +3,14 @@ import type { MaestroDaemonOperationRequest } from '../daemon-runtime-public-ope import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { makeRuntimeEnvelope, makeDependencies } from './daemon-runtime-port-fixtures.ts'; -async function typeRequestFor(platform: 'android' | 'ios'): Promise { +type TextCommand = + | { kind: 'inputText'; source: { line: number }; text: string } + | { kind: 'eraseText'; source: { line: number }; charactersToErase?: number }; + +async function typeRequestFor( + platform: 'android' | 'ios', + command: TextCommand, +): Promise { const requests: MaestroDaemonOperationRequest[] = []; const port = createDaemonMaestroRuntimePort({ ...makeRuntimeEnvelope({ flags: { platform, replayBackend: 'maestro' } }), @@ -25,27 +32,38 @@ async function typeRequestFor(platform: 'android' | 'ios'): Promise candidate.command === 'type'); - if (!request) throw new Error('inputText did not reach the type command'); + if (!request) throw new Error(`${command.kind} did not reach the type command`); return request; } const MAESTRO_2_5_1_ANDROID_INPUT_TEXT_DELAY_MS = 75; +const INPUT_TEXT: TextCommand = { kind: 'inputText', source: { line: 2 }, text: 'hello' }; +const ERASE_TEXT: TextCommand = { kind: 'eraseText', source: { line: 2 }, charactersToErase: 3 }; describe('Maestro inputText pacing', () => { test('Android types with the pinned Maestro per-character delay', async () => { - const request = await typeRequestFor('android'); + const request = await typeRequestFor('android', INPUT_TEXT); expect(request.positionals).toEqual(['hello']); expect(request.flags?.delayMs).toBe(MAESTRO_2_5_1_ANDROID_INPUT_TEXT_DELAY_MS); }); + test.each(['android', 'ios'] as const)( + '%s eraseText sends backspaces without a delay', + async (platform) => { + const request = await typeRequestFor(platform, ERASE_TEXT); + expect(request.positionals).toEqual(['\b\b\b']); + expect(request.flags).not.toHaveProperty('delayMs'); + }, + ); + test('iOS types without a delay', async () => { - const request = await typeRequestFor('ios'); + const request = await typeRequestFor('ios', INPUT_TEXT); expect(request.flags).not.toHaveProperty('delayMs'); }); }); diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port.ts b/packages/maestro/src/daemon-port/daemon-runtime-port.ts index 62ecf79bad..b8e9b0bae4 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port.ts @@ -127,19 +127,18 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper applied.push(describePermissionMutation(mutation)); } }; + const inputTextDelayMs = + platform === 'android' + ? MAESTRO_RUNTIME_ADAPTER_POLICY.androidInputTextCharacterDelayMs + : undefined; const typeTextAndSettle = async ( text: string, context: MaestroRuntimeOperationContext, + delayMs?: number, ): Promise => { registerDiagnosticSensitiveValue(text); await invokeMutation( - { - kind: 'typeText', - text, - ...(platform === 'android' - ? { delayMs: MAESTRO_RUNTIME_ADAPTER_POLICY.androidInputTextCharacterDelayMs } - : {}), - }, + { kind: 'typeText', text, ...(delayMs === undefined ? {} : { delayMs }) }, context, ); const stable = await waitForTypedSnapshotStability({ @@ -271,7 +270,8 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper ); return data ? { data } : undefined; }, - inputText: async (input, context) => await typeTextAndSettle(input.text, context), + inputText: async (input, context) => + await typeTextAndSettle(input.text, context, inputTextDelayMs), eraseText: async (input, context) => await typeTextAndSettle( '\b'.repeat(