diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3ae3024b..d63f876a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 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` after a readable capture) carry a per-poll timeline in `error.details` (`captures`, `polls[]` with `startedMs`, `durationMs`, and a typed `outcome`: readable, unreadable, deadline, diff --git a/packages/maestro/src/index.ts b/packages/maestro/src/index.ts index cddc432a77..d0acefb8bc 100644 --- a/packages/maestro/src/index.ts +++ b/packages/maestro/src/index.ts @@ -20,12 +20,13 @@ export { export { exportReplayActionsToMaestro, - MAESTRO_SELECTOR_PROJECTION, type MaestroExportOptions, type MaestroExportResult, type MaestroExportWarning, type MaestroSelectorProjection, -} from './internal/facade-export.ts'; +} from './internal/export-flow.ts'; + +export { MAESTRO_SELECTOR_PROJECTION } from './internal/selector-vocabulary.ts'; export { formatMaestroCompatibilityReference, diff --git a/packages/maestro/src/internal/__tests__/export-navigation.test.ts b/packages/maestro/src/internal/__tests__/export-navigation.test.ts new file mode 100644 index 0000000000..a34626db39 --- /dev/null +++ b/packages/maestro/src/internal/__tests__/export-navigation.test.ts @@ -0,0 +1,116 @@ +import { parseAllDocuments } from 'yaml'; +import { expect, test } from 'vitest'; +import type { SessionAction } from '@agent-device/contracts/session'; +import { + executeMaestroFlow, + exportReplayActionsToMaestro, + inspectMaestroFlow, +} from '../../index.ts'; +import { createMaestroRuntimePort, makeOperations } from './runtime-port-fixtures.ts'; + +test.each(['android', 'ios'] as const)( + 'exports an app-to-home-to-app journey that executes in order on %s', + async (platform) => { + const result = exportReplayActionsToMaestro( + [action('open', ['com.example.app']), action('home'), action('open', ['com.example.app'])], + { resolveSelector: () => null }, + ); + + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.app' }, + ['launchApp', { pressKey: 'Home' }, 'launchApp'], + ]); + expect(result.warnings).toEqual([]); + + const calls: string[] = []; + const port = createMaestroRuntimePort( + makeOperations({ + platform, + launchApp: async ({ appId }) => { + calls.push(`open ${appId}`); + }, + pressKey: async ({ key }) => { + calls.push(key); + }, + }), + ); + const outcome = await executeMaestroFlow(inspectMaestroFlow(result.yaml, 'home.yaml'), port, { + platform, + readSource: () => { + throw new Error('unexpected flow include'); + }, + }); + + expect(outcome).toMatchObject({ ok: true, replayed: 3 }); + expect(calls).toEqual(['open com.example.app', 'home', 'open com.example.app']); + }, +); + +test('preserves launch options, deep links, back, and keyboard exports', () => { + const result = exportReplayActionsToMaestro( + [ + { + ...action('open', ['com.example.app', 'example://checkout']), + flags: { relaunch: true, clearAppState: true, launchArgs: ['--fixture'] }, + }, + action('back'), + action('keyboard', ['dismiss']), + action('keyboard', ['enter']), + action('keyboard', ['return']), + action('open', ['example://done']), + ], + { resolveSelector: () => null }, + ); + + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.app' }, + [ + { + launchApp: { + appId: 'com.example.app', + stopApp: true, + clearState: true, + launchArguments: ['--fixture'], + }, + }, + { openLink: 'example://checkout' }, + 'back', + 'hideKeyboard', + { pressKey: 'Enter' }, + { pressKey: 'Enter' }, + { openLink: 'example://done' }, + ], + ]); + expect(result.warnings).toEqual([]); +}); + +test.each([ + { command: 'close', positionals: [], message: 'close has no Maestro equivalent' }, + { command: 'keyboard', positionals: ['status'], message: 'keyboard status' }, + { command: 'open', positionals: [], message: 'open requires an app id or URL' }, + { + command: 'open', + positionals: ['com.example.app', 'another-app'], + message: 'open with a non-URL second argument is unsupported', + }, +])('rejects unsupported navigation: $command $positionals', ({ command, positionals, message }) => { + expect(() => + exportReplayActionsToMaestro([action(command, positionals)], { + actionLines: [7], + resolveSelector: () => null, + }), + ).toThrowError( + expect.objectContaining({ + code: 'INVALID_ARGS', + details: { unsupported: [expect.objectContaining({ line: 7, message })] }, + }), + ); +}); + +function action(command: string, positionals: string[] = []): SessionAction { + return { ts: 0, command, positionals, flags: {} }; +} + +function parseYamlDocs(script: string): unknown[] { + return parseAllDocuments(script).map((document) => document.toJSON()); +} diff --git a/packages/maestro/src/internal/export-flow.ts b/packages/maestro/src/internal/export-flow.ts index 591f148834..888263cd69 100644 --- a/packages/maestro/src/internal/export-flow.ts +++ b/packages/maestro/src/internal/export-flow.ts @@ -4,7 +4,8 @@ import { AppError } from '@agent-device/kernel/errors'; import { selectorContainsKey } from '@agent-device/selectors'; import { formatMaestroPoint } from './export-points.ts'; import { DEFAULT_MAESTRO_COMPATIBILITY_TIMING_POLICY } from './compatibility-policy.ts'; -import type { MaestroExportCommand, MaestroExportConfig } from './export-types.ts'; +import type { ConvertedAction, MaestroExportCommand, MaestroExportConfig } from './export-types.ts'; +import { NAVIGATION_ACTION_CONVERTERS } from './export-navigation.ts'; import { stringifyMaestroYamlDocuments } from './export-yaml.ts'; export type MaestroExportWarning = { @@ -32,11 +33,6 @@ type ExportContext = { unsupported: MaestroExportWarning[]; }; -type ConvertedAction = - | { kind: 'commands'; commands: MaestroExportCommand[]; warnings?: string[] } - | { kind: 'config'; appId: string; commands: MaestroExportCommand[]; warnings?: string[] } - | { kind: 'unsupported'; message: string }; - type ActionConverter = ( action: SessionAction, resolveSelector: MaestroExportOptions['resolveSelector'], @@ -107,14 +103,12 @@ function buildInitialConfig(metadata: MaestroExportOptions['metadata']): Maestro } const ACTION_CONVERTERS: Record = { - open: convertOpenAction, + ...NAVIGATION_ACTION_CONVERTERS, click: convertClickAction, press: convertClickAction, longpress: convertLongPressAction, fill: convertFillAction, type: convertTypeAction, - keyboard: convertKeyboardAction, - back: () => ({ kind: 'commands', commands: ['back'] }), wait: convertWaitAction, find: convertFindAction, screenshot: convertScreenshotAction, @@ -134,40 +128,6 @@ function convertAction( ); } -function convertOpenAction(action: SessionAction): ConvertedAction { - const [first, second] = action.positionals; - if (!first) return { kind: 'unsupported', message: 'open requires an app id or URL' }; - - if (isUrl(first)) { - return { kind: 'commands', commands: [{ openLink: first }] }; - } - - const launchApp = buildLaunchAppCommand(action, first); - if (second && isUrl(second)) { - return { kind: 'config', appId: first, commands: [launchApp, { openLink: second }] }; - } - if (second) { - return { kind: 'unsupported', message: 'open with a non-URL second argument is unsupported' }; - } - return { kind: 'config', appId: first, commands: [launchApp] }; -} - -function buildLaunchAppCommand(action: SessionAction, appId: string): MaestroExportCommand { - const options = buildLaunchAppOptions(action); - return options ? { launchApp: { appId, ...options } } : 'launchApp'; -} - -function buildLaunchAppOptions(action: SessionAction): Record | undefined { - const launchArgs = action.flags?.launchArgs; - const options: Record = {}; - if (action.flags?.relaunch === true) options.stopApp = true; - if (action.flags?.clearAppState === true) options.clearState = true; - if (Array.isArray(launchArgs) && launchArgs.length > 0) { - options.launchArguments = launchArgs; - } - return Object.keys(options).length > 0 ? options : undefined; -} - function convertClickAction( action: SessionAction, resolveSelector: MaestroExportOptions['resolveSelector'], @@ -298,15 +258,6 @@ function convertTypeAction(action: SessionAction): ConvertedAction { return { kind: 'commands', commands: [{ inputText: text }] }; } -function convertKeyboardAction(action: SessionAction): ConvertedAction { - const [subcommand] = action.positionals; - if (subcommand === 'dismiss') return { kind: 'commands', commands: ['hideKeyboard'] }; - if (subcommand === 'enter' || subcommand === 'return') { - return { kind: 'commands', commands: [{ pressKey: 'Enter' }] }; - } - return { kind: 'unsupported', message: `keyboard ${subcommand ?? ''}`.trim() }; -} - function convertWaitAction( action: SessionAction, resolveSelector: MaestroExportOptions['resolveSelector'], @@ -546,10 +497,6 @@ function formatActionForMessage(action: SessionAction): string { return [action.command, ...(action.positionals ?? [])].join(' ').trim(); } -function isUrl(value: string): boolean { - return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value); -} - function isNumber(value: string | undefined): value is string { return value !== undefined && Number.isFinite(Number(value)); } diff --git a/packages/maestro/src/internal/export-navigation.ts b/packages/maestro/src/internal/export-navigation.ts new file mode 100644 index 0000000000..5bf0c4ce14 --- /dev/null +++ b/packages/maestro/src/internal/export-navigation.ts @@ -0,0 +1,59 @@ +import type { SessionAction } from '@agent-device/contracts/session'; +import type { ConvertedAction, MaestroExportCommand } from './export-types.ts'; + +export const NAVIGATION_ACTION_CONVERTERS: Record< + 'open' | 'back' | 'home' | 'keyboard', + (action: SessionAction) => ConvertedAction +> = { + open: convertOpenAction, + back: () => ({ kind: 'commands', commands: ['back'] }), + home: () => ({ kind: 'commands', commands: [{ pressKey: 'Home' }] }), + keyboard: convertKeyboardAction, +}; + +function convertOpenAction(action: SessionAction): ConvertedAction { + const [first, second] = action.positionals; + if (!first) return { kind: 'unsupported', message: 'open requires an app id or URL' }; + + if (isUrl(first)) { + return { kind: 'commands', commands: [{ openLink: first }] }; + } + + const launchApp = buildLaunchAppCommand(action, first); + if (second && isUrl(second)) { + return { kind: 'config', appId: first, commands: [launchApp, { openLink: second }] }; + } + if (second) { + return { kind: 'unsupported', message: 'open with a non-URL second argument is unsupported' }; + } + return { kind: 'config', appId: first, commands: [launchApp] }; +} + +function buildLaunchAppCommand(action: SessionAction, appId: string): MaestroExportCommand { + const options = buildLaunchAppOptions(action); + return options ? { launchApp: { appId, ...options } } : 'launchApp'; +} + +function buildLaunchAppOptions(action: SessionAction): Record | undefined { + const launchArgs = action.flags?.launchArgs; + const options: Record = {}; + if (action.flags?.relaunch === true) options.stopApp = true; + if (action.flags?.clearAppState === true) options.clearState = true; + if (Array.isArray(launchArgs) && launchArgs.length > 0) { + options.launchArguments = launchArgs; + } + return Object.keys(options).length > 0 ? options : undefined; +} + +function convertKeyboardAction(action: SessionAction): ConvertedAction { + const [subcommand] = action.positionals; + if (subcommand === 'dismiss') return { kind: 'commands', commands: ['hideKeyboard'] }; + if (subcommand === 'enter' || subcommand === 'return') { + return { kind: 'commands', commands: [{ pressKey: 'Enter' }] }; + } + return { kind: 'unsupported', message: `keyboard ${subcommand ?? ''}`.trim() }; +} + +function isUrl(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value); +} diff --git a/packages/maestro/src/internal/export-types.ts b/packages/maestro/src/internal/export-types.ts index 3d46400f30..441de4723b 100644 --- a/packages/maestro/src/internal/export-types.ts +++ b/packages/maestro/src/internal/export-types.ts @@ -4,3 +4,8 @@ export type MaestroExportConfig = { }; export type MaestroExportCommand = string | Record; + +export type ConvertedAction = + | { kind: 'commands'; commands: MaestroExportCommand[]; warnings?: string[] } + | { kind: 'config'; appId: string; commands: MaestroExportCommand[]; warnings?: string[] } + | { kind: 'unsupported'; message: string }; diff --git a/packages/maestro/src/internal/facade-export.ts b/packages/maestro/src/internal/facade-export.ts deleted file mode 100644 index d82128d0fe..0000000000 --- a/packages/maestro/src/internal/facade-export.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - exportReplayActionsToMaestro, - type MaestroExportOptions, - type MaestroExportResult, - type MaestroExportWarning, - type MaestroSelectorProjection, -} from './export-flow.ts'; - -export { MAESTRO_SELECTOR_PROJECTION } from './selector-vocabulary.ts'; diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 4d72e5937f..cafe5955cb 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.', + '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.', }, 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 e78359a5f9..9acf327e32 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -97,7 +97,7 @@ 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. -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, text visibility assertions, coordinate swipes, basic scroll, screenshots, and `.ad` `env` directives. 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. +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