Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <depth>` and `--scope, -s <scope>` where it used to
show the short aliases, and `--record` is documented under `Command flags:` instead of inside the
Expand Down
73 changes: 73 additions & 0 deletions packages/maestro/src/internal/__tests__/export-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)], {
Expand Down
9 changes: 3 additions & 6 deletions packages/maestro/src/internal/export-navigation.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
42 changes: 42 additions & 0 deletions src/cli/commands/__tests__/replay-maestro-export.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/commands/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <flow.yaml> --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 <file.ad> 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 <flow.yaml> --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 <file.ad> 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)),
Expand Down
2 changes: 2 additions & 0 deletions website/docs/docs/replay-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ agent-device replay export ./workflows/checkout.ad --out ./maestro/checkout.yaml

Each `open <appId>` 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
Expand Down
Loading