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

- 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 <selector>`, `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,
Expand Down
5 changes: 3 additions & 2 deletions packages/maestro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions packages/maestro/src/internal/__tests__/export-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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());
}
59 changes: 3 additions & 56 deletions packages/maestro/src/internal/export-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -107,14 +103,12 @@ function buildInitialConfig(metadata: MaestroExportOptions['metadata']): Maestro
}

const ACTION_CONVERTERS: Record<string, ActionConverter> = {
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,
Expand All @@ -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<string, unknown> | undefined {
const launchArgs = action.flags?.launchArgs;
const options: Record<string, unknown> = {};
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'],
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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));
}
59 changes: 59 additions & 0 deletions packages/maestro/src/internal/export-navigation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined {
const launchArgs = action.flags?.launchArgs;
const options: Record<string, unknown> = {};
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);
}
5 changes: 5 additions & 0 deletions packages/maestro/src/internal/export-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ export type MaestroExportConfig = {
};

export type MaestroExportCommand = string | Record<string, unknown>;

export type ConvertedAction =
| { kind: 'commands'; commands: MaestroExportCommand[]; warnings?: string[] }
| { kind: 'config'; appId: string; commands: MaestroExportCommand[]; warnings?: string[] }
| { kind: 'unsupported'; message: string };
9 changes: 0 additions & 9 deletions packages/maestro/src/internal/facade-export.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/commands/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.',
'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 home as pressKey: Home.',
},
metadata: replayCommandMetadata,
run: (client, input) => client.replay.run(withCommandRuntimeHints(input)),
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/replay-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading