diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74585148e..a0a676c2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,11 +34,49 @@ Run tests: pnpm test ``` +Targeted checks: + +```bash +pnpm check:quick +pnpm check:unit +pnpm exec vitest run src/compat/maestro/__tests__/replay-flow.test.ts src/compat/__tests__/replay-input.test.ts +``` + Optional device selectors for tests: - `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554` - `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=` +## Test App and Maestro Compatibility + +The Expo test app lives in `examples/test-app`. Install its dependencies once: + +```bash +pnpm test-app:install +``` + +For Maestro compatibility, we currently have 15 parser/compat unit tests and one +top-level test-app Maestro flow, `examples/test-app/maestro/checkout-form.yaml`, +which includes `examples/test-app/maestro/helpers/open-checkout-form.yaml`. + +Run only the parser/compat tests: + +```bash +pnpm exec vitest run src/compat/maestro/__tests__/replay-flow.test.ts src/compat/__tests__/replay-input.test.ts +``` + +Run the Expo test-app flow on iOS: + +```bash +pnpm test-app:start +pnpm ad --session test-app-maestro open "Expo Go" exp://127.0.0.1:8081 --platform ios --device "iPhone 17 Pro" +pnpm ad --session test-app-maestro wait "Agent Device Tester" 30000 --platform ios --device "iPhone 17 Pro" +pnpm test-app:maestro:ios -- --session test-app-maestro -- --device "iPhone 17 Pro" +``` + +Use `pnpm test-app:maestro:android` for Android, passing the same extra +`agent-device` flags after `--`. + ## Guidelines - Keep dependencies minimal. diff --git a/examples/test-app/README.md b/examples/test-app/README.md index f36156c90..8b7990ac2 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -76,3 +76,48 @@ pnpm android ``` Once the app is running, use `agent-device` against `Agent Device Tester` like any other target app. + +## Local Agent Device suites + +The repo includes two local suites for iterating on the fixture app: + +```bash +pnpm test-app:replay:ios +pnpm test-app:replay:android +``` + +These run the `.ad` replay suite in `examples/test-app/replays`. + +To target a specific iOS simulator or an installed Expo development build, run the +underlying command directly so global flags stay before replay inputs: + +```bash +node bin/agent-device.mjs test examples/test-app/replays \ + --platform ios \ + --device "iPhone 17 Pro" \ + --env APP_TARGET=dev.expo.easagentdevice \ + --env APP_URL= \ + --artifacts-dir .tmp/test-app-replay/ios +``` + +Use `APP_TARGET=com.callstack.agentdevicelab` when the standalone fixture app is +installed instead of an Expo development shell. + +The Maestro prototype suite lives in `examples/test-app/maestro` and runs through +`agent-device replay --maestro`: + +```bash +pnpm test-app:maestro:ios -- --open "Agent Device Tester" +pnpm test-app:maestro:android -- --open "Agent Device Tester" +``` + +When running through Expo Go, start the project first and pass the shell that is already showing +the app, for example: + +```bash +pnpm test-app:maestro:ios -- --open "Expo Go" +``` + +The suite intentionally covers the compat layer syntax used by public Maestro suites: +`runFlow` file/inline blocks, `when.platform`, config hooks, deterministic `repeat.times`, +flow `env`, selectors, input, assertions, and swipe. diff --git a/examples/test-app/maestro/checkout-form.yaml b/examples/test-app/maestro/checkout-form.yaml new file mode 100644 index 000000000..480dfa5fa --- /dev/null +++ b/examples/test-app/maestro/checkout-form.yaml @@ -0,0 +1,44 @@ +appId: host.exp.Exponent +env: + CHECKOUT_NAME: Ada Lovelace + CHECKOUT_EMAIL: ada@example.com + PICKUP_TAPS: "2" +onFlowStart: + - assertVisible: Agent Device Tester +onFlowComplete: + - assertVisible: Delivery choices +--- +- runFlow: + file: helpers/open-checkout-form.yaml + env: + CHECKOUT_NAME: Ada Lovelace + CHECKOUT_EMAIL: ada@example.com +- runFlow: + when: + platform: iOS + commands: + - assertVisible: Checkout form +- runFlow: + when: + platform: Android + commands: + - assertVisible: Checkout form +- assertVisible: + text: Checkout form +- swipe: + start: 50%, 75% + end: 50%, 35% + duration: 300 +- repeat: + times: ${PICKUP_TAPS} + commands: + - tapOn: + id: shipping-pickup + - assertVisible: + id: shipping-pickup + selected: true +- tapOn: + id: payment-cash +- assertVisible: + id: payment-cash + selected: true diff --git a/examples/test-app/maestro/helpers/open-checkout-form.yaml b/examples/test-app/maestro/helpers/open-checkout-form.yaml new file mode 100644 index 000000000..58298297a --- /dev/null +++ b/examples/test-app/maestro/helpers/open-checkout-form.yaml @@ -0,0 +1,14 @@ +--- +- tapOn: + id: home-open-form +- assertVisible: Checkout form +- tapOn: + id: field-name +- inputText: + text: ${CHECKOUT_NAME} + label: Full name +- tapOn: + id: field-email +- inputText: ${CHECKOUT_EMAIL} +- tapOn: + text: Checkout form diff --git a/examples/test-app/replays/checkout-form-android.ad b/examples/test-app/replays/checkout-form-android.ad new file mode 100644 index 000000000..53b62ff99 --- /dev/null +++ b/examples/test-app/replays/checkout-form-android.ad @@ -0,0 +1,21 @@ +context platform=android timeout=60000 +env APP_TARGET="Agent Device Tester" +env APP_URL="" +open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" +wait "label=\"Form\"" 30000 +click "label=\"Form\"" +wait "Checkout form" 5000 +fill id="field-name" "Ada Lovelace" +fill id="field-email" "ada@example.com" +keyboard dismiss +wait "Checkout form" 5000 +scroll down 0.6 +click id="shipping-pickup" +click id="payment-cash" +wait "Delivery choices" 5000 +scroll down 0.7 +click id="checkbox-agree" +click id="submit-order" +wait "Order summary" 5000 +wait "Ada Lovelace chose pickup with cash payment." 5000 +close diff --git a/examples/test-app/replays/checkout-form.ad b/examples/test-app/replays/checkout-form.ad new file mode 100644 index 000000000..8bd23afbd --- /dev/null +++ b/examples/test-app/replays/checkout-form.ad @@ -0,0 +1,21 @@ +context platform=ios timeout=60000 +env APP_TARGET="Agent Device Tester" +env APP_URL="" +open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" +wait "label=\"Form\"" 30000 +click "label=\"Form\"" +wait "Checkout form" 5000 +fill id="field-name" "Ada Lovelace" +fill id="field-email" "ada@example.com" +keyboard dismiss +wait "Checkout form" 5000 +scroll down 0.6 +click id="shipping-pickup" +click id="payment-cash" +wait "Delivery choices" 5000 +scroll down 0.7 +click id="checkbox-agree" +click id="submit-order" +wait "Order summary" 5000 +wait "Ada Lovelace chose pickup with cash payment." 5000 +close diff --git a/package.json b/package.json index 58b316612..fa30169a8 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,11 @@ "test-app:ios": "pnpm --dir examples/test-app ios", "test-app:android": "pnpm --dir examples/test-app android", "test-app:typecheck": "pnpm --dir examples/test-app typecheck", + "test-app:replay:ios": "pnpm ad test examples/test-app/replays --platform ios --artifacts-dir .tmp/test-app-replay/ios", + "test-app:replay:android": "pnpm ad test examples/test-app/replays --platform android --artifacts-dir .tmp/test-app-replay/android", + "test-app:maestro": "node scripts/run-test-app-maestro-suite.mjs", + "test-app:maestro:ios": "node scripts/run-test-app-maestro-suite.mjs --platform ios", + "test-app:maestro:android": "node scripts/run-test-app-maestro-suite.mjs --platform android", "test": "vitest run --project unit", "test:unit": "vitest run --project unit", "test:coverage": "vitest run --coverage", diff --git a/scripts/run-test-app-maestro-suite.mjs b/scripts/run-test-app-maestro-suite.mjs new file mode 100644 index 000000000..3d9ffcdfb --- /dev/null +++ b/scripts/run-test-app-maestro-suite.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const binPath = path.join(repoRoot, 'bin', 'agent-device.mjs'); + +const options = { + platform: 'ios', + session: 'test-app-maestro', + flowDir: path.join(repoRoot, 'examples', 'test-app', 'maestro'), + openTarget: '', + close: false, + passthrough: [], +}; + +for (let index = 2; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + if (arg === '--') { + options.passthrough.push(...process.argv.slice(index + 1)); + break; + } + if (arg === '--platform' && process.argv[index + 1]) { + options.platform = process.argv[index + 1]; + index += 1; + continue; + } + if (arg === '--session' && process.argv[index + 1]) { + options.session = process.argv[index + 1]; + index += 1; + continue; + } + if (arg === '--flow-dir' && process.argv[index + 1]) { + options.flowDir = path.resolve(process.argv[index + 1]); + index += 1; + continue; + } + if (arg === '--open' && process.argv[index + 1]) { + options.openTarget = process.argv[index + 1]; + index += 1; + continue; + } + if (arg === '--close') { + options.close = true; + continue; + } + options.passthrough.push(arg); +} + +const flows = fs + .readdirSync(options.flowDir) + .filter((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml')) + .sort() + .map((entry) => path.join(options.flowDir, entry)); + +if (flows.length === 0) { + console.error(`No Maestro flows found in ${options.flowDir}`); + process.exit(1); +} + +function runAgentDevice(args) { + execFileSync(process.execPath, [binPath, '--session', options.session, ...args], { + cwd: repoRoot, + stdio: 'inherit', + }); +} + +if (options.openTarget) { + runAgentDevice(['open', options.openTarget, '--platform', options.platform, ...options.passthrough]); + runAgentDevice(['wait', 'Agent Device Tester', '30000', '--platform', options.platform, ...options.passthrough]); +} + +for (const flow of flows) { + runAgentDevice(['replay', flow, '--maestro', '--platform', options.platform, ...options.passthrough]); +} + +if (options.close) { + runAgentDevice(['close']); +} diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 18283f074..b70cd9c94 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -399,6 +399,7 @@ test('client throws AppError for daemon failures', async () => { ); }); +// fallow-ignore-next-line complexity test('replay.run serializes client-collected AD_VAR shell env into daemon request', async () => { const previousAppId = process.env.AD_VAR_APP_ID; 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 assert.equal(setup.calls[0]?.command, 'replay'); assert.deepEqual(setup.calls[0]?.positionals, ['./flows/login.ad']); assert.deepEqual(setup.calls[0]?.flags?.replayEnv, ['APP_ID=cli-override']); + assert.equal(setup.calls[0]?.flags?.replayBackend, undefined); const replayShellEnv = setup.calls[0]?.flags?.replayShellEnv as | Record | undefined; @@ -435,6 +437,36 @@ test('replay.run serializes client-collected AD_VAR shell env into daemon reques } }); +test('replay.run forwards backend without knowing the concrete syntax', async () => { + const setup = createTransport(async () => ({ ok: true, data: {} })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.replay.run({ + path: './flows/login.yaml', + backend: 'external-flow', + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'replay'); + assert.deepEqual(setup.calls[0]?.positionals, ['./flows/login.yaml']); + assert.equal(setup.calls[0]?.flags?.replayBackend, 'external-flow'); +}); + +test('replay.run keeps deprecated maestro option as backend alias', async () => { + const setup = createTransport(async () => ({ ok: true, data: {} })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.replay.run({ + path: './flows/login.yaml', + maestro: true, + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'replay'); + assert.deepEqual(setup.calls[0]?.positionals, ['./flows/login.yaml']); + assert.equal(setup.calls[0]?.flags?.replayBackend, 'maestro'); +}); + test('client.command.wait prepares selector options and rejects invalid selectors', async () => { const setup = createTransport(async () => ({ ok: true, diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index bb83eea98..77b583c02 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -58,7 +58,7 @@ const genericClientCommandRunners = { ...buildSelectionOptions(flags), path: required(positionals[0], 'replay requires path'), update: flags.replayUpdate, - maestro: flags.replayMaestro, + backend: flags.replayMaestro ? 'maestro' : undefined, env: flags.replayEnv, }), test: ({ client, positionals, flags }) => { diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index d1a6e4465..aea1bfca7 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -309,7 +309,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { headless: options.headless, restart: options.restart, replayUpdate: options.replayUpdate, - replayMaestro: options.replayMaestro, + replayBackend: options.replayBackend, replayEnv: options.replayEnv, replayShellEnv: options.replayShellEnv, failFast: options.failFast, diff --git a/src/client-types.ts b/src/client-types.ts index 58901aa6a..4cc91ba52 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -624,7 +624,9 @@ export type FindOptions = export type ReplayRunOptions = AgentDeviceRequestOverrides & { path: string; update?: boolean; + /** @deprecated Use backend: 'maestro'. */ maestro?: boolean; + backend?: string; env?: string[]; }; @@ -759,7 +761,7 @@ type CommandExecutionOptions = Partial & { headless?: boolean; restart?: boolean; replayUpdate?: boolean; - replayMaestro?: boolean; + replayBackend?: string; replayEnv?: string[]; replayShellEnv?: Record; failFast?: boolean; diff --git a/src/client.ts b/src/client.ts index 235e25fee..e58cf2a16 100644 --- a/src/client.ts +++ b/src/client.ts @@ -380,7 +380,7 @@ export function createAgentDeviceClient( await executeCommandRequest(PUBLIC_COMMANDS.replay, [options.path], { ...options, replayUpdate: options.update, - replayMaestro: options.maestro, + replayBackend: options.backend ?? (options.maestro === true ? 'maestro' : undefined), replayEnv: options.env, replayShellEnv: collectReplayClientShellEnv(process.env), }), diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 5244f5151..a19d3c12d 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -94,6 +94,7 @@ export const DAEMON_COMMAND_GROUPS = { PUBLIC_COMMANDS.longPress, PUBLIC_COMMANDS.pinch, PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.rotate, PUBLIC_COMMANDS.screenshot, PUBLIC_COMMANDS.scroll, diff --git a/src/compat/__tests__/replay-input.test.ts b/src/compat/__tests__/replay-input.test.ts new file mode 100644 index 000000000..225b5bc56 --- /dev/null +++ b/src/compat/__tests__/replay-input.test.ts @@ -0,0 +1,76 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { AppError } from '../../utils/errors.ts'; +import { parseReplayInput } from '../replay-input.ts'; + +test('parseReplayInput routes native replay scripts through the native parser', () => { + const parsed = parseReplayInput('open Demo\nwait "Ready" 5000\n', undefined); + + assert.equal(parsed.updateUnsupportedMessage, undefined); + assert.deepEqual( + parsed.actions.map((action) => [action.command, action.positionals]), + [ + ['open', ['Demo']], + ['wait', ['Ready', '5000']], + ], + ); +}); + +test('parseReplayInput routes compat replay scripts through the selected parser', () => { + const parsed = parseReplayInput( + `appId: com.callstack.agentdevicelab +--- +- launchApp +- tapOn: + id: submit-order +`, + { replayBackend: 'maestro' }, + ); + + assert.match(parsed.updateUnsupportedMessage ?? '', /Convert to \.ad/); + assert.deepEqual( + parsed.actions.map((action) => [action.command, action.positionals]), + [ + ['open', ['com.callstack.agentdevicelab']], + ['click', ['id="submit-order"']], + ], + ); +}); + +test('parseReplayInput applies replay env precedence before compat parsing', () => { + const parsed = parseReplayInput( + `appId: \${APP_ID} +env: + APP_ID: yaml-app + BUTTON_ID: yaml-button +--- +- launchApp +- tapOn: + id: \${BUTTON_ID} +`, + { + replayBackend: 'maestro', + replayShellEnv: { AD_VAR_APP_ID: 'shell-app', AD_VAR_BUTTON_ID: 'shell-button' }, + replayEnv: ['APP_ID=cli-app'], + }, + ); + + assert.equal(parsed.metadata.env?.APP_ID, 'yaml-app'); + assert.deepEqual( + parsed.actions.map((action) => [action.command, action.positionals]), + [ + ['open', ['cli-app']], + ['click', ['id="shell-button"']], + ], + ); +}); + +test('parseReplayInput rejects unknown replay backends', () => { + assert.throws( + () => parseReplayInput('open Demo\n', { replayBackend: 'unknown' }), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /Unsupported replay backend "unknown"/.test(error.message), + ); +}); diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts new file mode 100644 index 000000000..cf17308d5 --- /dev/null +++ b/src/compat/maestro/__tests__/replay-flow.test.ts @@ -0,0 +1,382 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { AppError } from '../../../utils/errors.ts'; +import { parseMaestroReplayFlow } from '../replay-flow.ts'; + +test('parseMaestroReplayFlow converts a supported Maestro command subset', () => { + const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +env: + USER_NAME: Ada +--- +- launchApp +- tapOn: + id: home-open-form +- doubleTapOn: + id: release-notice + delay: 150 +- longPressOn: + text: Agent Device Tester +- openLink: exp://localhost:8082 +- tapOn: Full name +- inputText: + text: Ada Lovelace + label: Full name +- assertVisible: + text: Checkout form +- assertNotVisible: + text: Missing banner +- extendedWaitUntil: + visible: + id: submit-order + timeout: 7000 +- scroll +- swipe: + start: 50%, 75% + end: 50%, 35% + duration: 300 +- takeScreenshot: ./screens/form.png +- hideKeyboard +- stopApp +`); + + assert.equal(parsed.metadata.env?.USER_NAME, 'Ada'); + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [ + ['open', ['com.callstack.agentdevicelab']], + ['click', ['id="home-open-form"']], + ['click', ['id="release-notice"']], + ['click', ['label="Agent Device Tester"']], + ['open', ['exp://localhost:8082']], + ['click', ['label="Full name" || text="Full name" || id="Full name"']], + ['type', ['Ada Lovelace']], + ['wait', ['label="Checkout form"', '5000']], + ['is', ['hidden', 'label="Missing banner"']], + ['wait', ['id="submit-order"', '7000']], + ['scroll', ['down']], + ['scroll', ['down', '0.4']], + ['screenshot', ['./screens/form.png']], + ['keyboard', ['dismiss']], + ['close', ['com.callstack.agentdevicelab']], + ], + ); + assert.equal(parsed.actions[2]?.flags.doubleTap, true); + assert.equal(parsed.actions[2]?.flags.intervalMs, 150); + assert.equal(parsed.actions[3]?.flags.holdMs, 3000); +}); + +test('parseMaestroReplayFlow rejects unsupported Maestro commands', () => { + assert.throws( + () => parseMaestroReplayFlow('---\n- scrollUntilVisible: Save\n'), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /scrollUntilVisible/.test(error.message) && + /issues\/558/.test(error.message) && + /issues\/new/.test(error.message) && + /line 2/.test(error.message), + ); +}); + +test('parseMaestroReplayFlow preserves selector state and absolute swipe commands', () => { + const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- assertVisible: + id: shipping-pickup + selected: true +- swipe: + start: 100, 500 + end: 100, 200 + duration: 300 +`); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [ + ['wait', ['id="shipping-pickup" selected="true"', '5000']], + ['swipe', ['100', '500', '100', '200', '300']], + ], + ); + assert.deepEqual(parsed.actionLines, [3, 6]); +}); + +test('parseMaestroReplayFlow maps easy Maestro device and utility commands', () => { + const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +env: + VIDEO_PATH: ./recordings/checkout.mp4 +--- +- setAirplaneMode: true +- setAirplaneMode: false +- setLocation: + latitude: 52.2297 + longitude: 21.0122 +- setOrientation: landscapeLeft +- setPermissions: + camera: allow + microphone: deny + photos: unset + location: always +- killApp +- killApp: com.callstack.other +- pasteText: hello there +- startRecording: + path: \${VIDEO_PATH} +- stopRecording +- assertTrue: true +`); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [ + ['settings', ['airplane', 'on']], + ['settings', ['airplane', 'off']], + ['settings', ['location', 'set', '52.2297', '21.0122']], + ['rotate', ['landscape-left']], + ['settings', ['permission', 'grant', 'camera']], + ['settings', ['permission', 'deny', 'microphone']], + ['settings', ['permission', 'reset', 'photos']], + ['settings', ['permission', 'grant', 'location-always']], + ['close', ['com.callstack.agentdevicelab']], + ['close', ['com.callstack.other']], + ['type', ['hello there']], + ['record', ['start', './recordings/checkout.mp4']], + ['record', ['stop']], + ], + ); +}); + +test('parseMaestroReplayFlow rejects unsupported easy-mapping variants loudly', () => { + assert.throws( + () => parseMaestroReplayFlow('---\n- assertTrue: "${READY}"\n'), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /assertTrue/.test(error.message) && + /issues\/558/.test(error.message) && + /line 2/.test(error.message), + ); + + assert.throws( + () => parseMaestroReplayFlow('---\n- setPermissions:\n camera: always\n'), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /setPermissions state "always"/.test(error.message) && + /issues\/558/.test(error.message) && + /line 2/.test(error.message), + ); +}); + +test('parseMaestroReplayFlow rejects unsupported fields instead of ignoring them', () => { + assert.throws( + () => + parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- tapOn: + id: submit-order + retryTapIfNoChange: true +`), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /retryTapIfNoChange/.test(error.message) && + /issues\/558/.test(error.message) && + /line 3/.test(error.message), + ); +}); + +test('parseMaestroReplayFlow reports top-level command lines around nested lists', () => { + assert.throws( + () => + parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- runFlow: + commands: + - tapOn: Nested +- scrollUntilVisible: Save +`), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /scrollUntilVisible/.test(error.message) && + /line 6/.test(error.message), + ); +}); + +test('parseMaestroReplayFlow flattens hooks, file runFlow, inline runFlow, env, and repeat times', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-flow-')); + const childPath = path.join(root, 'child.yaml'); + fs.writeFileSync( + childPath, + `appId: com.child.app +--- +- tapOn: "\${CHILD_LABEL}" +- repeat: + times: \${COUNT} + commands: + - tapOn: + id: child-repeat +`, + ); + + const parsed = parseMaestroReplayFlow( + `appId: com.callstack.agentdevicelab +env: + COUNT: "2" +onFlowStart: + - tapOn: Before +onFlowComplete: + - tapOn: After +--- +- runFlow: + file: child.yaml + env: + CHILD_LABEL: Nested +- runFlow: + when: + platform: iOS + commands: + - tapOn: iOS only +- repeat: + times: 2 + commands: + - tapOn: Again +`, + { sourcePath: path.join(root, 'main.yaml'), platform: 'ios' }, + ); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [ + ['click', ['label="Before" || text="Before" || id="Before"']], + ['click', ['label="Nested" || text="Nested" || id="Nested"']], + ['click', ['id="child-repeat"']], + ['click', ['id="child-repeat"']], + ['click', ['label="iOS only" || text="iOS only" || id="iOS only"']], + ['click', ['label="Again" || text="Again" || id="Again"']], + ['click', ['label="Again" || text="Again" || id="Again"']], + ['click', ['label="After" || text="After" || id="After"']], + ], + ); +}); + +test('parseMaestroReplayFlow skips platform-gated runFlow commands for other platforms', () => { + const parsed = parseMaestroReplayFlow( + `appId: com.callstack.agentdevicelab +--- +- runFlow: + when: + platform: Android + commands: + - tapOn: Android only +- tapOn: Shared +`, + { platform: 'ios' }, + ); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals]), + [['click', ['label="Shared" || text="Shared" || id="Shared"']]], + ); +}); + +test('parseMaestroReplayFlow tolerates false launchApp reset options and rejects reset side effects', () => { + const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- launchApp: + clearState: false + clearKeychain: false + stopApp: true +`); + + assert.deepEqual( + parsed.actions.map((entry) => [entry.command, entry.positionals, entry.flags]), + [['open', ['com.callstack.agentdevicelab'], { relaunch: true }]], + ); + + assert.throws( + () => + parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- launchApp: + clearState: true +`), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /clearState: true/.test(error.message) && + /line 3/.test(error.message), + ); +}); + +test('parseMaestroReplayFlow rejects runtime-dependent flow control for now', () => { + assert.throws( + () => + parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- runFlow: + when: + visible: Continue + commands: + - tapOn: Continue +`), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /when.visible/.test(error.message) && + /line 3/.test(error.message), + ); + + assert.throws( + () => + parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab +--- +- repeat: + while: + notVisible: Done + times: 3 + commands: + - tapOn: Again +`), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /repeat.while/.test(error.message) && + /line 3/.test(error.message), + ); +}); + +test('parseMaestroReplayFlow parses the test-app Maestro suite fixture', () => { + const fixturePath = path.resolve('examples/test-app/maestro/checkout-form.yaml'); + const parsed = parseMaestroReplayFlow(fs.readFileSync(fixturePath, 'utf8'), { + sourcePath: fixturePath, + platform: 'ios', + }); + + assert.deepEqual( + parsed.actions.map((entry) => entry.command), + [ + 'wait', + 'click', + 'wait', + 'click', + 'type', + 'click', + 'type', + 'click', + 'wait', + 'wait', + 'scroll', + 'click', + 'wait', + 'click', + 'wait', + 'click', + 'wait', + 'wait', + ], + ); +}); diff --git a/src/compat/maestro/command-mapper.ts b/src/compat/maestro/command-mapper.ts new file mode 100644 index 000000000..76a3bc1b7 --- /dev/null +++ b/src/compat/maestro/command-mapper.ts @@ -0,0 +1,274 @@ +import type { SessionAction } from '../../daemon/types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + convertAssertTrue, + convertKillApp, + convertLaunchApp, + convertSetAirplaneMode, + convertSetLocation, + convertSetOrientation, + convertSetPermissions, + convertStartRecording, + convertStopApp, + convertStopRecording, +} from './device-actions.ts'; +import { + convertDoubleTapOn, + convertExtendedWaitUntil, + convertLongPressOn, + convertPressKey, + convertScroll, + convertSwipe, + convertTapOn, + maestroSelector, + readInputText, +} from './interactions.ts'; +import { + action, + assertOnlyKeys, + isPlainRecord, + normalizeCommandList, + normalizePlatformValue, + readEnvMap, + readTimeoutMs, + requireStringValue, + resolveMaestroString, + unsupportedCommand, + unsupportedMaestroSyntax, +} from './support.ts'; +import type { + MaestroCommand, + MaestroCommandMapperDeps, + MaestroFlowConfig, + MaestroParseContext, +} from './types.ts'; + +const MAX_REPEAT_EXPANSIONS = 100; +type MaestroCommandHandler = (params: { + value: unknown; + config: MaestroFlowConfig; + context: MaestroParseContext; + deps: MaestroCommandMapperDeps; + name: string; +}) => SessionAction[]; + +const MAP_COMMAND_HANDLERS: Record = { + launchApp: ({ value, config, context }) => [convertLaunchApp(value, config, context)], + tapOn: ({ value, context }) => [convertTapOn(value, context)], + doubleTapOn: ({ value, context }) => [convertDoubleTapOn(value, context)], + longPressOn: ({ value, context }) => [convertLongPressOn(value, context)], + inputText: ({ value, context }) => [ + action('type', [resolveMaestroString(readInputText(value), context)]), + ], + pasteText: ({ value, context, name }) => [ + action('type', [resolveMaestroString(requireStringValue(name, value), context)]), + ], + openLink: ({ value, context, name }) => [ + action('open', [resolveMaestroString(requireStringValue(name, value), context)]), + ], + assertVisible: ({ value, context, name }) => [ + action('wait', [maestroSelector(value, name, [], context), '5000']), + ], + assertNotVisible: ({ value, context, name }) => [ + action('is', ['hidden', maestroSelector(value, name, [], context)]), + ], + assertTrue: ({ value, context }) => convertAssertTrue(value, context), + extendedWaitUntil: ({ value, context }) => convertExtendedWaitUntil(value, context), + takeScreenshot: ({ value, context, name }) => [ + action('screenshot', [resolveMaestroString(requireStringValue(name, value), context)]), + ], + scroll: ({ value }) => [convertScroll(value)], + swipe: ({ value }) => [convertSwipe(value)], + hideKeyboard: () => [action('keyboard', ['dismiss'])], + pressKey: ({ value }) => [convertPressKey(value)], + back: () => [action('back')], + waitForAnimationToEnd: ({ value }) => [action('wait', [String(readTimeoutMs(value, 250))])], + stopApp: ({ value, config, context }) => [convertStopApp(value, config, context)], + killApp: ({ value, config, context }) => [convertKillApp(value, config, context)], + setAirplaneMode: ({ value, context }) => [convertSetAirplaneMode(value, context)], + setLocation: ({ value, context }) => [convertSetLocation(value, context)], + setOrientation: ({ value, context }) => [convertSetOrientation(value, context)], + setPermissions: ({ value, context }) => convertSetPermissions(value, context), + startRecording: ({ value, context }) => [convertStartRecording(value, context)], + stopRecording: ({ value }) => [convertStopRecording(value)], + runFlow: ({ value, config, context, deps }) => convertRunFlow(value, config, context, deps), + repeat: ({ value, config, context, deps }) => convertRepeat(value, config, context, deps), +}; + +const SCALAR_COMMAND_HANDLERS: Record< + string, + (config: MaestroFlowConfig, context: MaestroParseContext) => SessionAction[] +> = { + launchApp: (config, context) => [convertLaunchApp(undefined, config, context)], + scroll: () => [action('scroll', ['down'])], + hideKeyboard: () => [action('keyboard', ['dismiss'])], + back: () => [action('back')], + waitForAnimationToEnd: () => [action('wait', ['250'])], + stopApp: (config, context) => [convertStopApp(undefined, config, context)], + killApp: (config, context) => [convertKillApp(undefined, config, context)], + startRecording: () => [action('record', ['start'])], + stopRecording: () => [action('record', ['stop'])], +}; + +export function convertMaestroCommandWithLine( + command: MaestroCommand, + config: MaestroFlowConfig, + line: number, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, +): SessionAction[] { + try { + return convertMaestroCommand(command, config, context, deps); + } catch (error) { + if (error instanceof AppError && !/\bline \d+\b/.test(error.message)) { + throw new AppError(error.code, `${error.message} (line ${line})`, error.details); + } + throw error; + } +} + +function convertMaestroCommand( + command: MaestroCommand, + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, +): SessionAction[] { + if (typeof command === 'string') return convertScalarCommand(command, config, context); + + const entries = Object.entries(command); + if (entries.length !== 1) { + throw new AppError('INVALID_ARGS', 'Maestro command maps must contain exactly one command.'); + } + + const [name, value] = entries[0] as [string, unknown]; + const handler = MAP_COMMAND_HANDLERS[name]; + if (!handler) return unsupportedCommand(name); + return handler({ value, config, context, deps, name }); +} + +function convertScalarCommand( + command: string, + config: MaestroFlowConfig, + context: MaestroParseContext, +): SessionAction[] { + const handler = SCALAR_COMMAND_HANDLERS[command]; + if (!handler) return unsupportedCommand(command); + return handler(config, context); +} + +function convertRunFlow( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, +): SessionAction[] { + if (typeof value === 'string') { + return deps.parseRunFlowFile(resolveMaestroString(value, context), context).actions; + } + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'runFlow expects a file path string or map.'); + } + assertOnlyKeys(value, 'runFlow', ['file', 'commands', 'env', 'when', 'label']); + if (!shouldRunFlow(value.when, context)) return []; + + const runContext = { + ...context, + env: { ...context.env, ...readEnvMap(value.env, 'runFlow.env'), ...context.envOverrides }, + }; + if (typeof value.file === 'string') { + return deps.parseRunFlowFile(resolveMaestroString(value.file, runContext), runContext).actions; + } + if (Array.isArray(value.commands)) { + return convertCommandList(normalizeCommandList(value.commands), config, runContext, deps); + } + throw new AppError('INVALID_ARGS', 'runFlow map requires either file or commands.'); +} + +function convertRepeat( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, +): SessionAction[] { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'repeat expects a map.'); + } + assertOnlyKeys(value, 'repeat', ['times', 'commands', 'while']); + if (value.while !== undefined) { + throw unsupportedMaestroSyntax( + 'Maestro repeat.while is not supported yet. Only deterministic repeat.times is supported.', + ); + } + const times = readRepeatTimes(value.times, context); + if (!Array.isArray(value.commands)) { + throw new AppError('INVALID_ARGS', 'repeat requires a commands list.'); + } + if (times > MAX_REPEAT_EXPANSIONS) { + throw new AppError( + 'INVALID_ARGS', + `repeat.times must be <= ${MAX_REPEAT_EXPANSIONS} for deterministic replay expansion.`, + ); + } + const commands = normalizeCommandList(value.commands); + return Array.from({ length: times }).flatMap(() => + convertCommandList(commands, config, context, deps), + ); +} + +function convertCommandList( + commands: MaestroCommand[], + config: MaestroFlowConfig, + context: MaestroParseContext, + deps: MaestroCommandMapperDeps, +): SessionAction[] { + return commands.flatMap((command, index) => + convertMaestroCommandWithLine(command, config, index + 1, context, deps), + ); +} + +function shouldRunFlow(value: unknown, context: MaestroParseContext): boolean { + if (value === undefined || value === null) return true; + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'runFlow.when expects a map.'); + } + assertOnlyKeys(value, 'runFlow.when', ['platform', 'visible', 'notVisible', 'true']); + rejectUnsupportedCondition(value, 'visible', 'when.visible'); + rejectUnsupportedCondition(value, 'notVisible', 'when.notVisible'); + rejectUnsupportedCondition(value, 'true', 'when.true'); + if (value.platform === undefined) return true; + const platform = normalizePlatformValue(value.platform, 'runFlow.when.platform'); + if (!context.platform) { + throw new AppError( + 'INVALID_ARGS', + 'Maestro runFlow.when.platform requires replay to be run with --platform ios|android.', + ); + } + return platform === context.platform; +} + +function readRepeatTimes(value: unknown, context: MaestroParseContext): number { + const resolved = typeof value === 'string' ? resolveMaestroString(value, context) : value; + const numeric = + typeof resolved === 'number' + ? resolved + : typeof resolved === 'string' && /^\d+$/.test(resolved) + ? Number(resolved) + : undefined; + if (numeric === undefined || !Number.isInteger(numeric) || numeric < 0) { + throw new AppError( + 'INVALID_ARGS', + 'repeat.times must be a non-negative integer or ${VAR} resolving to one.', + ); + } + return numeric; +} + +function rejectUnsupportedCondition( + value: Record, + key: string, + label: string, +): void { + if (value[key] !== undefined) { + throw unsupportedMaestroSyntax(`Maestro ${label} is not supported yet.`); + } +} diff --git a/src/compat/maestro/device-actions.ts b/src/compat/maestro/device-actions.ts new file mode 100644 index 000000000..95e33db4d --- /dev/null +++ b/src/compat/maestro/device-actions.ts @@ -0,0 +1,270 @@ +import type { SessionAction } from '../../daemon/types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + action, + assertOnlyKeys, + isPlainRecord, + normalizeToken, + readBooleanLiteral, + requireAppId, + resolveMaestroString, + resolveMaybeMaestroString, + unsupportedMaestroSyntax, +} from './support.ts'; +import type { MaestroFlowConfig, MaestroParseContext, PermissionCommand } from './types.ts'; + +const SUPPORTED_PERMISSION_TARGETS = new Set([ + 'accessibility', + 'calendar', + 'camera', + 'contacts', + 'contacts-limited', + 'input-monitoring', + 'location', + 'location-always', + 'media-library', + 'microphone', + 'motion', + 'notifications', + 'photos', + 'reminders', + 'screen-recording', + 'siri', +]); + +const BASIC_PERMISSION_STATES: Record = { + allow: 'grant', + grant: 'grant', + granted: 'grant', + deny: 'deny', + denied: 'deny', + reset: 'reset', + unset: 'reset', + revoke: 'reset', + revoked: 'reset', +}; + +const MODE_PERMISSION_STATES: Record = { + limited: { command: 'grant', mode: 'limited' }, + full: { command: 'grant', mode: 'full' }, +}; + +export function convertLaunchApp( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, +): SessionAction { + if (value === null || value === undefined) { + return action('open', [resolveMaestroString(requireAppId(config, 'launchApp'), context)]); + } + if (typeof value === 'string') return action('open', [resolveMaestroString(value, context)]); + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'launchApp expects a string or map.'); + } + assertOnlyKeys(value, 'launchApp', [ + 'appId', + 'stopApp', + 'clearState', + 'clearKeychain', + 'arguments', + 'permissions', + 'launchArguments', + ]); + rejectTruthyLaunchOption(value, 'clearState'); + rejectTruthyLaunchOption(value, 'clearKeychain'); + rejectUnsupportedLaunchOption(value, 'arguments'); + rejectUnsupportedLaunchOption(value, 'permissions'); + rejectUnsupportedLaunchOption(value, 'launchArguments'); + const appId = resolveMaestroString( + typeof value.appId === 'string' ? value.appId : requireAppId(config, 'launchApp'), + context, + ); + return action('open', [appId], { relaunch: value.stopApp === true }); +} + +export function convertStopApp( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, +): SessionAction { + if (value === null || value === undefined) { + return action('close', [resolveMaestroString(requireAppId(config, 'stopApp'), context)]); + } + if (typeof value === 'string') return action('close', [resolveMaestroString(value, context)]); + throw new AppError('INVALID_ARGS', 'stopApp expects a string appId or no value.'); +} + +export function convertSetAirplaneMode( + value: unknown, + context: MaestroParseContext, +): SessionAction { + const enabled = readBooleanLiteral(resolveMaybeMaestroString(value, context), 'setAirplaneMode'); + return action('settings', ['airplane', enabled ? 'on' : 'off']); +} + +export function convertSetLocation(value: unknown, context: MaestroParseContext): SessionAction { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'setLocation expects a map.'); + } + assertOnlyKeys(value, 'setLocation', ['latitude', 'longitude', 'lat', 'lon', 'lng']); + const latitude = readCoordinate(value.latitude ?? value.lat, 'setLocation.latitude', context); + const longitude = readCoordinate( + value.longitude ?? value.lon ?? value.lng, + 'setLocation.longitude', + context, + ); + return action('settings', ['location', 'set', latitude, longitude]); +} + +export function convertSetOrientation(value: unknown, context: MaestroParseContext): SessionAction { + const raw = resolveMaybeMaestroString(value, context); + if (typeof raw !== 'string') { + throw new AppError('INVALID_ARGS', 'setOrientation expects a string value.'); + } + const orientation = normalizeToken(raw); + switch (orientation) { + case 'portrait': + case 'landscape-left': + case 'landscape-right': + return action('rotate', [orientation]); + case 'portrait-upside-down': + case 'upside-down': + return action('rotate', ['portrait-upside-down']); + default: + throw unsupportedMaestroSyntax( + `Maestro setOrientation "${raw}" cannot be mapped to a supported rotate orientation.`, + ); + } +} + +export function convertSetPermissions( + value: unknown, + context: MaestroParseContext, +): SessionAction[] { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'setPermissions expects a map.'); + } + return Object.entries(value).map(([rawTarget, rawState]) => { + const { target, command, mode } = readPermissionMapping(rawTarget, rawState, context); + return action('settings', ['permission', command, target, ...(mode ? [mode] : [])]); + }); +} + +export function convertKillApp( + value: unknown, + config: MaestroFlowConfig, + context: MaestroParseContext, +): SessionAction { + if (value === null || value === undefined) { + return action('close', [resolveMaestroString(requireAppId(config, 'killApp'), context)]); + } + if (typeof value === 'string') return action('close', [resolveMaestroString(value, context)]); + throw new AppError('INVALID_ARGS', 'killApp expects a string appId or no value.'); +} + +export function convertStartRecording(value: unknown, context: MaestroParseContext): SessionAction { + if (value === null || value === undefined) return action('record', ['start']); + if (typeof value === 'string') + return action('record', ['start', resolveMaestroString(value, context)]); + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'startRecording expects a string path, map, or no value.'); + } + assertOnlyKeys(value, 'startRecording', ['path', 'file']); + const rawPath = value.path ?? value.file; + if (rawPath === undefined) return action('record', ['start']); + if (typeof rawPath !== 'string') { + throw new AppError('INVALID_ARGS', 'startRecording path must be a string.'); + } + return action('record', ['start', resolveMaestroString(rawPath, context)]); +} + +export function convertStopRecording(value: unknown): SessionAction { + if (value !== null && value !== undefined) { + throw new AppError('INVALID_ARGS', 'stopRecording expects no value.'); + } + return action('record', ['stop']); +} + +export function convertAssertTrue(value: unknown, context: MaestroParseContext): SessionAction[] { + const resolved = resolveMaybeMaestroString(value, context); + if (resolved === true || (typeof resolved === 'string' && normalizeToken(resolved) === 'true')) { + return []; + } + if ( + resolved === false || + (typeof resolved === 'string' && normalizeToken(resolved) === 'false') + ) { + throw new AppError('INVALID_ARGS', 'Maestro assertTrue literal evaluated to false.'); + } + throw unsupportedMaestroSyntax('Only literal Maestro assertTrue true/false is supported.'); +} + +function readCoordinate(value: unknown, name: string, context: MaestroParseContext): string { + const resolved = resolveMaybeMaestroString(value, context); + const numeric = + typeof resolved === 'number' + ? resolved + : typeof resolved === 'string' && resolved.trim().length > 0 + ? Number(resolved) + : Number.NaN; + if (!Number.isFinite(numeric)) { + throw new AppError('INVALID_ARGS', `${name} must be a finite number.`); + } + return String(numeric); +} + +function readPermissionMapping( + rawTarget: string, + rawState: unknown, + context: MaestroParseContext, +): { target: string; command: PermissionCommand; mode?: string } { + let target = normalizeToken(rawTarget); + const resolvedState = resolveMaybeMaestroString(rawState, context); + if (typeof resolvedState !== 'string') { + throw new AppError('INVALID_ARGS', `setPermissions.${rawTarget} expects a string state.`); + } + const state = normalizeToken(resolvedState); + if (target === 'location' && state === 'always') target = 'location-always'; + + if (!SUPPORTED_PERMISSION_TARGETS.has(target)) { + throw unsupportedMaestroSyntax( + `Maestro setPermissions target "${rawTarget}" cannot be mapped to a supported settings permission target.`, + ); + } + + const basicCommand = BASIC_PERMISSION_STATES[state]; + if (basicCommand) return { target, command: basicCommand }; + + const modeMapping = MODE_PERMISSION_STATES[state]; + if (modeMapping) return { target, ...modeMapping }; + + const locationCommand = readLocationPermissionCommand(target, state); + if (locationCommand) return { target, command: locationCommand }; + + throw unsupportedMaestroSyntax( + `Maestro setPermissions state "${resolvedState}" cannot be mapped to grant, deny, or reset.`, + ); +} + +function readLocationPermissionCommand( + target: string, + state: string, +): PermissionCommand | undefined { + if (target === 'location-always' && state === 'always') return 'grant'; + if (target === 'location' && (state === 'while-in-use' || state === 'when-in-use')) { + return 'grant'; + } + return undefined; +} + +function rejectTruthyLaunchOption(value: Record, key: string): void { + if (value[key] === true) { + throw unsupportedMaestroSyntax(`Maestro launchApp ${key}: true is not supported yet.`); + } +} + +function rejectUnsupportedLaunchOption(value: Record, key: string): void { + if (value[key] !== undefined) { + throw unsupportedMaestroSyntax(`Maestro launchApp field "${key}" is not supported yet.`); + } +} diff --git a/src/compat/maestro/interactions.ts b/src/compat/maestro/interactions.ts new file mode 100644 index 000000000..c61271606 --- /dev/null +++ b/src/compat/maestro/interactions.ts @@ -0,0 +1,262 @@ +import type { SessionAction } from '../../daemon/types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + action, + assertOnlyKeys, + isPlainRecord, + readTimeoutMs, + requireStringValue, + resolveMaestroString, + unsupportedMaestroSyntax, +} from './support.ts'; +import type { MaestroParseContext } from './types.ts'; + +export function convertTapOn(value: unknown, context: MaestroParseContext): SessionAction { + if (isPlainRecord(value) && typeof value.point === 'string') { + assertOnlyKeys(value, 'tapOn', ['point', 'repeat', 'delay']); + const point = parsePoint(value.point); + return action('click', [String(point.x), String(point.y)], tapFlags(value)); + } + if (isPlainRecord(value)) { + assertOnlyKeys(value, 'tapOn', [ + 'id', + 'text', + 'enabled', + 'selected', + 'repeat', + 'delay', + 'optional', + 'label', + ]); + } + return action( + 'click', + [maestroSelector(value, 'tapOn', ['repeat', 'delay', 'optional', 'label'], context)], + tapFlags(value), + ); +} + +export function convertDoubleTapOn(value: unknown, context: MaestroParseContext): SessionAction { + if (isPlainRecord(value) && typeof value.point === 'string') { + assertOnlyKeys(value, 'doubleTapOn', ['point', 'delay']); + const point = parsePoint(value.point); + return action('click', [String(point.x), String(point.y)], doubleTapFlags(value)); + } + if (isPlainRecord(value)) { + assertOnlyKeys(value, 'doubleTapOn', ['id', 'text', 'enabled', 'selected', 'delay']); + } + return action( + 'click', + [maestroSelector(value, 'doubleTapOn', ['delay'], context)], + doubleTapFlags(value), + ); +} + +export function convertLongPressOn(value: unknown, context: MaestroParseContext): SessionAction { + if (isPlainRecord(value) && typeof value.point === 'string') { + assertOnlyKeys(value, 'longPressOn', ['point']); + const point = parsePoint(value.point); + return action('longpress', [String(point.x), String(point.y), '3000']); + } + if (isPlainRecord(value)) { + assertOnlyKeys(value, 'longPressOn', ['id', 'text', 'enabled', 'selected']); + } + return action('click', [maestroSelector(value, 'longPressOn', [], context)], { holdMs: 3000 }); +} + +export function readInputText(value: unknown): string { + if (typeof value === 'string') return value; + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'inputText expects a string or map.'); + } + assertOnlyKeys(value, 'inputText', ['text', 'label']); + if (typeof value.text !== 'string') { + throw new AppError('INVALID_ARGS', 'inputText map requires a string text field.'); + } + return value.text; +} + +export function convertExtendedWaitUntil( + value: unknown, + context: MaestroParseContext, +): SessionAction[] { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'extendedWaitUntil expects a map.'); + } + assertOnlyKeys(value, 'extendedWaitUntil', ['visible', 'notVisible', 'timeout']); + const target = value.visible ?? value.notVisible; + if (target === undefined) { + throw unsupportedMaestroSyntax( + 'Only Maestro extendedWaitUntil.visible/notVisible is supported.', + ); + } + const selector = maestroSelector(target, 'extendedWaitUntil', [], context); + const timeoutMs = String(readTimeoutMs(value, 30000)); + if (value.notVisible !== undefined) { + return [action('wait', [timeoutMs]), action('is', ['hidden', selector])]; + } + return [action('wait', [selector, timeoutMs])]; +} + +export function convertScroll(value: unknown): SessionAction { + if (value !== null && value !== undefined) { + throw unsupportedMaestroSyntax('Maestro scroll options are not supported yet.'); + } + return action('scroll', ['down']); +} + +export function convertSwipe(value: unknown): SessionAction { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'swipe expects a map.'); + } + assertOnlyKeys(value, 'swipe', ['start', 'end', 'duration']); + if (typeof value.start !== 'string' || typeof value.end !== 'string') { + throw unsupportedMaestroSyntax('Only Maestro swipe start/end coordinates are supported.'); + } + const start = parseSwipePoint(value.start); + const end = parseSwipePoint(value.end); + const durationMs = + typeof value.duration === 'number' && Number.isFinite(value.duration) + ? String(Math.max(16, Math.floor(value.duration))) + : undefined; + if (start.kind === 'absolute' && end.kind === 'absolute') { + return action('swipe', [ + String(start.x), + String(start.y), + String(end.x), + String(end.y), + ...(durationMs ? [durationMs] : []), + ]); + } + if (start.kind === 'percent' && end.kind === 'percent') { + return action('scroll', readScrollPositionalsFromPercentSwipe(start, end)); + } + throw unsupportedMaestroSyntax( + 'Maestro swipe start/end must both be absolute pixels or both be percentages.', + ); +} + +export function convertPressKey(value: unknown): SessionAction { + const key = requireStringValue('pressKey', value).toLowerCase(); + if (key === 'back') return action('back'); + if (key === 'enter' || key === 'return') return action('press', ['return']); + if (key === 'home') return action('home'); + throw unsupportedMaestroSyntax(`Maestro pressKey "${key}" is not supported yet.`); +} + +export function maestroSelector( + value: unknown, + command: string, + allowedExtraKeys: readonly string[] = [], + context: MaestroParseContext, +): string { + if (typeof value === 'string') return visibleTextSelector(resolveMaestroString(value, context)); + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', `${command} expects a string or selector map.`); + } + assertOnlyKeys(value, command, ['id', 'text', 'enabled', 'selected', ...allowedExtraKeys]); + + const terms: string[] = []; + if (typeof value.id === 'string') + terms.push(selectorTerm('id', resolveMaestroString(value.id, context))); + if (typeof value.text === 'string') + terms.push(selectorTerm('label', resolveMaestroString(value.text, context))); + if (typeof value.enabled === 'boolean') + terms.push(selectorTerm('enabled', String(value.enabled))); + if (typeof value.selected === 'boolean') + terms.push(selectorTerm('selected', String(value.selected))); + if (terms.length === 0) { + throw new AppError( + 'INVALID_ARGS', + `${command} selector map must include one of id, text, enabled, or selected.`, + ); + } + return terms.join(' '); +} + +function visibleTextSelector(value: string): string { + return [ + selectorTerm('label', value), + selectorTerm('text', value), + selectorTerm('id', value), + ].join(' || '); +} + +function selectorTerm(key: string, value: string): string { + return `${key}=${JSON.stringify(value)}`; +} + +function tapFlags(value: unknown): SessionAction['flags'] | undefined { + if (!isPlainRecord(value)) return undefined; + const flags: SessionAction['flags'] = {}; + if (typeof value.repeat === 'number' && Number.isInteger(value.repeat) && value.repeat > 1) { + flags.count = value.repeat; + } + if (typeof value.delay === 'number' && Number.isInteger(value.delay) && value.delay >= 0) { + flags.intervalMs = value.delay; + } + return Object.keys(flags).length > 0 ? flags : undefined; +} + +function doubleTapFlags(value: unknown): SessionAction['flags'] { + const flags: SessionAction['flags'] = { doubleTap: true }; + if (isPlainRecord(value) && typeof value.delay === 'number' && Number.isInteger(value.delay)) { + flags.intervalMs = Math.max(0, value.delay); + } + return flags; +} + +function parsePoint(value: string): { x: number; y: number } { + const match = value.match(/^(\d+),(\d+)$/); + if (!match) { + throw unsupportedMaestroSyntax( + 'Only absolute Maestro point selectors like "100,200" are supported.', + ); + } + return { x: Number(match[1]), y: Number(match[2]) }; +} + +type SwipePoint = + | { + kind: 'absolute'; + x: number; + y: number; + } + | { + kind: 'percent'; + x: number; + y: number; + }; + +function parseSwipePoint(value: string): SwipePoint { + const absolute = value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); + if (absolute) { + return { kind: 'absolute', x: Number(absolute[1]), y: Number(absolute[2]) }; + } + const percent = value.match(/^\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*$/); + if (percent) { + return { kind: 'percent', x: Number(percent[1]), y: Number(percent[2]) }; + } + throw unsupportedMaestroSyntax( + 'Only Maestro swipe coordinates like "100,200" or "50%,75%" are supported.', + ); +} + +function readScrollPositionalsFromPercentSwipe( + start: Extract, + end: Extract, +): string[] { + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + if (Math.abs(deltaX) === 0 && Math.abs(deltaY) === 0) { + throw new AppError('INVALID_ARGS', 'swipe start and end cannot be the same point.'); + } + const vertical = Math.abs(deltaY) >= Math.abs(deltaX); + const direction = vertical ? (deltaY < 0 ? 'down' : 'up') : deltaX < 0 ? 'right' : 'left'; + const amount = Math.min(1, Math.max(0.01, Math.abs(vertical ? deltaY : deltaX) / 100)); + return [direction, formatAmount(amount)]; +} + +function formatAmount(value: number): string { + return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} diff --git a/src/compat/maestro/replay-flow.ts b/src/compat/maestro/replay-flow.ts new file mode 100644 index 000000000..e6b28cdaa --- /dev/null +++ b/src/compat/maestro/replay-flow.ts @@ -0,0 +1,179 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseAllDocuments } from 'yaml'; +import type { SessionAction } from '../../daemon/types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { convertMaestroCommandWithLine } from './command-mapper.ts'; +import { isPlainRecord, normalizeCommandList, normalizePlatform, readEnvMap } from './support.ts'; +import type { + MaestroCommand, + MaestroFlowConfig, + MaestroParseContext, + MaestroParseOptions, + MaestroReplayFlow, +} from './types.ts'; + +export function parseMaestroReplayFlow( + script: string, + options: MaestroParseOptions = {}, +): MaestroReplayFlow { + return parseMaestroReplayFlowInternal(script, createParseContext(options)); +} + +function parseMaestroReplayFlowInternal( + script: string, + context: MaestroParseContext, +): MaestroReplayFlow { + const values = parseYamlDocuments(script); + const { config, commands } = splitMaestroDocuments(values); + const nextContext = { + ...context, + env: { ...context.env, ...(config.env ?? {}), ...context.envOverrides }, + }; + const commandLines = findMaestroCommandLines(script); + const { actions, actionLines } = convertRootCommands({ + config, + commands, + commandLines, + context: nextContext, + }); + + return { + actions, + actionLines, + metadata: { + env: config.env, + }, + }; +} + +function convertRootCommands(params: { + config: MaestroFlowConfig; + commands: MaestroCommand[]; + commandLines: number[]; + context: MaestroParseContext; +}): { actions: SessionAction[]; actionLines: number[] } { + const { config, commands, commandLines, context } = params; + const allCommands = [ + ...(config.onFlowStart ?? []), + ...commands, + ...(config.onFlowComplete ?? []), + ]; + const allCommandLines = [ + ...Array.from({ length: config.onFlowStart?.length ?? 0 }, () => 1), + ...commandLines, + ...Array.from({ length: config.onFlowComplete?.length ?? 0 }, () => commandLines.at(-1) ?? 1), + ]; + const actions: SessionAction[] = []; + const actionLines: number[] = []; + for (const [index, command] of allCommands.entries()) { + const line = allCommandLines[index] ?? index + 1; + const converted = convertMaestroCommandWithLine(command, config, line, context, { + parseRunFlowFile, + }); + actions.push(...converted); + converted.forEach(() => actionLines.push(line)); + } + return { actions, actionLines }; +} + +function parseYamlDocuments(script: string): unknown[] { + const documents = parseAllDocuments(script); + for (const document of documents) { + if (document.errors.length > 0) { + const message = document.errors[0]?.message ?? 'Invalid Maestro YAML flow.'; + throw new AppError('INVALID_ARGS', `Invalid Maestro YAML flow: ${message}`); + } + } + return documents + .map((document) => document.toJSON() as unknown) + .filter((value) => value !== null); +} + +function createParseContext(options: MaestroParseOptions): MaestroParseContext { + const visitedPaths = options.visitedPaths ?? new Set(); + if (options.sourcePath) visitedPaths.add(path.resolve(options.sourcePath)); + return { + baseDir: options.sourcePath ? path.dirname(options.sourcePath) : undefined, + platform: normalizePlatform(options.platform), + env: {}, + envOverrides: options.env ?? {}, + visitedPaths, + }; +} + +function findMaestroCommandLines(script: string): number[] { + const lines = script.split(/\r?\n/); + const separatorIndex = lines.findIndex((line) => line.trim() === '---'); + const firstCommandLine = separatorIndex === -1 ? 0 : separatorIndex + 1; + const commandLines: number[] = []; + for (let index = firstCommandLine; index < lines.length; index += 1) { + if (/^-\s+/.test(lines[index] ?? '')) commandLines.push(index + 1); + } + return commandLines; +} + +function splitMaestroDocuments(values: unknown[]): { + config: MaestroFlowConfig; + commands: MaestroCommand[]; +} { + if (values.length === 0) { + throw new AppError('INVALID_ARGS', 'Maestro flow is empty.'); + } + + if (Array.isArray(values[0])) { + return { config: {}, commands: normalizeCommandList(values[0]) }; + } + + const config = normalizeConfig(values[0]); + const commandDocument = values[1]; + if (!Array.isArray(commandDocument)) { + throw new AppError( + 'INVALID_ARGS', + 'Maestro flow must contain a command list after the YAML document separator.', + ); + } + return { config, commands: normalizeCommandList(commandDocument) }; +} + +function normalizeConfig(value: unknown): MaestroFlowConfig { + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', 'Maestro flow config must be a YAML map.'); + } + return { + ...(typeof value.appId === 'string' && value.appId.length > 0 ? { appId: value.appId } : {}), + ...(isPlainRecord(value.env) ? { env: readEnvMap(value.env, 'env') } : {}), + ...(Array.isArray(value.onFlowStart) + ? { onFlowStart: normalizeCommandList(value.onFlowStart) } + : {}), + ...(Array.isArray(value.onFlowComplete) + ? { onFlowComplete: normalizeCommandList(value.onFlowComplete) } + : {}), + }; +} + +function parseRunFlowFile(filePath: string, context: MaestroParseContext): MaestroReplayFlow { + const resolved = resolveRunFlowPath(filePath, context); + if (context.visitedPaths.has(resolved)) { + throw new AppError('INVALID_ARGS', `Maestro runFlow cycle detected at ${resolved}.`); + } + const script = fs.readFileSync(resolved, 'utf8'); + const visitedPaths = new Set(context.visitedPaths); + visitedPaths.add(resolved); + return parseMaestroReplayFlowInternal(script, { + ...context, + baseDir: path.dirname(resolved), + visitedPaths, + }); +} + +function resolveRunFlowPath(filePath: string, context: MaestroParseContext): string { + if (path.isAbsolute(filePath)) return filePath; + if (!context.baseDir) { + throw new AppError( + 'INVALID_ARGS', + 'runFlow file paths require replay input to have a source path.', + ); + } + return path.resolve(context.baseDir, filePath); +} diff --git a/src/compat/maestro/support.ts b/src/compat/maestro/support.ts new file mode 100644 index 000000000..3bd998faf --- /dev/null +++ b/src/compat/maestro/support.ts @@ -0,0 +1,141 @@ +import type { SessionAction } from '../../daemon/types.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { MaestroCommand, MaestroFlowConfig, MaestroParseContext } from './types.ts'; + +const MAESTRO_COMPAT_TRACKER_URL = 'https://github.com/callstackincubator/agent-device/issues/558'; +const MAESTRO_NEW_ISSUE_URL = 'https://github.com/callstackincubator/agent-device/issues/new'; + +export function action( + command: string, + positionals: string[] = [], + flags?: SessionAction['flags'], +): SessionAction { + return { + ts: Date.now(), + command, + positionals, + flags: flags ?? {}, + }; +} + +export function assertOnlyKeys( + value: Record, + command: string, + supportedKeys: readonly string[], +): void { + const supported = new Set(supportedKeys); + const unsupported = Object.keys(value).filter((key) => !supported.has(key)); + if (unsupported.length > 0) { + throw unsupportedMaestroSyntax( + `Maestro ${command} field "${unsupported[0]}" is not supported yet.`, + ); + } +} + +export function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function normalizeCommandList(value: unknown[]): MaestroCommand[] { + return value.map((entry, index) => { + if (typeof entry === 'string') return entry; + if (isPlainRecord(entry)) return entry; + throw new AppError( + 'INVALID_ARGS', + `Unsupported Maestro command at index ${index + 1}: expected a scalar or one-key map.`, + ); + }); +} + +export function normalizePlatform(value: string | undefined): 'android' | 'ios' | undefined { + if (!value) return undefined; + return normalizePlatformName(value); +} + +export function normalizePlatformValue(value: unknown, name: string): 'android' | 'ios' { + if (typeof value !== 'string') { + throw new AppError('INVALID_ARGS', `${name} expects Android or iOS.`); + } + const platform = normalizePlatformName(value); + if (!platform) { + throw new AppError('INVALID_ARGS', `${name} expects Android or iOS.`); + } + return platform; +} + +export function normalizeToken(value: string): string { + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase(); +} + +export function readBooleanLiteral(value: unknown, command: string): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = normalizeToken(value); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + throw new AppError('INVALID_ARGS', `${command} expects a boolean value.`); +} + +export function readEnvMap(value: unknown, name: string): Record { + if (value === undefined || value === null) return {}; + if (!isPlainRecord(value)) { + throw new AppError('INVALID_ARGS', `${name} expects a map.`); + } + const env: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') { + env[key] = String(raw); + } + } + return env; +} + +export function readTimeoutMs(value: unknown, fallback: number): number { + if (isPlainRecord(value) && typeof value.timeout === 'number' && Number.isFinite(value.timeout)) { + return Math.max(0, Math.floor(value.timeout)); + } + return fallback; +} + +export function requireAppId(config: MaestroFlowConfig, command: string): string { + if (config.appId) return config.appId; + throw new AppError('INVALID_ARGS', `${command} requires appId in the Maestro flow config.`); +} + +export function requireStringValue(command: string, value: unknown): string { + if (typeof value === 'string') return value; + throw new AppError('INVALID_ARGS', `${command} expects a string value.`); +} + +export function resolveMaestroString(value: string, context: MaestroParseContext): string { + return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, key: string) => { + return Object.prototype.hasOwnProperty.call(context.env, key) ? context.env[key] : match; + }); +} + +export function resolveMaybeMaestroString(value: unknown, context: MaestroParseContext): unknown { + return typeof value === 'string' ? resolveMaestroString(value, context) : value; +} + +export function unsupportedCommand(command: string): never { + throw unsupportedMaestroSyntax(`Maestro command "${command}" is not supported yet.`); +} + +export function unsupportedMaestroSyntax(message: string): never { + throw new AppError( + 'INVALID_ARGS', + `${message} See supported/unsupported Maestro compatibility at ${MAESTRO_COMPAT_TRACKER_URL}. If this syntax matters for your flows, comment there or open a focused issue at ${MAESTRO_NEW_ISSUE_URL}.`, + ); +} + +function normalizePlatformName(value: string): 'android' | 'ios' | undefined { + const normalized = value.trim().toLowerCase(); + if (normalized === 'android') return 'android'; + if (normalized === 'ios') return 'ios'; + return undefined; +} diff --git a/src/compat/maestro/types.ts b/src/compat/maestro/types.ts new file mode 100644 index 000000000..81012d5e7 --- /dev/null +++ b/src/compat/maestro/types.ts @@ -0,0 +1,35 @@ +import type { ParsedReplayScript, ReplayScriptMetadata } from '../../replay/script.ts'; + +export type MaestroFlowConfig = { + appId?: string; + env?: Record; + onFlowStart?: MaestroCommand[]; + onFlowComplete?: MaestroCommand[]; +}; + +export type MaestroReplayFlow = ParsedReplayScript & { + metadata: ReplayScriptMetadata; +}; + +export type MaestroCommand = string | Record; + +export type MaestroParseOptions = { + sourcePath?: string; + platform?: string; + env?: Record; + visitedPaths?: Set; +}; + +export type MaestroParseContext = { + baseDir?: string; + platform?: 'android' | 'ios'; + env: Record; + envOverrides: Record; + visitedPaths: Set; +}; + +export type MaestroCommandMapperDeps = { + parseRunFlowFile(filePath: string, context: MaestroParseContext): MaestroReplayFlow; +}; + +export type PermissionCommand = 'grant' | 'deny' | 'reset'; diff --git a/src/compat/replay-input.ts b/src/compat/replay-input.ts new file mode 100644 index 000000000..57b7cf8f8 --- /dev/null +++ b/src/compat/replay-input.ts @@ -0,0 +1,85 @@ +import type { CommandFlags } from '../core/dispatch.ts'; +import { AppError } from '../utils/errors.ts'; +import { + collectReplayShellEnv, + parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, +} from '../replay/vars.ts'; +import { parseMaestroReplayFlow } from './maestro/replay-flow.ts'; +import { + parseReplayScriptDetailed, + readReplayScriptMetadata, + type ParsedReplayScript, + type ReplayScriptMetadata, +} from '../replay/script.ts'; + +type ReplayCompatParser = { + parse: ( + script: string, + options: ReplayCompatParseOptions, + ) => ParsedReplayScript & { metadata: ReplayScriptMetadata }; +}; + +export type ParsedReplayInput = ParsedReplayScript & { + metadata: ReplayScriptMetadata; + updateUnsupportedMessage?: string; +}; + +type ReplayInputParseOptions = { + sourcePath?: string; +}; + +type ReplayCompatParseOptions = ReplayInputParseOptions & { + platform?: string; + env?: Record; +}; + +const REPLAY_COMPAT_PARSERS: Record = { + maestro: { + parse: parseMaestroReplayFlow, + }, +}; + +const COMPAT_UPDATE_UNSUPPORTED_MESSAGE = + 'replay -u is not supported for compat flow input. Convert to .ad first, then update that replay file.'; + +export function parseReplayInput( + script: string, + flags: CommandFlags | undefined, + options: ReplayInputParseOptions = {}, +): ParsedReplayInput { + const compatParser = readReplayCompatParser(flags); + if (compatParser) { + return { + ...compatParser.parse(script, { + ...options, + platform: flags?.platform, + env: readReplayCompatEnv(flags), + }), + updateUnsupportedMessage: COMPAT_UPDATE_UNSUPPORTED_MESSAGE, + }; + } + + return { + ...parseReplayScriptDetailed(script), + metadata: readReplayScriptMetadata(script), + }; +} + +function readReplayCompatEnv(flags: CommandFlags | undefined): Record { + return { + ...collectReplayShellEnv(readReplayShellEnvSource(flags?.replayShellEnv)), + ...parseReplayCliEnvEntries(readReplayCliEnvEntries(flags?.replayEnv)), + }; +} + +function readReplayCompatParser(flags: CommandFlags | undefined): ReplayCompatParser | undefined { + const backend = flags?.replayBackend; + if (typeof backend !== 'string') return undefined; + const parser = REPLAY_COMPAT_PARSERS[backend]; + if (!parser) { + throw new AppError('INVALID_ARGS', `Unsupported replay backend "${backend}".`); + } + return parser; +} diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 89ee3faef..cacc2a407 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -1,4 +1,4 @@ -import type { CliFlags } from '../utils/command-schema.ts'; +import type { CliFlags, DaemonExcludedCliFlag } from '../utils/command-schema.ts'; import type { ScreenshotDispatchFlags } from '../commands/capture-screenshot-options.ts'; import type { ClickButton } from './click-button.ts'; import type { SessionSurface } from './session-surface.ts'; @@ -10,8 +10,9 @@ export type BatchStep = { runtime?: unknown; }; -export type CommandFlags = Omit & { +export type CommandFlags = Omit & { batchSteps?: BatchStep[]; + replayBackend?: string; }; export type DispatchContext = ScreenshotDispatchFlags & { diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 7b7149b2d..db01e2b9c 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -13,12 +13,12 @@ import { parseReplayCliEnvEntries, resolveReplayAction, resolveReplayString, -} from '../session-replay-vars.ts'; +} from '../../../replay/vars.ts'; import { parseReplayScript, parseReplayScriptDetailed, readReplayScriptMetadata, -} from '../session-replay-script.ts'; +} from '../../../replay/script.ts'; import { runReplayScriptFile } from '../session-replay-runtime.ts'; const LOC = { file: 'test.ad', line: 1 }; @@ -381,6 +381,7 @@ test('runReplayScriptFile rejects replay -u when any action contains ${VAR}', as } }); +// fallow-ignore-next-line complexity test('runReplayScriptFile dispatches resolved literals with file env overridden by CLI', async () => { const { response, calls } = await runReplayFixture({ label: 'green', @@ -414,6 +415,32 @@ test('runReplayScriptFile dispatches resolved literals with file env overridden } }); +test('runReplayScriptFile applies CLI env overrides before Maestro compat mapping', async () => { + const { response, calls } = await runReplayFixture({ + label: 'maestro-env', + script: [ + 'appId: ${APP_ID}', + 'env:', + ' APP_ID: yaml-app', + ' BUTTON_ID: yaml-button', + '---', + '- launchApp', + '- tapOn:', + ' id: ${BUTTON_ID}', + '', + ].join('\n'), + flags: { + replayBackend: 'maestro', + replayShellEnv: { AD_VAR_BUTTON_ID: 'shell-button' }, + replayEnv: ['APP_ID=cli-app'], + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(calls[0]?.positionals, ['cli-app']); + assert.deepEqual(calls[1]?.positionals, ['id="shell-button"']); +}); + test('runReplayScriptFile reads shell env from request (client-collected), not daemon process.env', async () => { // Ensure the daemon's own process.env does NOT contain AD_VAR_APP. assert.equal(process.env.AD_VAR_APP, undefined); diff --git a/src/daemon/handlers/session-maestro-replay.ts b/src/daemon/handlers/session-maestro-replay.ts deleted file mode 100644 index d757360dd..000000000 --- a/src/daemon/handlers/session-maestro-replay.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { parseAllDocuments } from 'yaml'; -import { AppError } from '../../utils/errors.ts'; -import type { SessionAction } from '../types.ts'; -import type { ParsedReplayScript, ReplayScriptMetadata } from './session-replay-script.ts'; - -type MaestroFlowConfig = { - appId?: string; - env?: Record; -}; - -type MaestroReplayFlow = ParsedReplayScript & { - metadata: ReplayScriptMetadata; -}; - -type MaestroCommand = string | Record; - -export function parseMaestroReplayFlow(script: string): MaestroReplayFlow { - const documents = parseAllDocuments(script); - for (const document of documents) { - if (document.errors.length > 0) { - const message = document.errors[0]?.message ?? 'Invalid Maestro YAML flow.'; - throw new AppError('INVALID_ARGS', `Invalid Maestro YAML flow: ${message}`); - } - } - - const values = documents - .map((document) => document.toJSON() as unknown) - .filter((value) => value !== null); - const { config, commands } = splitMaestroDocuments(values); - const actions: SessionAction[] = []; - const actionLines: number[] = []; - - for (let index = 0; index < commands.length; index += 1) { - const command = commands[index]; - const converted = convertMaestroCommand(command, config); - actions.push(...converted); - converted.forEach(() => actionLines.push(index + 1)); - } - - return { - actions, - actionLines, - metadata: { - env: config.env, - }, - }; -} - -function splitMaestroDocuments(values: unknown[]): { - config: MaestroFlowConfig; - commands: MaestroCommand[]; -} { - if (values.length === 0) { - throw new AppError('INVALID_ARGS', 'Maestro flow is empty.'); - } - - if (Array.isArray(values[0])) { - return { config: {}, commands: normalizeCommandList(values[0]) }; - } - - const config = normalizeConfig(values[0]); - const commandDocument = values[1]; - if (!Array.isArray(commandDocument)) { - throw new AppError( - 'INVALID_ARGS', - 'Maestro flow must contain a command list after the YAML document separator.', - ); - } - return { config, commands: normalizeCommandList(commandDocument) }; -} - -function normalizeConfig(value: unknown): MaestroFlowConfig { - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'Maestro flow config must be a YAML map.'); - } - const config: MaestroFlowConfig = {}; - if (typeof value.appId === 'string' && value.appId.length > 0) { - config.appId = value.appId; - } - if (isPlainRecord(value.env)) { - config.env = {}; - for (const [key, raw] of Object.entries(value.env)) { - if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') { - config.env[key] = String(raw); - } - } - } - return config; -} - -function normalizeCommandList(value: unknown[]): MaestroCommand[] { - return value.map((entry, index) => { - if (typeof entry === 'string') return entry; - if (isPlainRecord(entry)) return entry; - throw new AppError( - 'INVALID_ARGS', - `Unsupported Maestro command at index ${index + 1}: expected a scalar or one-key map.`, - ); - }); -} - -// fallow-ignore-next-line complexity -function convertMaestroCommand( - command: MaestroCommand, - config: MaestroFlowConfig, -): SessionAction[] { - if (typeof command === 'string') { - if (command === 'launchApp') return [action('open', [requireAppId(config, 'launchApp')])]; - if (command === 'hideKeyboard') return [action('keyboard', ['dismiss'])]; - if (command === 'back') return [action('back')]; - if (command === 'waitForAnimationToEnd') return [action('wait', ['250'])]; - return unsupportedCommand(command); - } - - const entries = Object.entries(command); - if (entries.length !== 1) { - throw new AppError('INVALID_ARGS', 'Maestro command maps must contain exactly one command.'); - } - - const [name, value] = entries[0] as [string, unknown]; - switch (name) { - case 'launchApp': - return [convertLaunchApp(value, config)]; - case 'tapOn': - return [convertTapOn(value)]; - case 'inputText': - return [action('type', [requireStringValue(name, value)])]; - case 'openLink': - return [action('open', [requireStringValue(name, value)])]; - case 'assertVisible': { - const selector = maestroSelector(value, name); - return [action('wait', [selector, '5000'])]; - } - case 'extendedWaitUntil': - return convertExtendedWaitUntil(value); - case 'takeScreenshot': - return [action('screenshot', [requireStringValue(name, value)])]; - case 'hideKeyboard': - return [action('keyboard', ['dismiss'])]; - case 'pressKey': - return [convertPressKey(value)]; - case 'back': - return [action('back')]; - case 'waitForAnimationToEnd': - return [action('wait', [String(readTimeoutMs(value, 250))])]; - default: - return unsupportedCommand(name); - } -} - -function convertLaunchApp(value: unknown, config: MaestroFlowConfig): SessionAction { - if (value === null || value === undefined) - return action('open', [requireAppId(config, 'launchApp')]); - if (typeof value === 'string') return action('open', [value]); - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'launchApp expects a string or map.'); - } - const appId = typeof value.appId === 'string' ? value.appId : requireAppId(config, 'launchApp'); - return action('open', [appId], { relaunch: value.stopApp === true }); -} - -function convertTapOn(value: unknown): SessionAction { - if (isPlainRecord(value) && typeof value.point === 'string') { - const point = parsePoint(value.point); - return action('click', [String(point.x), String(point.y)], tapFlags(value)); - } - return action('click', [maestroSelector(value, 'tapOn')], tapFlags(value)); -} - -function convertExtendedWaitUntil(value: unknown): SessionAction[] { - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', 'extendedWaitUntil expects a map.'); - } - const target = value.visible ?? value.notVisible; - if (target === undefined) { - throw new AppError( - 'INVALID_ARGS', - 'Prototype supports only extendedWaitUntil.visible/notVisible.', - ); - } - const selector = maestroSelector(target, 'extendedWaitUntil'); - const timeoutMs = String(readTimeoutMs(value, 30000)); - if (value.notVisible !== undefined) { - return [action('wait', [timeoutMs]), action('is', ['hidden', selector])]; - } - return [action('wait', [selector, timeoutMs])]; -} - -function convertPressKey(value: unknown): SessionAction { - const key = requireStringValue('pressKey', value).toLowerCase(); - if (key === 'back') return action('back'); - if (key === 'enter' || key === 'return') return action('press', ['return']); - if (key === 'home') return action('home'); - throw new AppError('INVALID_ARGS', `Prototype does not support Maestro pressKey "${key}".`); -} - -function maestroSelector(value: unknown, command: string): string { - if (typeof value === 'string') return visibleTextSelector(value); - if (!isPlainRecord(value)) { - throw new AppError('INVALID_ARGS', `${command} expects a string or selector map.`); - } - - const terms: string[] = []; - if (typeof value.id === 'string') terms.push(selectorTerm('id', value.id)); - if (typeof value.text === 'string') terms.push(selectorTerm('label', value.text)); - if (typeof value.enabled === 'boolean') - terms.push(selectorTerm('enabled', String(value.enabled))); - if (terms.length === 0) { - throw new AppError( - 'INVALID_ARGS', - `${command} selector map must include one of id, text, or enabled.`, - ); - } - return terms.join(' '); -} - -function visibleTextSelector(value: string): string { - return [ - selectorTerm('label', value), - selectorTerm('text', value), - selectorTerm('id', value), - ].join(' || '); -} - -function selectorTerm(key: string, value: string): string { - return `${key}=${JSON.stringify(value)}`; -} - -function tapFlags(value: unknown): SessionAction['flags'] | undefined { - if (!isPlainRecord(value)) return undefined; - const flags: SessionAction['flags'] = {}; - if (typeof value.repeat === 'number' && Number.isInteger(value.repeat) && value.repeat > 1) { - flags.count = value.repeat; - } - if (typeof value.delay === 'number' && Number.isInteger(value.delay) && value.delay >= 0) { - flags.intervalMs = value.delay; - } - return Object.keys(flags).length > 0 ? flags : undefined; -} - -function parsePoint(value: string): { x: number; y: number } { - const match = value.match(/^(\d+),(\d+)$/); - if (!match) { - throw new AppError( - 'INVALID_ARGS', - 'Prototype supports only absolute Maestro point selectors like "100,200".', - ); - } - return { x: Number(match[1]), y: Number(match[2]) }; -} - -function readTimeoutMs(value: unknown, fallback: number): number { - if (isPlainRecord(value) && typeof value.timeout === 'number' && Number.isFinite(value.timeout)) { - return Math.max(0, Math.floor(value.timeout)); - } - return fallback; -} - -function requireAppId(config: MaestroFlowConfig, command: string): string { - if (config.appId) return config.appId; - throw new AppError('INVALID_ARGS', `${command} requires appId in the Maestro flow config.`); -} - -function requireStringValue(command: string, value: unknown): string { - if (typeof value === 'string') return value; - throw new AppError('INVALID_ARGS', `${command} expects a string value.`); -} - -function unsupportedCommand(command: string): never { - throw new AppError( - 'INVALID_ARGS', - `Prototype does not support Maestro command "${command}" yet.`, - ); -} - -function action( - command: string, - positionals: string[] = [], - flags?: SessionAction['flags'], -): SessionAction { - return { - ts: Date.now(), - command, - positionals, - flags: flags ?? {}, - }; -} - -function isPlainRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index 14b68cf2b..039f56f5e 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -16,7 +16,7 @@ import { } from '../selectors.ts'; import { inferFillText, uniqueStrings } from '../action-utils.ts'; import type { SessionAction, SessionState } from '../types.ts'; -import { isClickLikeCommand } from '../script-utils.ts'; +import { isClickLikeCommand } from '../../replay/script-utils.ts'; import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; @@ -38,6 +38,7 @@ function parseSelectorWaitPositionals(positionals: string[]): { }; } +// fallow-ignore-next-line complexity function collectReplaySelectorCandidates(action: SessionAction): string[] { const result: string[] = []; const explicitChain = @@ -87,6 +88,7 @@ function collectReplaySelectorChains(action: SessionAction) { .filter((chain) => chain !== null); } +// fallow-ignore-next-line complexity export async function healReplayAction(params: { action: SessionAction; sessionName: string; @@ -168,6 +170,7 @@ export async function healReplayAction(params: { return null; } +// fallow-ignore-next-line complexity async function captureSnapshotForReplay( session: SessionState, action: SessionAction, diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index a36e33a77..12ff2cee0 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -1,27 +1,26 @@ import fs from 'node:fs'; import path from 'node:path'; import { type CommandFlags } from '../../core/dispatch.ts'; +import { parseReplayInput } from '../../compat/replay-input.ts'; import { asAppError } from '../../utils/errors.ts'; import type { DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { - parseReplayScriptDetailed, - readReplayScriptMetadata, - writeReplayScript, -} from './session-replay-script.ts'; -import { parseMaestroReplayFlow } from './session-maestro-replay.ts'; +import { type ReplayScriptMetadata, writeReplayScript } from '../../replay/script.ts'; import { healReplayAction } from './session-replay-heal.ts'; -import { formatScriptActionSummary } from '../script-utils.ts'; +import { formatScriptActionSummary } from '../../replay/script-utils.ts'; import { mergeParentFlags } from './handler-utils.ts'; import { errorResponse } from './response.ts'; import { buildReplayVarScope, collectReplayShellEnv, parseReplayCliEnvEntries, + readReplayCliEnvEntries, + readReplayShellEnvSource, resolveReplayAction, type ReplayVarScope, -} from './session-replay-vars.ts'; +} from '../../replay/vars.ts'; +// fallow-ignore-next-line complexity export async function runReplayScriptFile(params: { req: DaemonRequest; sessionName: string; @@ -48,21 +47,16 @@ export async function runReplayScriptFile(params: { ); } - const maestroReplay = req.flags?.replayMaestro === true; - const maestroFlow = maestroReplay ? parseMaestroReplayFlow(script) : null; - const metadata = maestroFlow?.metadata ?? readReplayScriptMetadata(script); + const parsed = parseReplayInput(script, req.flags, { sourcePath: resolved }); + const metadata = parsed.metadata; const replayReq = metadata.platform || metadata.target ? { ...req, flags: buildReplayMetadataFlags(req.flags, metadata) } : req; - const parsed = maestroFlow ?? parseReplayScriptDetailed(script); const actions = parsed.actions; const actionLines = parsed.actionLines; - if (req.flags?.replayUpdate === true && maestroReplay) { - return errorResponse( - 'INVALID_ARGS', - 'replay -u is not supported for Maestro flow input. Convert to .ad first, then update that replay file.', - ); + if (req.flags?.replayUpdate === true && parsed.updateUnsupportedMessage) { + return errorResponse('INVALID_ARGS', parsed.updateUnsupportedMessage); } if (req.flags?.replayUpdate === true && metadata.env && Object.keys(metadata.env).length > 0) { return errorResponse( @@ -84,8 +78,8 @@ export async function runReplayScriptFile(params: { resolvedPath: resolved, }), fileEnv: metadata.env, - shellEnv: collectReplayShellEnv(readShellEnvSource(req)), - cliEnv: parseReplayCliEnvEntries(readCliEnvEntries(req)), + shellEnv: collectReplayShellEnv(readReplayShellEnvSource(req.flags?.replayShellEnv)), + cliEnv: parseReplayCliEnvEntries(readReplayCliEnvEntries(req.flags?.replayEnv)), }); const shouldUpdate = req.flags?.replayUpdate === true; let healed = 0; @@ -218,10 +212,11 @@ function appendReplayTraceEvent( fs.appendFileSync(tracePath, `${JSON.stringify(event)}\n`); } +// fallow-ignore-next-line complexity function buildReplayBuiltinVars(params: { req: DaemonRequest; sessionName: string; - metadata: ReturnType; + metadata: ReplayScriptMetadata; resolvedPath: string; }): Record { const { req, sessionName, metadata, resolvedPath } = params; @@ -247,7 +242,7 @@ function buildReplayBuiltinVars(params: { function buildReplayMetadataFlags( flags: CommandFlags | undefined, - metadata: ReturnType, + metadata: ReplayScriptMetadata, ): CommandFlags { return { ...(flags ?? {}), @@ -260,25 +255,6 @@ function buildReplayMetadataFlags( }; } -function readCliEnvEntries(req: DaemonRequest): string[] { - const raw = req.flags?.replayEnv; - return Array.isArray(raw) - ? raw.filter((value): value is string => typeof value === 'string') - : []; -} - -function readShellEnvSource(req: DaemonRequest): NodeJS.ProcessEnv { - const raw = req.flags?.replayShellEnv; - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { - const result: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(raw)) { - if (typeof value === 'string') result[key] = value; - } - return result; - } - return process.env; -} - export function withReplayFailureContext( response: DaemonResponse, action: SessionAction, @@ -308,6 +284,7 @@ export function withReplayFailureContext( }; } +// fallow-ignore-next-line complexity export function collectReplayActionArtifactPaths(response: DaemonResponse): string[] { if (!response.ok || !response.data) return []; const candidates: string[] = []; @@ -343,6 +320,7 @@ export function buildReplayActionFlags( return mergeParentFlags(parentFlags, { ...(actionFlags ?? {}) }); } +// fallow-ignore-next-line complexity function actionsContainInterpolation(actions: SessionAction[]): boolean { for (const action of actions) { for (const positional of action.positionals ?? []) { diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index eb885d02e..298fce1ba 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -4,7 +4,7 @@ import { SessionStore } from '../session-store.ts'; import { runReplayTestSuite } from './session-test.ts'; import { handleCloseCommand } from './session-close.ts'; import { collectReplayActionArtifactPaths, runReplayScriptFile } from './session-replay-runtime.ts'; -import type { ReplayScriptMetadata } from './session-replay-script.ts'; +import type { ReplayScriptMetadata } from '../../replay/script.ts'; export function buildNestedReplayFlags(params: { parentFlags: CommandFlags | undefined; diff --git a/src/daemon/handlers/session-test-discovery.ts b/src/daemon/handlers/session-test-discovery.ts index 0e8324d71..df2101840 100644 --- a/src/daemon/handlers/session-test-discovery.ts +++ b/src/daemon/handlers/session-test-discovery.ts @@ -4,7 +4,7 @@ import { AppError } from '../../utils/errors.ts'; import type { PlatformSelector } from '../../utils/device.ts'; import { resolveRequestTrackingId } from '../request-cancel.ts'; import { SessionStore } from '../session-store.ts'; -import { readReplayScriptMetadata, type ReplayScriptMetadata } from './session-replay-script.ts'; +import { readReplayScriptMetadata, type ReplayScriptMetadata } from '../../replay/script.ts'; const GLOB_PATTERN_CHARS = /[*?[\]{}]/; diff --git a/src/daemon/handlers/session-test-runtime.ts b/src/daemon/handlers/session-test-runtime.ts index d23643fd3..81341bd97 100644 --- a/src/daemon/handlers/session-test-runtime.ts +++ b/src/daemon/handlers/session-test-runtime.ts @@ -8,7 +8,7 @@ import { registerRequestAbort, } from '../request-cancel.ts'; import type { DaemonResponse } from '../types.ts'; -import type { ReplayScriptMetadata } from './session-replay-script.ts'; +import type { ReplayScriptMetadata } from '../../replay/script.ts'; import type { ReplayTestRuntimeDependencies } from './session-test-types.ts'; const REPLAY_TIMEOUT_CLEANUP_GRACE_MS = 2_000; diff --git a/src/daemon/handlers/session-test-types.ts b/src/daemon/handlers/session-test-types.ts index 0c318cd0a..ee8964501 100644 --- a/src/daemon/handlers/session-test-types.ts +++ b/src/daemon/handlers/session-test-types.ts @@ -1,5 +1,5 @@ import type { DaemonResponse } from '../types.ts'; -import type { ReplayScriptMetadata } from './session-replay-script.ts'; +import type { ReplayScriptMetadata } from '../../replay/script.ts'; export type ReplayTestRunReplayParams = { filePath: string; diff --git a/src/daemon/session-script-writer.ts b/src/daemon/session-script-writer.ts index 7b6df9a0c..520060046 100644 --- a/src/daemon/session-script-writer.ts +++ b/src/daemon/session-script-writer.ts @@ -2,14 +2,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { inferFillText } from './action-utils.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; -import { formatPortableActionLine } from './session-script-formatting.ts'; +import { formatPortableActionLine } from '../replay/script-formatting.ts'; import { expandSessionPath, safeSessionName } from './session-paths.ts'; import { appendScriptSeriesFlags, formatScriptArg, formatScriptStringLiteral, isClickLikeCommand, -} from './script-utils.ts'; +} from '../replay/script-utils.ts'; import type { SessionAction, SessionState } from './types.ts'; export type SessionScriptWriteResult = { written: false } | { written: true; path: string }; diff --git a/src/daemon/handlers/__tests__/session-replay-script.test.ts b/src/replay/__tests__/script.test.ts similarity index 86% rename from src/daemon/handlers/__tests__/session-replay-script.test.ts rename to src/replay/__tests__/script.test.ts index 74a1c5a4e..9c79d84f4 100644 --- a/src/daemon/handlers/__tests__/session-replay-script.test.ts +++ b/src/replay/__tests__/script.test.ts @@ -3,14 +3,9 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { AppError } from '../../../utils/errors.ts'; -import { - parseReplayScript, - readReplayScriptMetadata, - writeReplayScript, -} from '../session-replay-script.ts'; -import { parseMaestroReplayFlow } from '../session-maestro-replay.ts'; -import type { SessionAction, SessionState } from '../../types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseReplayScript, readReplayScriptMetadata, writeReplayScript } from '../script.ts'; +import type { SessionAction, SessionState } from '../../daemon/types.ts'; function makeSession(): SessionState { return { @@ -54,54 +49,6 @@ test('writeReplayScript preserves inline open runtime hints', () => { ); }); -test('parseMaestroReplayFlow converts a supported Maestro command subset', () => { - const parsed = parseMaestroReplayFlow(`appId: com.callstack.agentdevicelab -env: - USER_NAME: Ada ---- -- launchApp -- tapOn: - id: home-open-form -- openLink: exp://localhost:8082 -- tapOn: Full name -- inputText: Ada Lovelace -- assertVisible: - text: Checkout form -- extendedWaitUntil: - visible: - id: submit-order - timeout: 7000 -- takeScreenshot: ./screens/form.png -- hideKeyboard -`); - - assert.equal(parsed.metadata.env?.USER_NAME, 'Ada'); - assert.deepEqual( - parsed.actions.map((entry) => [entry.command, entry.positionals]), - [ - ['open', ['com.callstack.agentdevicelab']], - ['click', ['id="home-open-form"']], - ['open', ['exp://localhost:8082']], - ['click', ['label="Full name" || text="Full name" || id="Full name"']], - ['type', ['Ada Lovelace']], - ['wait', ['label="Checkout form"', '5000']], - ['wait', ['id="submit-order"', '7000']], - ['screenshot', ['./screens/form.png']], - ['keyboard', ['dismiss']], - ], - ); -}); - -test('parseMaestroReplayFlow rejects unsupported Maestro commands', () => { - assert.throws( - () => parseMaestroReplayFlow('---\n- scrollUntilVisible: Save\n'), - (error) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /scrollUntilVisible/.test(error.message), - ); -}); - test('record replay script round-trips fps, quality, and hide-touches flags', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-script-record-')); const replayPath = path.join(root, 'flow.ad'); diff --git a/src/daemon/session-open-script.ts b/src/replay/open-script.ts similarity index 96% rename from src/daemon/session-open-script.ts rename to src/replay/open-script.ts index cda2ebee9..ebbccdc7d 100644 --- a/src/daemon/session-open-script.ts +++ b/src/replay/open-script.ts @@ -1,4 +1,4 @@ -import type { SessionAction } from './types.ts'; +import type { SessionAction } from '../daemon/types.ts'; import { appendRuntimeHintFlags, formatScriptArg, diff --git a/src/daemon/session-script-formatting.ts b/src/replay/script-formatting.ts similarity index 89% rename from src/daemon/session-script-formatting.ts rename to src/replay/script-formatting.ts index 8d879b270..19453fc7b 100644 --- a/src/daemon/session-script-formatting.ts +++ b/src/replay/script-formatting.ts @@ -1,4 +1,4 @@ -import { appendOpenActionScriptArgs } from './session-open-script.ts'; +import { appendOpenActionScriptArgs } from './open-script.ts'; import { appendGenericActionScriptArgs, appendRecordActionScriptArgs, @@ -6,7 +6,7 @@ import { appendScreenshotActionScriptArgs, appendSnapshotActionScriptArgs, } from './script-utils.ts'; -import type { SessionAction } from './types.ts'; +import type { SessionAction } from '../daemon/types.ts'; export function formatPortableActionLine( action: SessionAction, diff --git a/src/daemon/script-utils.ts b/src/replay/script-utils.ts similarity index 98% rename from src/daemon/script-utils.ts rename to src/replay/script-utils.ts index 4ff90f99f..78b8a8e38 100644 --- a/src/daemon/script-utils.ts +++ b/src/replay/script-utils.ts @@ -1,4 +1,4 @@ -import type { SessionAction } from './types.ts'; +import type { SessionAction } from '../daemon/types.ts'; import { appendScreenshotScriptFlags } from '../commands/capture-screenshot-options.ts'; const NUMERIC_ARG_RE = /^-?\d+(\.\d+)?$/; @@ -59,6 +59,7 @@ export function formatScriptActionSummary(action: SessionAction): string { return [action.command, ...values].join(' '); } +// fallow-ignore-next-line complexity export function appendScriptSeriesFlags( parts: string[], action: Pick, @@ -178,6 +179,7 @@ export function appendGenericActionScriptArgs(parts: string[], action: SessionAc appendScriptSeriesFlags(parts, action); } +// fallow-ignore-next-line complexity export function parseReplaySeriesFlags( command: string, args: string[], @@ -234,6 +236,7 @@ export function parseReplaySeriesFlags( return { positionals, flags }; } +// fallow-ignore-next-line complexity export function parseReplayRuntimeFlags(args: string[]): { positionals: string[]; flags: { diff --git a/src/daemon/handlers/session-replay-script.ts b/src/replay/script.ts similarity index 96% rename from src/daemon/handlers/session-replay-script.ts rename to src/replay/script.ts index 7f50652a0..0198048a0 100644 --- a/src/daemon/handlers/session-replay-script.ts +++ b/src/replay/script.ts @@ -1,17 +1,17 @@ import fs from 'node:fs'; -import { AppError } from '../../utils/errors.ts'; -import { readScreenshotScriptFlag } from '../../commands/capture-screenshot-options.ts'; -import type { DeviceTarget, PlatformSelector } from '../../utils/device.ts'; -import { parseReplayOpenFlags } from '../session-open-script.ts'; -import { formatPortableActionLine } from '../session-script-formatting.ts'; -import type { SessionAction, SessionState } from '../types.ts'; +import { AppError } from '../utils/errors.ts'; +import { readScreenshotScriptFlag } from '../commands/capture-screenshot-options.ts'; +import type { DeviceTarget, PlatformSelector } from '../utils/device.ts'; +import { parseReplayOpenFlags } from './open-script.ts'; +import { formatPortableActionLine } from './script-formatting.ts'; +import type { SessionAction, SessionState } from '../daemon/types.ts'; import { formatScriptStringLiteral, isClickLikeCommand, parseReplaySeriesFlags, parseReplayRuntimeFlags, -} from '../script-utils.ts'; -import { REPLAY_VAR_KEY_RE } from './session-replay-vars.ts'; +} from './script-utils.ts'; +import { REPLAY_VAR_KEY_RE } from './vars.ts'; type ReplayScriptPlatform = Exclude; @@ -67,6 +67,7 @@ export function parseReplayScriptDetailed(script: string): ParsedReplayScript { return { actions, actionLines }; } +// fallow-ignore-next-line complexity export function readReplayScriptMetadata(script: string): ReplayScriptMetadata { const lines = script.split(/\r?\n/); const metadata: ReplayScriptMetadata = {}; @@ -184,6 +185,7 @@ function assignReplayMetadataValue( metadata[key] = value as ReplayScriptMetadata[Key]; } +// fallow-ignore-next-line complexity function parseReplayScriptLine(line: string): SessionAction | null { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith('#')) return null; diff --git a/src/daemon/handlers/session-replay-vars.ts b/src/replay/vars.ts similarity index 87% rename from src/daemon/handlers/session-replay-vars.ts rename to src/replay/vars.ts index ee9b1d26c..dc3fb7d54 100644 --- a/src/daemon/handlers/session-replay-vars.ts +++ b/src/replay/vars.ts @@ -1,5 +1,5 @@ -import { AppError } from '../../utils/errors.ts'; -import type { SessionAction } from '../types.ts'; +import { AppError } from '../utils/errors.ts'; +import type { SessionAction } from '../daemon/types.ts'; export type ReplayVarScope = { readonly values: Readonly>; @@ -91,6 +91,23 @@ export function parseReplayCliEnvEntries(entries: readonly string[]): Record typeof value === 'string') + : []; +} + +export function readReplayShellEnvSource(raw: unknown): NodeJS.ProcessEnv { + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const result: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value === 'string') result[key] = value; + } + return result; + } + return process.env; +} + export function resolveReplayString( raw: string, scope: ReplayVarScope, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 1c8a6014e..0aca2d76e 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -871,6 +871,14 @@ test('usageForCommand includes Maestro replay flag', () => { const help = usageForCommand('replay'); if (help === null) throw new Error('Expected replay help text'); assert.match(help, /--maestro/); + assert.match(help, /doubleTapOn/); + assert.match(help, /pasteText/); + assert.match(help, /setPermissions/); + assert.match(help, /startRecording\/stopRecording/); + assert.match(help, /runFlow file\/inline/); + assert.match(help, /repeat\.times/); + assert.match(help, /Unsupported syntax fails loudly/); + assert.match(help, /issues\/558/); }); test('usageForCommand resolves workflow help topic', () => { diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index eb1b9cc69..8ee3064d9 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -109,6 +109,8 @@ export type CliFlags = RemoteConfigMetroOptions & version: boolean; }; +export type DaemonExcludedCliFlag = 'json' | 'help' | 'version' | 'batchSteps' | 'replayMaestro'; + export type FlagKey = keyof CliFlags; type FlagType = 'boolean' | 'int' | 'enum' | 'string' | 'booleanOrString'; @@ -1235,7 +1237,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'boolean', usageLabel: '--maestro', usageDescription: - 'Replay: treat input as a prototype Maestro YAML flow and execute the supported subset through replay', + 'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp without state-reset side effects, runFlow file/inline with when.platform, onFlowStart/onFlowComplete, deterministic repeat.times, tapOn, doubleTapOn, longPressOn, inputText, pasteText, openLink, assertVisible, assertNotVisible, assertTrue literal true/false, extendedWaitUntil, scroll, absolute/percentage swipe, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, stopApp/killApp, setAirplaneMode, setLocation, setOrientation, supported setPermissions targets, and startRecording/stopRecording. ' + + 'Unsupported syntax fails loudly with a link to https://github.com/callstackincubator/agent-device/issues/558', }, { key: 'replayEnv', diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 1660172a9..80f29511c 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1519,6 +1519,23 @@ const SKILL_GUIDANCE_CASES: Case[] = [ outputs: [plannedCommand('replay'), /-u|--update/i, /\.\/replays\/catalog-checkout\.ad/i], forbiddenOutputs: [/sed\s+-i/i, /open .*\.ad/i], }), + makeCase({ + id: 'replay-maestro-compatibility-flow', + contract: [ + 'Flow path: ./flows/checkout-form.yaml', + 'The flow is a Maestro YAML compatibility flow', + 'Need to run it through Agent Device replay, not the Maestro CLI', + 'Target platform: iOS', + ], + task: 'Plan the command to replay the Maestro YAML flow through Agent Device on iOS.', + outputs: [ + plannedCommand('replay'), + /--maestro/i, + /\.\/flows\/checkout-form\.yaml/i, + /--platform\s+ios/i, + ], + forbiddenOutputs: [/maestro\s+test/i, /maestro\s+cloud/i, plannedCommand('test')], + }), makeCase({ id: 'batch-known-stable-flow', contract: [ diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index d88ba6df4..25a390c39 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -62,7 +62,7 @@ { "name": "replay-e2e", "type": "file", - "label": "Replay & E2E (Experimental)" + "label": "Replay & E2E" }, { "name": "snapshots", diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 83814a9d4..ea90b915a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -337,7 +337,7 @@ agent-device replay -u ./session.ad # Update selector drift and rewrite .ad sc - `replay -u` updates stale recorded actions and rewrites the same script. - `--save-script` records a replay script on `close`; optional path is a file path and parent directories are created. -See [Replay & E2E (Experimental)](/docs/replay-e2e) for recording and CI workflow details. +See [Replay & E2E](/docs/replay-e2e) for recording, Maestro compatibility, and CI workflow details. ## Batch diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index 40bf62d0a..cfe6c036e 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -111,9 +111,9 @@ agent-device find label "Email" fill "user@example.com" agent-device find role button click ``` -## Replay (experimental) +## Replay -For deterministic replay scripts and E2E guidance, see [Replay & E2E (Experimental)](/docs/replay-e2e). +For deterministic replay scripts and E2E guidance, see [Replay & E2E](/docs/replay-e2e). ## Scrolling diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index fb094d52b..675266000 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -1,8 +1,8 @@ --- -title: Replay & E2E Testing (Experimental) +title: Replay & E2E Testing --- -# Replay & E2E Testing (Experimental) +# Replay & E2E Testing Agents use refs for exploration and authoring. Replay scripts are deterministic runs that can be used for E2E testing. @@ -48,6 +48,25 @@ agent-device replay ~/.agent-device/sessions/e2e-2026-02-09T12-00-00-000Z.ad --s - Replay reads `.ad` scripts. +## Run Maestro compatibility flows + +Agent Device can run a supported subset of Maestro YAML through the replay runtime: + +```bash +agent-device replay ./flow.yaml --maestro --platform ios --session e2e-run +``` + +Maestro compatibility translates supported YAML commands into Agent Device replay actions. It is intended for common mobile flows, not full Maestro parity. Unsupported Maestro syntax fails loudly with the command or field name and a line number when available. If a missing command matters for your flows, use the compatibility tracker to check current support and share demand: + +- Supported and unsupported capabilities: https://github.com/callstackincubator/agent-device/issues/558 +- New focused compatibility request: https://github.com/callstackincubator/agent-device/issues/new + +Currently supported areas include app launch without state-reset side effects, file and inline `runFlow` with `when.platform`, `onFlowStart` / `onFlowComplete`, deterministic `repeat.times`, `tapOn`, `doubleTapOn`, `longPressOn`, `inputText`, `pasteText`, `openLink`, visibility assertions, literal `assertTrue`, `extendedWaitUntil`, `scroll`, absolute/percentage `swipe`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, `stopApp` / `killApp`, airplane mode, mock location, orientation, supported permission targets, and screen recording. + +Maestro `env` values use the same replay precedence as `.ad` files: flow `env` is the default, shell `AD_VAR_*` values override it, and CLI `-e KEY=VALUE` wins over both. + +Runtime-dependent Maestro features such as `scrollUntilVisible`, `repeat.while`, `runFlow.when.visible`, `runScript`, `evalScript`, text clearing, and app state reset are tracked separately because they require neutral Agent Device runtime or device capabilities before they can be mapped safely. + ## Run a lightweight `.ad` suite ```bash @@ -219,3 +238,5 @@ Use `replay -u` locally during maintenance, review the rewritten `.ad` lines, th - Re-record that flow (`--save-script`) from a fresh exploratory pass. - Replay file parse error: - Validate quoting in `.ad` lines (unclosed quotes are rejected). +- Maestro compatibility flow fails on unsupported syntax: + - Check the linked command or field in https://github.com/callstackincubator/agent-device/issues/558. If it is important to your suite, comment there or open a focused issue with a small flow snippet. diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index d60fcfe1a..1e70254ac 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -37,4 +37,4 @@ Notes: - For remote `connect --remote-config` sessions, see [Commands](/docs/commands#remote-metro-workflow). - Use `--session ` to run multiple sessions in parallel. Do not parallelize mutating commands against the same session; serialize stateful actions such as open, press, fill, type, scroll, back, alert, replay, batch, and close. -For replay scripts and deterministic E2E guidance, see [Replay & E2E (Experimental)](/docs/replay-e2e). +For replay scripts and deterministic E2E guidance, see [Replay & E2E](/docs/replay-e2e).