From a14a01336942e00afe5c687a089137222659e33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 13:07:20 +0200 Subject: [PATCH 1/3] test: add maestro provider integration guards --- .../android-test-suite.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 45be5e6e0..a067b0d43 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -126,6 +126,201 @@ test('Provider-backed integration Android Maestro replay uses fresh selector sna ); }); +test('Provider-backed integration Android Maestro replay test suite discovers YAML flows in directories', async () => { + let snapshots = 0; + await withProviderScenarioResource( + async () => + await createAndroidSettingsWorld({ + snapshotXml: () => { + snapshots += 1; + return androidMaestroReplayXml('[100,300][260,360]'); + }, + }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-directory'); + fs.mkdirSync(suiteRoot, { recursive: true }); + fs.writeFileSync( + path.join(suiteRoot, '01-visible.yaml'), + ['appId: com.android.settings', '---', '- launchApp', '- assertVisible: Apps', ''].join( + '\n', + ), + ); + fs.writeFileSync( + path.join(suiteRoot, '02-tap.yml'), + ['appId: com.android.settings', '---', '- tapOn: Search', ''].join('\n'), + ); + + const suite = await client.replay.test({ + paths: [suiteRoot], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); + + assert.equal(suite.total, 2, JSON.stringify(suite)); + assert.equal(suite.executed, 2, JSON.stringify(suite)); + assert.equal(suite.passed, 2, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual( + world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + ['shell', 'input', 'tap', '180', '330'], + ); + assert.equal(snapshots >= 2, true); + }, + ); +}); + +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(); + }, + ); +}); + +test('Provider-backed integration Android Maestro executes runFlow conditions and retry batches at runtime', async () => { + let snapshots = 0; + await withProviderScenarioResource( + async () => + await createAndroidSettingsWorld({ + snapshotXml: () => { + snapshots += 1; + return androidMaestroReplayXml('[100,300][260,360]'); + }, + }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-runtime-flow'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'runtime-flow.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- runFlow:', + ' when:', + ' visible: Apps', + ' commands:', + ' - tapOn: Search', + '- retry:', + ' maxRetries: 1', + ' commands:', + ' - assertVisible: Apps', + '', + ].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.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + ['shell', 'input', 'tap', '180', '330'], + ); + assert.equal(snapshots >= 3, true); + }, + ); +}); + +test('Provider-backed integration Android Maestro optional tap misses without touching the device', async () => { + await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-optional'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'optional-miss.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- tapOn:', + ' text: Missing target', + ' optional: true', + '- assertVisible: Apps', + '', + ].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.equal( + world.adbCalls.some((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + false, + JSON.stringify(world.adbCalls), + ); + }); +}); + function androidMaestroReplayXml(searchBounds: string): string { return [ '', From ba3308e3510075f6a13ffe5eeca2959dce4fa269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 13:51:21 +0200 Subject: [PATCH 2/3] test: cover maestro provider input coalescing --- .../maestro/__tests__/replay-flow.test.ts | 17 ++--- src/compat/maestro/replay-flow.ts | 41 ++++++------ .../android-test-suite.test.ts | 64 +++++++++++++++++-- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index 7542d487e..67c065eb0 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 without requiring Enter', () => { 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', () => { @@ -658,10 +659,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..e368671bc 100644 --- a/src/compat/maestro/replay-flow.ts +++ b/src/compat/maestro/replay-flow.ts @@ -113,27 +113,32 @@ function optimizeTypedAfterTap( if (!isLikelyTextEntrySelector(tapSelector)) { return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; } + const fillActions: SessionAction[] = [ + { + ...action, + command: 'wait', + positionals: [tapSelector, '30000'], + }, + { + ...nextAction, + command: 'fill', + positionals: [tapSelector, typedAfterTap], + flags: action.flags, + }, + ]; + const fillActionLines = [line, line]; const pressEnterAction = actions[index + 2]; - if (pressEnterAction?.command !== MAESTRO_RUNTIME_COMMAND.pressEnter) { - return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; + if (pressEnterAction?.command === MAESTRO_RUNTIME_COMMAND.pressEnter) { + return { + actions: [...fillActions, pressEnterAction], + actionLines: [...fillActionLines, actionLines[index + 2] ?? line], + consumed: 3, + }; } return { - actions: [ - { - ...action, - command: 'wait', - positionals: [tapSelector, '30000'], - }, - { - ...nextAction, - command: 'fill', - positionals: [tapSelector, typedAfterTap], - flags: action.flags, - }, - pressEnterAction, - ], - actionLines: [line, line, actionLines[index + 2] ?? line], - consumed: 3, + actions: fillActions, + actionLines: fillActionLines, + consumed: 2, }; } diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index a067b0d43..7e879da63 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -121,7 +121,7 @@ test('Provider-backed integration Android Maestro replay uses fresh selector sna world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input swipe'), ['shell', 'input', 'swipe', '351', '390', '39', '390', '300'], ); - assert.equal(snapshots >= 2, true); + assert.equal(snapshots, 2); }, ); }); @@ -167,18 +167,74 @@ test('Provider-backed integration Android Maestro replay test suite discovers YA world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), ['shell', 'input', 'tap', '180', '330'], ); - assert.equal(snapshots >= 2, true); + assert.equal(snapshots, 2); }, ); }); -test('Provider-backed integration Android Maestro coalesces tapOn inputText and pressKey Enter through native paths', async () => { +test('Provider-backed integration Android Maestro fills tapOn inputText without trailing Enter', 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-only.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- tapOn: Search', + '- inputText: "Łódź café"', + '', + ].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.equal( + world.adbCalls.some((call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER'), + false, + JSON.stringify(world.adbCalls), + ); + world.assertNoHostAdbCalls(); + }, + ); +}); + +test('Provider-backed integration Android Maestro preserves pressKey Enter after native fill', async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input-submit'); + fs.mkdirSync(suiteRoot, { recursive: true }); const flowPath = path.join(suiteRoot, 'input-submit.yaml'); fs.writeFileSync( flowPath, @@ -277,7 +333,7 @@ test('Provider-backed integration Android Maestro executes runFlow conditions an world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), ['shell', 'input', 'tap', '180', '330'], ); - assert.equal(snapshots >= 3, true); + assert.equal(snapshots, 3); }, ); }); From 7af8e5c62cfb915eee6c63e6296244e7289936f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 14:04:55 +0200 Subject: [PATCH 3/3] test: align maestro input provider guard --- .../maestro/__tests__/replay-flow.test.ts | 17 ++++---- src/compat/maestro/replay-flow.ts | 41 ++++++++----------- .../android-test-suite.test.ts | 9 ++-- 3 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index 67c065eb0..7542d487e 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 coalesces tapOn inputText without requiring Enter', () => { +test('parseMaestroReplayFlow marks tapOn before inputText for snapshot tap focus', () => { const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab --- - tapOn: @@ -234,12 +234,11 @@ test('parseMaestroReplayFlow coalesces tapOn inputText without requiring Enter', 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.equal(parsed.actions[0]?.flags?.maestro?.allowNonHittableCoordinateFallback, undefined); }); test('parseMaestroReplayFlow coalesces tapOn inputText while preserving pressKey Enter submit', () => { @@ -659,10 +658,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 e368671bc..60211a7e4 100644 --- a/src/compat/maestro/replay-flow.ts +++ b/src/compat/maestro/replay-flow.ts @@ -113,32 +113,27 @@ function optimizeTypedAfterTap( if (!isLikelyTextEntrySelector(tapSelector)) { return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; } - const fillActions: SessionAction[] = [ - { - ...action, - command: 'wait', - positionals: [tapSelector, '30000'], - }, - { - ...nextAction, - command: 'fill', - positionals: [tapSelector, typedAfterTap], - flags: action.flags, - }, - ]; - const fillActionLines = [line, line]; const pressEnterAction = actions[index + 2]; - if (pressEnterAction?.command === MAESTRO_RUNTIME_COMMAND.pressEnter) { - return { - actions: [...fillActions, pressEnterAction], - actionLines: [...fillActionLines, actionLines[index + 2] ?? line], - consumed: 3, - }; + if (pressEnterAction?.command !== MAESTRO_RUNTIME_COMMAND.pressEnter) { + return { actions: [clearMaestroNonHittableTap(action)], actionLines: [line], consumed: 1 }; } return { - actions: fillActions, - actionLines: fillActionLines, - consumed: 2, + actions: [ + { + ...action, + command: 'wait', + positionals: [tapSelector, '30000'], + }, + { + ...nextAction, + command: 'fill', + positionals: [tapSelector, typedAfterTap], + flags: action.flags, + }, + pressEnterAction, + ], + actionLines: [line, line, actionLines[index + 2] ?? line], + consumed: 3, }; } diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 7e879da63..f24471df7 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -172,7 +172,7 @@ test('Provider-backed integration Android Maestro replay test suite discovers YA ); }); -test('Provider-backed integration Android Maestro fills tapOn inputText without trailing Enter', async () => { +test('Provider-backed integration Android Maestro types after tapOn inputText without trailing Enter', async () => { await withProviderScenarioResource( async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), async (world) => { @@ -205,12 +205,15 @@ test('Provider-backed integration Android Maestro fills tapOn inputText without assert.equal(suite.failed, 0, JSON.stringify(suite)); assert.deepEqual(world.textInjectionCalls, [ { - action: 'fill', - target: { x: 195, y: 52 }, + action: 'type', text: 'Łódź café', delayMs: 0, }, ]); + assert.deepEqual( + world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + ['shell', 'input', 'tap', '195', '52'], + ); assert.equal( world.adbCalls.some( (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text',