Skip to content

Commit 896adcc

Browse files
authored
feat: add maestro replay compatibility (#561)
1 parent 840bef5 commit 896adcc

47 files changed

Lines changed: 2168 additions & 424 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,49 @@ Run tests:
3434
pnpm test
3535
```
3636

37+
Targeted checks:
38+
39+
```bash
40+
pnpm check:quick
41+
pnpm check:unit
42+
pnpm exec vitest run src/compat/maestro/__tests__/replay-flow.test.ts src/compat/__tests__/replay-input.test.ts
43+
```
44+
3745
Optional device selectors for tests:
3846

3947
- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554`
4048
- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=<udid>`
4149

50+
## Test App and Maestro Compatibility
51+
52+
The Expo test app lives in `examples/test-app`. Install its dependencies once:
53+
54+
```bash
55+
pnpm test-app:install
56+
```
57+
58+
For Maestro compatibility, we currently have 15 parser/compat unit tests and one
59+
top-level test-app Maestro flow, `examples/test-app/maestro/checkout-form.yaml`,
60+
which includes `examples/test-app/maestro/helpers/open-checkout-form.yaml`.
61+
62+
Run only the parser/compat tests:
63+
64+
```bash
65+
pnpm exec vitest run src/compat/maestro/__tests__/replay-flow.test.ts src/compat/__tests__/replay-input.test.ts
66+
```
67+
68+
Run the Expo test-app flow on iOS:
69+
70+
```bash
71+
pnpm test-app:start
72+
pnpm ad --session test-app-maestro open "Expo Go" exp://127.0.0.1:8081 --platform ios --device "iPhone 17 Pro"
73+
pnpm ad --session test-app-maestro wait "Agent Device Tester" 30000 --platform ios --device "iPhone 17 Pro"
74+
pnpm test-app:maestro:ios -- --session test-app-maestro -- --device "iPhone 17 Pro"
75+
```
76+
77+
Use `pnpm test-app:maestro:android` for Android, passing the same extra
78+
`agent-device` flags after `--`.
79+
4280
## Guidelines
4381

4482
- Keep dependencies minimal.

examples/test-app/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,48 @@ pnpm android
7676
```
7777

7878
Once the app is running, use `agent-device` against `Agent Device Tester` like any other target app.
79+
80+
## Local Agent Device suites
81+
82+
The repo includes two local suites for iterating on the fixture app:
83+
84+
```bash
85+
pnpm test-app:replay:ios
86+
pnpm test-app:replay:android
87+
```
88+
89+
These run the `.ad` replay suite in `examples/test-app/replays`.
90+
91+
To target a specific iOS simulator or an installed Expo development build, run the
92+
underlying command directly so global flags stay before replay inputs:
93+
94+
```bash
95+
node bin/agent-device.mjs test examples/test-app/replays \
96+
--platform ios \
97+
--device "iPhone 17 Pro" \
98+
--env APP_TARGET=dev.expo.easagentdevice \
99+
--env APP_URL=<project-url> \
100+
--artifacts-dir .tmp/test-app-replay/ios
101+
```
102+
103+
Use `APP_TARGET=com.callstack.agentdevicelab` when the standalone fixture app is
104+
installed instead of an Expo development shell.
105+
106+
The Maestro prototype suite lives in `examples/test-app/maestro` and runs through
107+
`agent-device replay --maestro`:
108+
109+
```bash
110+
pnpm test-app:maestro:ios -- --open "Agent Device Tester"
111+
pnpm test-app:maestro:android -- --open "Agent Device Tester"
112+
```
113+
114+
When running through Expo Go, start the project first and pass the shell that is already showing
115+
the app, for example:
116+
117+
```bash
118+
pnpm test-app:maestro:ios -- --open "Expo Go"
119+
```
120+
121+
The suite intentionally covers the compat layer syntax used by public Maestro suites:
122+
`runFlow` file/inline blocks, `when.platform`, config hooks, deterministic `repeat.times`,
123+
flow `env`, selectors, input, assertions, and swipe.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
appId: host.exp.Exponent
2+
env:
3+
CHECKOUT_NAME: Ada Lovelace
4+
CHECKOUT_EMAIL: ada@example.com
5+
PICKUP_TAPS: "2"
6+
onFlowStart:
7+
- assertVisible: Agent Device Tester
8+
onFlowComplete:
9+
- assertVisible: Delivery choices
10+
---
11+
- runFlow:
12+
file: helpers/open-checkout-form.yaml
13+
env:
14+
CHECKOUT_NAME: Ada Lovelace
15+
CHECKOUT_EMAIL: ada@example.com
16+
- runFlow:
17+
when:
18+
platform: iOS
19+
commands:
20+
- assertVisible: Checkout form
21+
- runFlow:
22+
when:
23+
platform: Android
24+
commands:
25+
- assertVisible: Checkout form
26+
- assertVisible:
27+
text: Checkout form
28+
- swipe:
29+
start: 50%, 75%
30+
end: 50%, 35%
31+
duration: 300
32+
- repeat:
33+
times: ${PICKUP_TAPS}
34+
commands:
35+
- tapOn:
36+
id: shipping-pickup
37+
- assertVisible:
38+
id: shipping-pickup
39+
selected: true
40+
- tapOn:
41+
id: payment-cash
42+
- assertVisible:
43+
id: payment-cash
44+
selected: true
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
- tapOn:
3+
id: home-open-form
4+
- assertVisible: Checkout form
5+
- tapOn:
6+
id: field-name
7+
- inputText:
8+
text: ${CHECKOUT_NAME}
9+
label: Full name
10+
- tapOn:
11+
id: field-email
12+
- inputText: ${CHECKOUT_EMAIL}
13+
- tapOn:
14+
text: Checkout form
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
context platform=android timeout=60000
2+
env APP_TARGET="Agent Device Tester"
3+
env APP_URL=""
4+
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
5+
wait "label=\"Form\"" 30000
6+
click "label=\"Form\""
7+
wait "Checkout form" 5000
8+
fill id="field-name" "Ada Lovelace"
9+
fill id="field-email" "ada@example.com"
10+
keyboard dismiss
11+
wait "Checkout form" 5000
12+
scroll down 0.6
13+
click id="shipping-pickup"
14+
click id="payment-cash"
15+
wait "Delivery choices" 5000
16+
scroll down 0.7
17+
click id="checkbox-agree"
18+
click id="submit-order"
19+
wait "Order summary" 5000
20+
wait "Ada Lovelace chose pickup with cash payment." 5000
21+
close
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
context platform=ios timeout=60000
2+
env APP_TARGET="Agent Device Tester"
3+
env APP_URL=""
4+
open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}"
5+
wait "label=\"Form\"" 30000
6+
click "label=\"Form\""
7+
wait "Checkout form" 5000
8+
fill id="field-name" "Ada Lovelace"
9+
fill id="field-email" "ada@example.com"
10+
keyboard dismiss
11+
wait "Checkout form" 5000
12+
scroll down 0.6
13+
click id="shipping-pickup"
14+
click id="payment-cash"
15+
wait "Delivery choices" 5000
16+
scroll down 0.7
17+
click id="checkbox-agree"
18+
click id="submit-order"
19+
wait "Order summary" 5000
20+
wait "Ada Lovelace chose pickup with cash payment." 5000
21+
close

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@
111111
"test-app:ios": "pnpm --dir examples/test-app ios",
112112
"test-app:android": "pnpm --dir examples/test-app android",
113113
"test-app:typecheck": "pnpm --dir examples/test-app typecheck",
114+
"test-app:replay:ios": "pnpm ad test examples/test-app/replays --platform ios --artifacts-dir .tmp/test-app-replay/ios",
115+
"test-app:replay:android": "pnpm ad test examples/test-app/replays --platform android --artifacts-dir .tmp/test-app-replay/android",
116+
"test-app:maestro": "node scripts/run-test-app-maestro-suite.mjs",
117+
"test-app:maestro:ios": "node scripts/run-test-app-maestro-suite.mjs --platform ios",
118+
"test-app:maestro:android": "node scripts/run-test-app-maestro-suite.mjs --platform android",
114119
"test": "vitest run --project unit",
115120
"test:unit": "vitest run --project unit",
116121
"test:coverage": "vitest run --coverage",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env node
2+
import { execFileSync } from 'node:child_process';
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8+
const binPath = path.join(repoRoot, 'bin', 'agent-device.mjs');
9+
10+
const options = {
11+
platform: 'ios',
12+
session: 'test-app-maestro',
13+
flowDir: path.join(repoRoot, 'examples', 'test-app', 'maestro'),
14+
openTarget: '',
15+
close: false,
16+
passthrough: [],
17+
};
18+
19+
for (let index = 2; index < process.argv.length; index += 1) {
20+
const arg = process.argv[index];
21+
if (arg === '--') {
22+
options.passthrough.push(...process.argv.slice(index + 1));
23+
break;
24+
}
25+
if (arg === '--platform' && process.argv[index + 1]) {
26+
options.platform = process.argv[index + 1];
27+
index += 1;
28+
continue;
29+
}
30+
if (arg === '--session' && process.argv[index + 1]) {
31+
options.session = process.argv[index + 1];
32+
index += 1;
33+
continue;
34+
}
35+
if (arg === '--flow-dir' && process.argv[index + 1]) {
36+
options.flowDir = path.resolve(process.argv[index + 1]);
37+
index += 1;
38+
continue;
39+
}
40+
if (arg === '--open' && process.argv[index + 1]) {
41+
options.openTarget = process.argv[index + 1];
42+
index += 1;
43+
continue;
44+
}
45+
if (arg === '--close') {
46+
options.close = true;
47+
continue;
48+
}
49+
options.passthrough.push(arg);
50+
}
51+
52+
const flows = fs
53+
.readdirSync(options.flowDir)
54+
.filter((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml'))
55+
.sort()
56+
.map((entry) => path.join(options.flowDir, entry));
57+
58+
if (flows.length === 0) {
59+
console.error(`No Maestro flows found in ${options.flowDir}`);
60+
process.exit(1);
61+
}
62+
63+
function runAgentDevice(args) {
64+
execFileSync(process.execPath, [binPath, '--session', options.session, ...args], {
65+
cwd: repoRoot,
66+
stdio: 'inherit',
67+
});
68+
}
69+
70+
if (options.openTarget) {
71+
runAgentDevice(['open', options.openTarget, '--platform', options.platform, ...options.passthrough]);
72+
runAgentDevice(['wait', 'Agent Device Tester', '30000', '--platform', options.platform, ...options.passthrough]);
73+
}
74+
75+
for (const flow of flows) {
76+
runAgentDevice(['replay', flow, '--maestro', '--platform', options.platform, ...options.passthrough]);
77+
}
78+
79+
if (options.close) {
80+
runAgentDevice(['close']);
81+
}

src/__tests__/client.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ test('client throws AppError for daemon failures', async () => {
399399
);
400400
});
401401

402+
// fallow-ignore-next-line complexity
402403
test('replay.run serializes client-collected AD_VAR shell env into daemon request', async () => {
403404
const previousAppId = process.env.AD_VAR_APP_ID;
404405
const previousWaitMs = process.env.AD_VAR_WAIT_MS;
@@ -419,6 +420,7 @@ test('replay.run serializes client-collected AD_VAR shell env into daemon reques
419420
assert.equal(setup.calls[0]?.command, 'replay');
420421
assert.deepEqual(setup.calls[0]?.positionals, ['./flows/login.ad']);
421422
assert.deepEqual(setup.calls[0]?.flags?.replayEnv, ['APP_ID=cli-override']);
423+
assert.equal(setup.calls[0]?.flags?.replayBackend, undefined);
422424
const replayShellEnv = setup.calls[0]?.flags?.replayShellEnv as
423425
| Record<string, string>
424426
| undefined;
@@ -435,6 +437,36 @@ test('replay.run serializes client-collected AD_VAR shell env into daemon reques
435437
}
436438
});
437439

440+
test('replay.run forwards backend without knowing the concrete syntax', async () => {
441+
const setup = createTransport(async () => ({ ok: true, data: {} }));
442+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
443+
444+
await client.replay.run({
445+
path: './flows/login.yaml',
446+
backend: 'external-flow',
447+
});
448+
449+
assert.equal(setup.calls.length, 1);
450+
assert.equal(setup.calls[0]?.command, 'replay');
451+
assert.deepEqual(setup.calls[0]?.positionals, ['./flows/login.yaml']);
452+
assert.equal(setup.calls[0]?.flags?.replayBackend, 'external-flow');
453+
});
454+
455+
test('replay.run keeps deprecated maestro option as backend alias', async () => {
456+
const setup = createTransport(async () => ({ ok: true, data: {} }));
457+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
458+
459+
await client.replay.run({
460+
path: './flows/login.yaml',
461+
maestro: true,
462+
});
463+
464+
assert.equal(setup.calls.length, 1);
465+
assert.equal(setup.calls[0]?.command, 'replay');
466+
assert.deepEqual(setup.calls[0]?.positionals, ['./flows/login.yaml']);
467+
assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro');
468+
});
469+
438470
test('client.command.wait prepares selector options and rejects invalid selectors', async () => {
439471
const setup = createTransport(async () => ({
440472
ok: true,

src/cli/commands/generic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const genericClientCommandRunners = {
5858
...buildSelectionOptions(flags),
5959
path: required(positionals[0], 'replay requires path'),
6060
update: flags.replayUpdate,
61-
maestro: flags.replayMaestro,
61+
backend: flags.replayMaestro ? 'maestro' : undefined,
6262
env: flags.replayEnv,
6363
}),
6464
test: ({ client, positionals, flags }) => {

0 commit comments

Comments
 (0)