From 865ed651a5b3b20c79b69b1ade0b2d236c905b3b Mon Sep 17 00:00:00 2001 From: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:35:38 +0000 Subject: [PATCH] feat(maestro): export replay flows that switch apps --- CHANGELOG.md | 2 + .../__tests__/export-label-projection.test.ts | 2 +- .../__tests__/export-navigation.test.ts | 55 ++++++++++++++++++- packages/maestro/src/internal/export-flow.ts | 22 +------- .../maestro/src/internal/export-navigation.ts | 7 +-- .../__tests__/replay-maestro-export.test.ts | 34 ++++++++++-- src/commands/replay/index.ts | 2 +- website/docs/docs/replay-e2e.md | 2 + 8 files changed, 94 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 359fb693a0..d2cb62bfad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added: `replay export` supports flows that switch apps and return, preserving each + `open ` target as an explicit Maestro `launchApp.appId`. - Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing app-to-home-to-app journeys to be exported. - Added: polling `wait` timeouts (`wait `, `wait text`, `wait @ref`, and `wait absent` diff --git a/packages/maestro/src/internal/__tests__/export-label-projection.test.ts b/packages/maestro/src/internal/__tests__/export-label-projection.test.ts index b5d1c4f7b4..0b2a27dbd4 100644 --- a/packages/maestro/src/internal/__tests__/export-label-projection.test.ts +++ b/packages/maestro/src/internal/__tests__/export-label-projection.test.ts @@ -18,7 +18,7 @@ test('projects native label selectors to text with a warning and self-parses', ( expect(parseAllDocuments(result.yaml).map((document) => document.toJSON())).toEqual([ { appId: 'com.example.app' }, - ['launchApp', { tapOn: { text: 'Save' } }], + [{ launchApp: { appId: 'com.example.app' } }, { tapOn: { text: 'Save' } }], ]); expect(result.warnings).toEqual([ { diff --git a/packages/maestro/src/internal/__tests__/export-navigation.test.ts b/packages/maestro/src/internal/__tests__/export-navigation.test.ts index a34626db39..f91c194a80 100644 --- a/packages/maestro/src/internal/__tests__/export-navigation.test.ts +++ b/packages/maestro/src/internal/__tests__/export-navigation.test.ts @@ -18,7 +18,11 @@ test.each(['android', 'ios'] as const)( expect(parseYamlDocs(result.yaml)).toEqual([ { appId: 'com.example.app' }, - ['launchApp', { pressKey: 'Home' }, 'launchApp'], + [ + { launchApp: { appId: 'com.example.app' } }, + { pressKey: 'Home' }, + { launchApp: { appId: 'com.example.app' } }, + ], ]); expect(result.warnings).toEqual([]); @@ -46,6 +50,55 @@ test.each(['android', 'ios'] as const)( }, ); +test.each(['android', 'ios'] as const)( + 'exports app switches, relaunch options, and deep links in order on %s', + async (platform) => { + const result = exportReplayActionsToMaestro( + [ + action('open', ['com.example.shop']), + action('open', ['com.example.auth']), + { + ...action('open', ['com.example.auth', 'example://approve']), + flags: { relaunch: true, clearAppState: true }, + }, + action('open', ['com.example.shop']), + ], + { resolveSelector: () => null }, + ); + + expect(parseYamlDocs(result.yaml)[0]).toEqual({ appId: 'com.example.shop' }); + expect(result.warnings).toEqual([]); + + const calls: unknown[] = []; + const port = createMaestroRuntimePort( + makeOperations({ + platform, + launchApp: async (input) => { + calls.push({ launchApp: input }); + }, + openLink: async (input) => { + calls.push({ openLink: input }); + }, + }), + ); + const outcome = await executeMaestroFlow(inspectMaestroFlow(result.yaml, 'apps.yaml'), port, { + platform, + readSource: () => { + throw new Error('unexpected flow include'); + }, + }); + + expect(outcome).toMatchObject({ ok: true, replayed: 5 }); + expect(calls).toEqual([ + { launchApp: { appId: 'com.example.shop' } }, + { launchApp: { appId: 'com.example.auth' } }, + { launchApp: { appId: 'com.example.auth', stopApp: true, clearState: true } }, + { openLink: { link: 'example://approve' } }, + { launchApp: { appId: 'com.example.shop' } }, + ]); + }, +); + test('preserves launch options, deep links, back, and keyboard exports', () => { const result = exportReplayActionsToMaestro( [ diff --git a/packages/maestro/src/internal/export-flow.ts b/packages/maestro/src/internal/export-flow.ts index 888263cd69..f50bd617e7 100644 --- a/packages/maestro/src/internal/export-flow.ts +++ b/packages/maestro/src/internal/export-flow.ts @@ -68,7 +68,7 @@ export function exportReplayActionsToMaestro( appendWarnings(context, converted.warnings, action, line); break; case 'config': - assignAppId(context, converted.appId, action, line); + context.config.appId ??= converted.appId; commands.push(...converted.commands); appendWarnings(context, converted.warnings, action, line); break; @@ -435,26 +435,6 @@ function withTapOptions(target: unknown, options: Record): Maes return { tapOn: target }; } -function assignAppId( - context: ExportContext, - appId: string, - action: SessionAction, - line: number, -): void { - if (!context.config.appId) { - context.config.appId = appId; - return; - } - if (context.config.appId === appId) return; - context.unsupported.push({ - line, - action: formatActionForMessage(action), - message: - `multiple app ids cannot be represented in one Maestro config ` + - `(${context.config.appId} vs ${appId})`, - }); -} - function readBackspaceCount(text: string): number | null { if (text.length === 0) return null; if (![...text].every((char) => char === '\b')) return null; diff --git a/packages/maestro/src/internal/export-navigation.ts b/packages/maestro/src/internal/export-navigation.ts index 5bf0c4ce14..d10a568638 100644 --- a/packages/maestro/src/internal/export-navigation.ts +++ b/packages/maestro/src/internal/export-navigation.ts @@ -30,11 +30,10 @@ function convertOpenAction(action: SessionAction): ConvertedAction { } function buildLaunchAppCommand(action: SessionAction, appId: string): MaestroExportCommand { - const options = buildLaunchAppOptions(action); - return options ? { launchApp: { appId, ...options } } : 'launchApp'; + return { launchApp: { appId, ...buildLaunchAppOptions(action) } }; } -function buildLaunchAppOptions(action: SessionAction): Record | undefined { +function buildLaunchAppOptions(action: SessionAction): Record { const launchArgs = action.flags?.launchArgs; const options: Record = {}; if (action.flags?.relaunch === true) options.stopApp = true; @@ -42,7 +41,7 @@ function buildLaunchAppOptions(action: SessionAction): Record | if (Array.isArray(launchArgs) && launchArgs.length > 0) { options.launchArguments = launchArgs; } - return Object.keys(options).length > 0 ? options : undefined; + return options; } function convertKeyboardAction(action: SessionAction): ConvertedAction { diff --git a/src/cli/commands/__tests__/replay-maestro-export.test.ts b/src/cli/commands/__tests__/replay-maestro-export.test.ts index be3a41d63d..e54c5f3c0d 100644 --- a/src/cli/commands/__tests__/replay-maestro-export.test.ts +++ b/src/cli/commands/__tests__/replay-maestro-export.test.ts @@ -45,6 +45,24 @@ screenshot "./artifacts/checkout" ]); }); + test('exports each app target when a script switches apps and returns', () => { + const result = exportReplayScriptToMaestro(`open com.example.shop +open com.example.auth +open com.example.shop +`); + + expect(() => inspectMaestroFlow(result.yaml, 'apps.yaml')).not.toThrow(); + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.shop' }, + [ + { launchApp: { appId: 'com.example.shop' } }, + { launchApp: { appId: 'com.example.auth' } }, + { launchApp: { appId: 'com.example.shop' } }, + ], + ]); + expect(result.warnings).toEqual([]); + }); + test('exports the empty-fill clear as eraseText, never a vacuous inputText', () => { // `fill ""` is the clear-field primitive (#2063); Maestro's `inputText: ""` types // nothing, so the recorded clear must become its clear verb. @@ -54,7 +72,11 @@ fill id="email" "" `); const docs = parseYamlDocs(result.yaml); - expect(docs[1]).toEqual(['launchApp', { tapOn: { id: 'email' } }, 'eraseText']); + expect(docs[1]).toEqual([ + { launchApp: { appId: 'com.example.app' } }, + { tapOn: { id: 'email' } }, + 'eraseText', + ]); expect(result.warnings).toEqual([ { line: 3, @@ -75,7 +97,7 @@ wait 500 expect(parseYamlDocs(result.yaml)).toEqual([ { appId: 'com.example.app' }, [ - 'launchApp', + { launchApp: { appId: 'com.example.app' } }, { tapOn: { point: '120,240' } }, { swipe: { start: '200,700', end: '200,200', duration: 100 } }, { swipe: { start: '200,700', end: '200,200', duration: 100 } }, @@ -102,7 +124,7 @@ press text="Retry" --hold-ms 1500 expect(parseYamlDocs(result.yaml)).toEqual([ { appId: 'com.example.app' }, [ - 'launchApp', + { launchApp: { appId: 'com.example.app' } }, { longPressOn: { text: 'Last message' } }, { longPressOn: { id: 'hold-button' } }, { longPressOn: { text: 'Retry' } }, @@ -144,7 +166,11 @@ press text="Hold" --hold-ms 1000 --count 3 --interval-ms 150 expect(parseYamlDocs(result.yaml)).toEqual([ { appId: 'com.example.app' }, - ['launchApp', { doubleTapOn: { id: 'retry' } }, { longPressOn: { text: 'Hold' } }], + [ + { launchApp: { appId: 'com.example.app' } }, + { doubleTapOn: { id: 'retry' } }, + { longPressOn: { text: 'Hold' } }, + ], ]); expect(result.warnings).toEqual([ { diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index cafe5955cb..a9480ed32e 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -230,7 +230,7 @@ export const replayCommandFacet = defineCommandFacet({ text: { summary: 'Replay a recorded session or Maestro flow', cliDetail: - 'For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including home as pressKey: Home.', + 'For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively. replay export converts compatible actions to Maestro YAML locally, including app switches with explicit launchApp.appId targets and home as pressKey: Home.', }, metadata: replayCommandMetadata, run: (client, input) => client.replay.run(withCommandRuntimeHints(input)), diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 9acf327e32..30d9b2e005 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -97,6 +97,8 @@ agent-device replay export ./workflows/checkout.ad --out ./maestro/checkout.yaml `replay export` is a local file transform. It does not start the daemon or contact a device. If `--out` is omitted, the YAML is printed to stdout. +Each `open ` exports with an explicit `launchApp.appId`, so a flow can switch between apps and return to the original app. The first app remains the flow's default `appId`; relaunch options and app-specific deep links stay attached to their authored targets. + The exporter is intentionally strict. It writes Maestro YAML for compatible flow actions such as app launch, taps, long press, text input, keyboard dismiss/enter, back, home, text visibility assertions, coordinate swipes, basic scroll, screenshots, and `.ad` `env` directives. `home` exports as `pressKey: Home`, so flows that visit the home screen and reopen the app can be exported. Agent-only inspection or maintenance actions such as `snapshot`, `get`, `record`, `trace`, `settings`, and unsupported selector shapes fail with the source line and action instead of being silently dropped. Known semantic differences are reported as warnings; for example, `.ad` `fill` exports as `tapOn` plus `inputText`, which may append text in Maestro rather than replacing existing field contents. Native `.ad` `label=` selectors export as Maestro `text:` selectors and warn because Maestro text matching is broader than label-only matching. ## Run a lightweight `.ad` suite