feat(maestro): export replay flows that switch apps (#2407)

Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
This commit is contained in:
Ahmad Al-Faqih
2026-09-09 09:27:40 +03:00
committed by GitHub
parent 6486788359
commit ee116079ba
8 changed files with 94 additions and 32 deletions
+2
View File
@@ -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`
@@ -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([
{
@@ -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([]);
@@ -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(
[
+1 -21
View File
@@ -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;
@@ -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;
@@ -30,11 +30,10 @@ 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;
@@ -42,7 +41,7 @@ function buildLaunchAppOptions(action: SessionAction): Record<string, unknown> |
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 {
@@ -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.
@@ -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,
@@ -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 } },
@@ -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' } },
@@ -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([
{
+1 -1
View File
@@ -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)),
+2
View File
@@ -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