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` supports flows that switch apps and return, preserving each
`open <appId>` 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 <selector>`, `wait text`, `wait @ref`, and `wait absent`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);

Expand Down Expand Up @@ -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(
[
Expand Down
22 changes: 1 addition & 21 deletions packages/maestro/src/internal/export-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -435,26 +435,6 @@ function withTapOptions(target: unknown, options: Record<string, unknown>): 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;
Expand Down
7 changes: 3 additions & 4 deletions packages/maestro/src/internal/export-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,18 @@ 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<string, unknown> | undefined {
function buildLaunchAppOptions(action: SessionAction): Record<string, unknown> {
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;
return options;
}

function convertKeyboardAction(action: SessionAction): ConvertedAction {
Expand Down
34 changes: 30 additions & 4 deletions src/cli/commands/__tests__/replay-maestro-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target> ""` is the clear-field primitive (#2063); Maestro's `inputText: ""` types
// nothing, so the recorded clear must become its clear verb.
Expand All @@ -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,
Expand All @@ -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 } },
Expand All @@ -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' } },
Expand Down Expand Up @@ -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([
{
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 @@ -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. replay export <file.ad> converts compatible actions to Maestro YAML locally, including 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 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 @@ -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 <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.

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