mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user