From b46dfbb2cf80acdd08cdae8ed9785e429645c24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 13:22:27 +0200 Subject: [PATCH 1/2] refactor: converge Maestro input handling --- .../maestro/__tests__/replay-flow.test.ts | 38 ++++++++++--- src/compat/maestro/replay-flow.ts | 10 ++-- .../__tests__/session-replay-vars.test.ts | 11 ++-- .../android-test-suite.test.ts | 56 +++++++++++++++++++ 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index 7542d487e..a1b53418a 100644 --- a/src/compat/maestro/__tests__/replay-flow.test.ts +++ b/src/compat/maestro/__tests__/replay-flow.test.ts @@ -223,7 +223,7 @@ test('parseMaestroReplayFlow keeps focused inputText and pressKey Enter as separ assert.deepEqual(parsed.actionLines, [3, 4, 5]); }); -test('parseMaestroReplayFlow marks tapOn before inputText for snapshot tap focus', () => { +test('parseMaestroReplayFlow coalesces tapOn inputText through native fill', () => { const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab --- - tapOn: @@ -234,11 +234,12 @@ test('parseMaestroReplayFlow marks tapOn before inputText for snapshot tap focus assert.deepEqual( parsed.actions.map((entry) => [entry.command, entry.positionals]), [ - ['__maestroTapOn', ['id="editableNameInput"']], - ['type', ['Saved list']], + ['wait', ['id="editableNameInput"', '30000']], + ['fill', ['id="editableNameInput"', 'Saved list']], ], ); - assert.equal(parsed.actions[0]?.flags?.maestro?.allowNonHittableCoordinateFallback, undefined); + assert.deepEqual(parsed.actionLines, [3, 3]); + assert.equal(parsed.actions[1]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); }); test('parseMaestroReplayFlow coalesces tapOn inputText while preserving pressKey Enter submit', () => { @@ -281,6 +282,27 @@ test('parseMaestroReplayFlow does not coalesce text entry for non-input-looking assert.equal(parsed.actions[0]?.flags?.maestro?.allowNonHittableCoordinateFallback, undefined); }); +test('parseMaestroReplayFlow maps focused input commands to native type and keyboard actions', () => { + const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- inputText: hello +- eraseText: + charactersToErase: 3 +- pasteText: pasted +- pressKey: Return +`); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [ + ['type', ['hello']], + ['type', ['\b'.repeat(3)]], + ['type', ['pasted']], + ['__maestroPressEnter', []], + ], + ); +}); + test('parseMaestroReplayFlow rejects relative runScript paths without source path', () => { assert.throws( () => @@ -658,10 +680,10 @@ test('parseMaestroReplayFlow parses the test-app Maestro suite fixture', () => { '__maestroAssertVisible', '__maestroTapOn', '__maestroAssertVisible', - '__maestroTapOn', - 'type', - '__maestroTapOn', - 'type', + 'wait', + 'fill', + 'wait', + 'fill', '__maestroTapOn', '__maestroAssertVisible', '__maestroAssertVisible', diff --git a/src/compat/maestro/replay-flow.ts b/src/compat/maestro/replay-flow.ts index 60211a7e4..4172e5672 100644 --- a/src/compat/maestro/replay-flow.ts +++ b/src/compat/maestro/replay-flow.ts @@ -114,9 +114,7 @@ function optimizeTypedAfterTap( return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; } const pressEnterAction = actions[index + 2]; - if (pressEnterAction?.command !== MAESTRO_RUNTIME_COMMAND.pressEnter) { - return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; - } + const shouldKeepEnter = pressEnterAction?.command === MAESTRO_RUNTIME_COMMAND.pressEnter; return { actions: [ { @@ -130,10 +128,10 @@ function optimizeTypedAfterTap( positionals: [tapSelector, typedAfterTap], flags: action.flags, }, - pressEnterAction, + ...(shouldKeepEnter ? [pressEnterAction] : []), ], - actionLines: [line, line, actionLines[index + 2] ?? line], - consumed: 3, + actionLines: [line, line, ...(shouldKeepEnter ? [actionLines[index + 2] ?? line] : [])], + consumed: shouldKeepEnter ? 3 : 2, }; } diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index cb4eaa557..1e45c5ddb 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -27,7 +27,7 @@ const LOC = { file: 'test.ad', line: 1 }; type CapturedInvocation = { command: string; positionals?: string[]; - flags?: Record; + flags?: CommandFlags; }; async function runReplayFixture(params: { @@ -1247,7 +1247,7 @@ test('runReplayScriptFile lets snapshot id tap handle Maestro one-point edge con ); }); -test('runReplayScriptFile keeps Maestro text-entry tapOn on the snapshot path', async () => { +test('runReplayScriptFile coalesces Maestro text-entry tapOn into native fill', async () => { const calls: CapturedInvocation[] = []; const { response } = await runReplayFixture({ label: 'maestro-tap-input-text-snapshot', @@ -1284,12 +1284,11 @@ test('runReplayScriptFile keeps Maestro text-entry tapOn on the snapshot path', assert.deepEqual( calls.map((call) => [call.command, call.positionals]), [ - ['snapshot', []], - ['click', ['120', '120']], - ['type', ['Saved list']], + ['wait', ['id="editableNameInput"', '30000']], + ['fill', ['id="editableNameInput"', 'Saved list']], ], ); - assert.equal(calls[0]?.flags?.noRecord, true); + assert.equal(calls[1]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); }); test('runReplayScriptFile resolves Maestro swipe.label from a labeled element rect', async () => { diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 45be5e6e0..7aedc4bf6 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -126,6 +126,62 @@ test('Provider-backed integration Android Maestro replay uses fresh selector sna ); }); +test('Provider-backed integration Android Maestro coalesces tapOn inputText and pressKey Enter through native paths', async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'input-submit.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- tapOn: Search', + '- inputText: "Łódź café"', + '- pressKey: Enter', + '', + ].join('\n'), + ); + + const suite = await client.replay.test({ + paths: [flowPath], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); + + assert.equal(suite.total, 1, JSON.stringify(suite)); + assert.equal(suite.passed, 1, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual(world.textInjectionCalls, [ + { + action: 'fill', + target: { x: 195, y: 52 }, + text: 'Łódź café', + delayMs: 0, + }, + ]); + assert.equal( + world.adbCalls.some( + (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text', + ), + false, + JSON.stringify(world.adbCalls), + ); + assert.deepEqual( + world.adbCalls.find((call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER'), + ['shell', 'input', 'keyevent', 'ENTER'], + ); + world.assertNoHostAdbCalls(); + }, + ); +}); + function androidMaestroReplayXml(searchBounds: string): string { return [ '', From 70b33f2f9f100ef85825dc804a7fb34ae81d8b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 13:59:58 +0200 Subject: [PATCH 2/2] fix: preserve Maestro optional input semantics --- .../maestro/__tests__/replay-flow.test.ts | 41 ++++++++++---- src/compat/maestro/replay-flow.ts | 47 ++++++++++++---- .../__tests__/session-replay-vars.test.ts | 2 + .../android-test-suite.test.ts | 56 ------------------- 4 files changed, 69 insertions(+), 77 deletions(-) diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index a1b53418a..ad55885c5 100644 --- a/src/compat/maestro/__tests__/replay-flow.test.ts +++ b/src/compat/maestro/__tests__/replay-flow.test.ts @@ -90,7 +90,7 @@ env: assert.equal(parsed.actions[3]?.flags.intervalMs, 150); assert.equal(parsed.actions[4]?.flags.holdMs, 3000); assert.equal(parsed.actions[1]?.flags.maestro?.allowNonHittableCoordinateFallback, true); - assert.equal(parsed.actions[6]?.flags?.maestro?.allowNonHittableCoordinateFallback, undefined); + assert.equal(parsed.actions[6]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); }); test('parseMaestroReplayFlow maps iOS openLink through the app id when available', () => { @@ -223,7 +223,7 @@ test('parseMaestroReplayFlow keeps focused inputText and pressKey Enter as separ assert.deepEqual(parsed.actionLines, [3, 4, 5]); }); -test('parseMaestroReplayFlow coalesces tapOn inputText through native fill', () => { +test('parseMaestroReplayFlow keeps tapOn inputText without Enter on Maestro path', () => { const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab --- - tapOn: @@ -234,12 +234,33 @@ test('parseMaestroReplayFlow coalesces tapOn inputText through native fill', () assert.deepEqual( parsed.actions.map((entry) => [entry.command, entry.positionals]), [ - ['wait', ['id="editableNameInput"', '30000']], - ['fill', ['id="editableNameInput"', 'Saved list']], + ['__maestroTapOn', ['id="editableNameInput"']], + ['type', ['Saved list']], ], ); - assert.deepEqual(parsed.actionLines, [3, 3]); - assert.equal(parsed.actions[1]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); + assert.deepEqual(parsed.actionLines, [3, 5]); + assert.equal(parsed.actions[0]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); +}); + +test('parseMaestroReplayFlow preserves optional tapOn before inputText without Enter', () => { + const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- tapOn: + id: editableNameInput + optional: true +- inputText: Saved list +`); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [ + ['__maestroTapOn', ['id="editableNameInput"']], + ['type', ['Saved list']], + ], + ); + assert.deepEqual(parsed.actionLines, [3, 6]); + assert.equal(parsed.actions[0]?.flags?.maestro?.optional, true); + assert.equal(parsed.actions[0]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); }); test('parseMaestroReplayFlow coalesces tapOn inputText while preserving pressKey Enter submit', () => { @@ -680,10 +701,10 @@ test('parseMaestroReplayFlow parses the test-app Maestro suite fixture', () => { '__maestroAssertVisible', '__maestroTapOn', '__maestroAssertVisible', - 'wait', - 'fill', - 'wait', - 'fill', + '__maestroTapOn', + 'type', + '__maestroTapOn', + 'type', '__maestroTapOn', '__maestroAssertVisible', '__maestroAssertVisible', diff --git a/src/compat/maestro/replay-flow.ts b/src/compat/maestro/replay-flow.ts index 4172e5672..05aa9f6a8 100644 --- a/src/compat/maestro/replay-flow.ts +++ b/src/compat/maestro/replay-flow.ts @@ -104,17 +104,12 @@ function optimizeTypedAfterTap( actionLines: number[], index: number, ): { actions: SessionAction[]; actionLines: number[]; consumed: number } | null { - const action = actions[index]!; - const nextAction = actions[index + 1]; - const typedAfterTap = readPlainTypeText(nextAction); - const tapSelector = readPlainMaestroTapSelector(action); - if (!nextAction || typedAfterTap === null || tapSelector === null) return null; - const line = actionLines[index] ?? 1; + const candidate = readTypedAfterTapCandidate(actions, actionLines, index); + if (!candidate) return null; + const { action, nextAction, pressEnterAction, tapSelector, typedAfterTap, line } = candidate; if (!isLikelyTextEntrySelector(tapSelector)) { return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; } - const pressEnterAction = actions[index + 2]; - const shouldKeepEnter = pressEnterAction?.command === MAESTRO_RUNTIME_COMMAND.pressEnter; return { actions: [ { @@ -128,10 +123,40 @@ function optimizeTypedAfterTap( positionals: [tapSelector, typedAfterTap], flags: action.flags, }, - ...(shouldKeepEnter ? [pressEnterAction] : []), + pressEnterAction, ], - actionLines: [line, line, ...(shouldKeepEnter ? [actionLines[index + 2] ?? line] : [])], - consumed: shouldKeepEnter ? 3 : 2, + actionLines: [line, line, actionLines[index + 2] ?? line], + consumed: 3, + }; +} + +function readTypedAfterTapCandidate( + actions: SessionAction[], + actionLines: number[], + index: number, +): { + action: SessionAction; + nextAction: SessionAction; + pressEnterAction: SessionAction; + tapSelector: string; + typedAfterTap: string; + line: number; +} | null { + const action = actions[index]!; + const nextAction = actions[index + 1]; + const pressEnterAction = actions[index + 2]; + if (pressEnterAction?.command !== MAESTRO_RUNTIME_COMMAND.pressEnter) return null; + if (action.flags?.maestro?.optional === true) return null; + const typedAfterTap = readPlainTypeText(nextAction); + const tapSelector = readPlainMaestroTapSelector(action); + if (!nextAction || typedAfterTap === null || tapSelector === null) return null; + return { + action, + nextAction, + pressEnterAction, + tapSelector, + typedAfterTap, + line: actionLines[index] ?? 1, }; } diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 1e45c5ddb..2ca387c51 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -1257,6 +1257,7 @@ test('runReplayScriptFile coalesces Maestro text-entry tapOn into native fill', '- tapOn:', ' id: editableNameInput', '- inputText: Saved list', + '- pressKey: Enter', '', ].join('\n'), flags: { replayBackend: 'maestro' }, @@ -1286,6 +1287,7 @@ test('runReplayScriptFile coalesces Maestro text-entry tapOn into native fill', [ ['wait', ['id="editableNameInput"', '30000']], ['fill', ['id="editableNameInput"', 'Saved list']], + ['keyboard', ['enter']], ], ); assert.equal(calls[1]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 7aedc4bf6..45be5e6e0 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -126,62 +126,6 @@ test('Provider-backed integration Android Maestro replay uses fresh selector sna ); }); -test('Provider-backed integration Android Maestro coalesces tapOn inputText and pressKey Enter through native paths', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), - async (world) => { - const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input'); - fs.mkdirSync(suiteRoot, { recursive: true }); - const flowPath = path.join(suiteRoot, 'input-submit.yaml'); - fs.writeFileSync( - flowPath, - [ - 'appId: com.android.settings', - '---', - '- launchApp', - '- tapOn: Search', - '- inputText: "Łódź café"', - '- pressKey: Enter', - '', - ].join('\n'), - ); - - const suite = await client.replay.test({ - paths: [flowPath], - backend: 'maestro', - artifactsDir: path.join(suiteRoot, 'artifacts'), - timeoutMs: 30000, - ...world.selection, - }); - - assert.equal(suite.total, 1, JSON.stringify(suite)); - assert.equal(suite.passed, 1, JSON.stringify(suite)); - assert.equal(suite.failed, 0, JSON.stringify(suite)); - assert.deepEqual(world.textInjectionCalls, [ - { - action: 'fill', - target: { x: 195, y: 52 }, - text: 'Łódź café', - delayMs: 0, - }, - ]); - assert.equal( - world.adbCalls.some( - (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text', - ), - false, - JSON.stringify(world.adbCalls), - ); - assert.deepEqual( - world.adbCalls.find((call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER'), - ['shell', 'input', 'keyevent', 'ENTER'], - ); - world.assertNoHostAdbCalls(); - }, - ); -}); - function androidMaestroReplayXml(searchBounds: string): string { return [ '',