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..0af37d2f0e --- /dev/null +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-input-text-pacing.test.ts @@ -0,0 +1,69 @@ +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'; + +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' } }), + 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, + generation: 0, + env: {}, + invalidateObservation() {}, + }); + const request = requests.find((candidate) => candidate.command === 'type'); + 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', 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', INPUT_TEXT); + 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..b8e9b0bae4 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port.ts @@ -127,12 +127,20 @@ 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 }, context); + await invokeMutation( + { kind: 'typeText', text, ...(delayMs === undefined ? {} : { delayMs }) }, + context, + ); const stable = await waitForTypedSnapshotStability({ timeoutMs: MAESTRO_RUNTIME_ADAPTER_POLICY.settleTimeoutMs, context, @@ -262,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( 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..7ce055c59f 100644 --- a/packages/maestro/src/internal/compatibility-policy.ts +++ b/packages/maestro/src/internal/compatibility-policy.ts @@ -25,6 +25,7 @@ export const MAESTRO_COMPATIBILITY_PRESETS = { scrollUntilVisibleSpeed: 40, scrollUntilVisiblePercentage: 100, eraseTextMaxCharacters: 50, + 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(