diff --git a/CHANGELOG.md b/CHANGELOG.md index 214df69a9..e0b537f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Fixed: `replay export` preserves deep links without `//`, including `tel:` and `mailto:`, as + Maestro `openLink` commands in both standalone and app-plus-link `open` actions. - Changed: a command whose synopsis is generated names each option with the label its declaration carries, so `snapshot` now shows `--depth, -d ` and `--scope, -s ` where it used to show the short aliases, and `--record` is documented under `Command flags:` instead of inside the diff --git a/packages/maestro/src/internal/__tests__/export-navigation.test.ts b/packages/maestro/src/internal/__tests__/export-navigation.test.ts index f91c194a8..c944fd757 100644 --- a/packages/maestro/src/internal/__tests__/export-navigation.test.ts +++ b/packages/maestro/src/internal/__tests__/export-navigation.test.ts @@ -8,6 +8,74 @@ import { } from '../../index.ts'; import { createMaestroRuntimePort, makeOperations } from './runtime-port-fixtures.ts'; +test.each(['tel:+15551234567', 'mailto:agent@example.test'])( + 'exports standalone deep link %s without an app config', + (link) => { + const result = exportReplayActionsToMaestro([action('open', [link])], { + resolveSelector: () => null, + }); + + expect(result.yaml).toBe(`- openLink: ${link}\n`); + expect(result.warnings).toEqual([]); + }, +); + +test.each([ + ['android', 'tel:+15551234567'], + ['android', 'mailto:agent@example.test'], + ['ios', 'tel:+15551234567'], + ['ios', 'mailto:agent@example.test'], +] as const)('exports and executes %s deep link %s in both open forms', async (platform, link) => { + const result = exportReplayActionsToMaestro( + [ + action('open', [link]), + { + ...action('open', ['com.example.app', link]), + flags: { relaunch: true, clearAppState: true, launchArgs: ['--fixture'] }, + }, + ], + { resolveSelector: () => null }, + ); + const launchApp = { + appId: 'com.example.app', + stopApp: true, + clearState: true, + launchArguments: ['--fixture'], + }; + + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.app' }, + [{ openLink: link }, { launchApp }, { openLink: link }], + ]); + 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, 'links.yaml'), port, { + platform, + readSource: () => { + throw new Error('unexpected flow include'); + }, + }); + + expect(outcome).toMatchObject({ ok: true, replayed: 3 }); + expect(calls).toEqual([ + { openLink: { link } }, + { launchApp: { ...launchApp, launchArguments: { kind: 'list', values: ['--fixture'] } } }, + { openLink: { link } }, + ]); +}); + test.each(['android', 'ios'] as const)( 'exports an app-to-home-to-app journey that executes in order on %s', async (platform) => { @@ -146,6 +214,11 @@ test.each([ positionals: ['com.example.app', 'another-app'], message: 'open with a non-URL second argument is unsupported', }, + ...['tel:', 'mailto:', 'http:/x', 'example://path with spaces'].map((target) => ({ + command: 'open', + positionals: ['com.example.app', target], + message: 'open with a non-URL second argument is unsupported', + })), ])('rejects unsupported navigation: $command $positionals', ({ command, positionals, message }) => { expect(() => exportReplayActionsToMaestro([action(command, positionals)], { diff --git a/packages/maestro/src/internal/export-navigation.ts b/packages/maestro/src/internal/export-navigation.ts index d10a56863..994c596fd 100644 --- a/packages/maestro/src/internal/export-navigation.ts +++ b/packages/maestro/src/internal/export-navigation.ts @@ -1,3 +1,4 @@ +import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { SessionAction } from '@agent-device/contracts/session'; import type { ConvertedAction, MaestroExportCommand } from './export-types.ts'; @@ -15,12 +16,12 @@ 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)) { + if (isDeepLinkTarget(first)) { return { kind: 'commands', commands: [{ openLink: first }] }; } const launchApp = buildLaunchAppCommand(action, first); - if (second && isUrl(second)) { + if (second && isDeepLinkTarget(second)) { return { kind: 'config', appId: first, commands: [launchApp, { openLink: second }] }; } if (second) { @@ -52,7 +53,3 @@ function convertKeyboardAction(action: SessionAction): ConvertedAction { } 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/src/cli/commands/__tests__/replay-maestro-export.test.ts b/src/cli/commands/__tests__/replay-maestro-export.test.ts index e54c5f3c0..7826174c5 100644 --- a/src/cli/commands/__tests__/replay-maestro-export.test.ts +++ b/src/cli/commands/__tests__/replay-maestro-export.test.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { parseAllDocuments } from 'yaml'; import { describe, expect, test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; @@ -8,8 +10,48 @@ import { } from '@agent-device/maestro'; import { parseReplayScriptDetailed, readReplayScriptMetadata } from '@agent-device/ad-script'; import { projectSelectorExpression } from '@agent-device/selectors'; +import { runCliCapture } from '../../../__tests__/cli-capture.ts'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; describe('exportReplayScriptToMaestro', () => { + test.each([ + ['tel:+15551234567', false], + ['tel:+15551234567', true], + ['mailto:agent@example.test', false], + ['mailto:agent@example.test', true], + ] as const)('CLI exports %s with explicit app=%s locally', async (link, withApp) => { + const dir = mkdtempForTestSync('agent-device-export-links-'); + const sourcePath = path.join(dir, 'flow.ad'); + const outPath = path.join(dir, 'flow.yaml'); + const open = withApp ? `com.example.app ${link} --relaunch` : link; + fs.writeFileSync(sourcePath, `open ${open}\n`); + + const result = await runCliCapture(['replay', 'export', sourcePath, '--json']); + + expect(result.code).toBeNull(); + expect(result.calls).toEqual([]); + expect(result.stderr).toBe(''); + const output = JSON.parse(result.stdout); + expect(output).toEqual({ + success: true, + data: { format: 'maestro', sourcePath, yaml: expect.any(String), warnings: [] }, + }); + expect(parseYamlDocs(output.data.yaml)).toEqual( + withApp + ? [ + { appId: 'com.example.app' }, + [{ launchApp: { appId: 'com.example.app', stopApp: true } }, { openLink: link }], + ] + : [[{ openLink: link }]], + ); + expect(() => inspectMaestroFlow(output.data.yaml, 'flow.yaml')).not.toThrow(); + + const written = await runCliCapture(['replay', 'export', sourcePath, '--out', outPath]); + + expect(written).toMatchObject({ code: null, calls: [], stderr: '', stdout: `${outPath}\n` }); + expect(fs.readFileSync(outPath, 'utf8')).toBe(output.data.yaml); + }); + test('exports app launch, selectors, input, keyboard, assertions, and screenshots', () => { const result = exportReplayScriptToMaestro(`env USER="Ada" context platform=ios target=mobile diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index f24f18b16..da5979d43 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -232,7 +232,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 app switches with explicit launchApp.appId targets and 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, deep links (including tel: and mailto:) as openLink, 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 30d9b2e00..1720739b9 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -99,6 +99,8 @@ agent-device replay export ./workflows/checkout.ad --out ./maestro/checkout.yaml 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. +Deep links, including schemes without `//` such as `tel:` and `mailto:`, export as `openLink`. A standalone `open tel:+15551234567` emits only the link command; `open com.example.app mailto:agent@example.test` emits the app launch followed by the link. + 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