diff --git a/README.md b/README.md index 5d966de4e..60ce04874 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ It works with native iOS and Android apps, plus apps built with Expo, Flutter, a - **Inspect** real app UI through compact accessibility snapshots, interactive refs like `@e3`, selectors, and React Native component trees. - **Interact** by opening apps, tapping, typing, scrolling, performing gestures, waiting, asserting state, handling alerts, and closing sessions. - **Capture evidence** with screenshots, videos, logs, traces, network traffic, performance samples, crash context, and React profiles. -- **Replay workflows** by recording `.ad` scripts for local runs, CI, and repeatable e2e checks. +- **Replay workflows** by recording `.ad` scripts for local runs, CI, repeatable e2e checks, and strict Maestro YAML export when a flow needs to run in Maestro. - **Run across platforms** with iOS Simulator automation, Android Emulator automation, physical devices, tvOS, Android TV, macOS, Linux, and desktop app automation, so agents can see and feel the app they work on. ## Use Cases diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index b43ad693e..9c91fd872 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -625,6 +625,71 @@ test('screenshot reports annotated ref count in non-json mode', async () => { assert.equal(stdout, 'Annotated 2 refs onto /tmp/screenshot.png\n'); }); +test('replay export writes Maestro YAML without contacting the daemon', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-export-')); + const sourcePath = path.join(dir, 'flow.ad'); + const outPath = path.join(dir, 'flow.yaml'); + fs.writeFileSync( + sourcePath, + `open com.example.app --relaunch +click text="Continue" +`, + ); + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected install call'); + }, + }); + + const stdout = await captureStdout(async () => { + const handled = await tryRunClientBackedCommand({ + command: 'replay', + positionals: ['export', sourcePath], + flags: { + json: false, + help: false, + version: false, + replayExportFormat: 'maestro', + out: outPath, + }, + client, + }); + assert.equal(handled, true); + }); + + const yaml = fs.readFileSync(outPath, 'utf8'); + assert.equal(stdout, `${outPath}\n`); + assert.match(yaml, /appId: com\.example\.app/); + assert.match(yaml, /text: Continue/); +}); + +test('replay rejects extra plain replay paths before daemon dispatch', async () => { + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected install call'); + }, + }); + + await assert.rejects( + async () => + await tryRunClientBackedCommand({ + command: 'replay', + positionals: ['one.ad', 'two.ad'], + flags: { + json: false, + help: false, + version: false, + }, + client, + }), + (error) => { + assert.equal(error instanceof AppError, true); + assert.match((error as AppError).message, /replay accepts exactly one input path/); + return true; + }, + ); +}); + test('wait keeps CLI bare text behavior through the typed client command API', async () => { let observed: Parameters[0] | undefined; const client = createStubClient({ diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts new file mode 100644 index 000000000..6263753c1 --- /dev/null +++ b/src/cli/commands/replay.ts @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { exportReplayScriptToMaestro } from '../../compat/maestro/export-flow.ts'; +import { AppError } from '../../utils/errors.ts'; +import { resolveUserPath } from '../../utils/path-resolution.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router-types.ts'; + +type ReplayCommandParams = Parameters[0]; + +export const replayCommand: ClientCommandHandler = async (params) => { + const { positionals } = params; + if (positionals[0] !== 'export') { + return handleReplayRunCommand(params); + } + return await handleReplayExportCommand(params); +}; + +function handleReplayRunCommand({ positionals, flags }: ReplayCommandParams): false { + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'replay accepts exactly one input path: replay '); + } + if (flags.replayExportFormat !== undefined || flags.out !== undefined) { + throw new AppError( + 'INVALID_ARGS', + 'replay --format/--out are only supported with replay export.', + ); + } + return false; +} + +async function handleReplayExportCommand({ + positionals, + flags, +}: ReplayCommandParams): Promise { + validateReplayExportOptions(positionals, flags); + const inputPath = positionals[1]; + if (!inputPath) { + throw new AppError('INVALID_ARGS', 'replay export requires an input path.'); + } + + const sourcePath = resolveUserPath(inputPath); + const script = fs.readFileSync(sourcePath, 'utf8'); + const result = exportReplayScriptToMaestro(script); + const outputPath = typeof flags.out === 'string' ? resolveUserPath(flags.out) : undefined; + if (outputPath) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, result.yaml); + } + for (const warning of result.warnings) { + process.stderr.write(`Warning: line ${warning.line}: ${warning.message}\n`); + } + + writeCommandOutput( + flags, + { + format: flags.replayExportFormat ?? 'maestro', + sourcePath, + ...(outputPath ? { path: outputPath } : { yaml: result.yaml }), + warnings: result.warnings, + }, + () => outputPath ?? result.yaml, + ); + return true; +} + +function validateReplayExportOptions( + positionals: ReplayCommandParams['positionals'], + flags: ReplayCommandParams['flags'], +): void { + if (positionals.length > 2) { + throw new AppError( + 'INVALID_ARGS', + 'replay export accepts exactly one input path: replay export ', + ); + } + if (flags.replayUpdate) { + throw new AppError('INVALID_ARGS', 'replay export does not support --update.'); + } + if (flags.replayMaestro) { + throw new AppError('INVALID_ARGS', 'replay export reads .ad files; omit --maestro.'); + } + if (flags.replayEnv?.length) { + throw new AppError('INVALID_ARGS', 'replay export does not evaluate --env substitutions.'); + } + const format = flags.replayExportFormat ?? 'maestro'; + if (format !== 'maestro') { + throw new AppError('INVALID_ARGS', `Unsupported replay export format: ${format}`); + } +} diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 0d49d5d9d..ac4b275a4 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -6,6 +6,7 @@ import { } from '../../command-catalog.ts'; import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { authCommand } from './auth.ts'; +import { replayCommand } from './replay.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; import type { ClientCommandHandlerMap, ClientCommandParams } from './router-types.ts'; @@ -20,6 +21,7 @@ const dedicatedCliCommandHandlers = { disconnect: disconnectCommand, connection: connectionCommand, auth: authCommand, + replay: replayCommand, screenshot: screenshotCommand, diff: diffCommand, } satisfies ClientCommandHandlerMap; diff --git a/src/compat/maestro/__tests__/export-flow.test.ts b/src/compat/maestro/__tests__/export-flow.test.ts new file mode 100644 index 000000000..a337d5252 --- /dev/null +++ b/src/compat/maestro/__tests__/export-flow.test.ts @@ -0,0 +1,168 @@ +import { parseAllDocuments } from 'yaml'; +import { describe, expect, test } from 'vitest'; +import { AppError } from '../../../utils/errors.ts'; +import { exportReplayScriptToMaestro } from '../export-flow.ts'; + +describe('exportReplayScriptToMaestro', () => { + test('exports app launch, selectors, input, keyboard, assertions, and screenshots', () => { + const result = exportReplayScriptToMaestro(`env USER="Ada" +context platform=ios target=mobile +open com.example.app --relaunch +click id="email" +fill id="email" "ada@example.com" +keyboard dismiss +find text "Continue" exists +screenshot "./artifacts/checkout" +`); + + const docs = parseYamlDocs(result.yaml); + expect(docs).toEqual([ + { appId: 'com.example.app', env: { USER: 'Ada' } }, + [ + { launchApp: { appId: 'com.example.app', stopApp: true } }, + { tapOn: { id: 'email' } }, + { tapOn: { id: 'email' } }, + { inputText: 'ada@example.com' }, + 'hideKeyboard', + { assertVisible: 'Continue' }, + { takeScreenshot: './artifacts/checkout' }, + ], + ]); + expect(result.warnings).toEqual([ + { + line: 5, + action: 'fill id="email" ada@example.com', + message: + 'fill exports as tapOn + inputText; Maestro may append text instead of replacing existing field contents', + }, + ]); + }); + + test('exports coordinate gestures and sleep waits with warnings', () => { + const result = exportReplayScriptToMaestro(`open com.example.app +click 120 240 +swipe 200 700 200 200 300 --count 2 +wait 500 +`); + + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.app' }, + [ + 'launchApp', + { tapOn: { point: '120,240' } }, + { swipe: { start: '200,700', end: '200,200', duration: 300 } }, + { swipe: { start: '200,700', end: '200,200', duration: 300 } }, + { waitForAnimationToEnd: { timeout: 500 } }, + ], + ]); + expect(result.warnings).toEqual([ + { + line: 4, + action: 'wait 500', + message: + 'wait exports as waitForAnimationToEnd and may return before the full duration', + }, + ]); + }); + + test('warns when explicit long-press durations export to Maestro defaults', () => { + const result = exportReplayScriptToMaestro(`open com.example.app +longpress "label=\\"Last message\\"" 800 +click id="hold-button" --hold-ms 1200 +press text="Retry" --hold-ms 1500 +`); + + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.app' }, + [ + 'launchApp', + { longPressOn: { label: 'Last message' } }, + { longPressOn: { id: 'hold-button' } }, + { longPressOn: { text: 'Retry' } }, + ], + ]); + expect(result.warnings).toEqual([ + { + line: 2, + action: 'longpress label="Last message" 800', + message: + 'long-press duration exports as Maestro longPressOn; Maestro uses its default long-press duration instead of 800ms', + }, + { + line: 3, + action: 'click id="hold-button"', + message: + 'long-press duration exports as Maestro longPressOn; Maestro uses its default long-press duration instead of 1200ms', + }, + { + line: 4, + action: 'press text="Retry"', + message: + 'long-press duration exports as Maestro longPressOn; Maestro uses its default long-press duration instead of 1500ms', + }, + ]); + }); + + test('warns when double-tap and hold exports ignore repeated tap options', () => { + const result = exportReplayScriptToMaestro(`open com.example.app +click id="retry" --double-tap --count 2 --interval-ms 200 +press text="Hold" --hold-ms 1000 --count 3 --interval-ms 150 +`); + + expect(parseYamlDocs(result.yaml)).toEqual([ + { appId: 'com.example.app' }, + ['launchApp', { doubleTapOn: { id: 'retry' } }, { longPressOn: { text: 'Hold' } }], + ]); + expect(result.warnings).toEqual([ + { + line: 2, + action: 'click id="retry"', + message: 'tap --count 2 is not represented by Maestro doubleTapOn', + }, + { + line: 2, + action: 'click id="retry"', + message: 'tap --interval-ms 200 is not represented by Maestro doubleTapOn', + }, + { + line: 3, + action: 'press text="Hold"', + message: + 'long-press duration exports as Maestro longPressOn; Maestro uses its default long-press duration instead of 1000ms', + }, + { + line: 3, + action: 'press text="Hold"', + message: 'tap --count 3 is not represented by Maestro longPressOn', + }, + { + line: 3, + action: 'press text="Hold"', + message: 'tap --interval-ms 150 is not represented by Maestro longPressOn', + }, + ]); + }); + + test('rejects native-only replay actions', () => { + expect(() => + exportReplayScriptToMaestro(`open com.example.app +snapshot -i +get text id="status" +`), + ).toThrowError(AppError); + try { + exportReplayScriptToMaestro(`open com.example.app +snapshot -i +get text id="status" +`); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + expect((error as AppError).message).toContain('line 2 (snapshot)'); + expect((error as AppError).message).toContain('line 3 (get text id="status")'); + } + }); +}); + +function parseYamlDocs(script: string): unknown[] { + return parseAllDocuments(script).map((doc) => doc.toJSON()); +} diff --git a/src/compat/maestro/__tests__/points.test.ts b/src/compat/maestro/__tests__/points.test.ts index 138729121..66e1b3c86 100644 --- a/src/compat/maestro/__tests__/points.test.ts +++ b/src/compat/maestro/__tests__/points.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import { test, expect } from 'vitest'; import { AppError } from '../../../utils/errors.ts'; -import { parseAbsolutePoint, parseMaestroPoint } from '../points.ts'; +import { formatMaestroPoint, parseAbsolutePoint, parseMaestroPoint } from '../points.ts'; + +test('formatMaestroPoint serializes coordinate pairs', () => { + expect(formatMaestroPoint(100, 200)).toBe('100,200'); + expect(formatMaestroPoint('50%', '75%')).toBe('50%,75%'); +}); test('parseMaestroPoint parses absolute pixel coordinates', () => { expect(parseMaestroPoint('100,200')).toEqual({ kind: 'absolute', x: 100, y: 200 }); diff --git a/src/compat/maestro/export-flow.ts b/src/compat/maestro/export-flow.ts new file mode 100644 index 000000000..1b21fb307 --- /dev/null +++ b/src/compat/maestro/export-flow.ts @@ -0,0 +1,528 @@ +import type { SessionAction } from '../../daemon/types.ts'; +import { parseSelectorChain, type Selector } from '../../daemon/selectors.ts'; +import type { SelectorTerm } from '../../utils/selectors-parse.ts'; +import { AppError } from '../../utils/errors.ts'; +import { + parseReplayScriptDetailed, + readReplayScriptMetadata, + type ReplayScriptMetadata, +} from '../../replay/script.ts'; +import { stringifyMaestroYamlDocuments } from './flow-yaml.ts'; +import { formatMaestroPoint } from './points.ts'; +import type { MaestroCommand, MaestroFlowConfig } from './types.ts'; + +export type MaestroExportWarning = { + line: number; + action: string; + message: string; +}; + +export type MaestroExportResult = { + yaml: string; + warnings: MaestroExportWarning[]; +}; + +type MaestroExportConfig = Pick; + +type ExportContext = { + config: MaestroExportConfig; + warnings: MaestroExportWarning[]; + unsupported: MaestroExportWarning[]; +}; + +type ConvertedAction = + | { kind: 'commands'; commands: MaestroCommand[]; warnings?: string[] } + | { kind: 'config'; appId: string; commands: MaestroCommand[]; warnings?: string[] } + | { kind: 'unsupported'; message: string }; + +type ActionConverter = (action: SessionAction) => ConvertedAction; +type SwipeGeometry = { + start: string; + end: string; + duration?: number; +}; + +const TEXT_SELECTOR_KEYS = new Set(['id', 'text', 'label']); +const STATE_SELECTOR_KEYS = new Set(['enabled', 'selected']); +const LONG_PRESS_DURATION_WARNING = + 'long-press duration exports as Maestro longPressOn; Maestro uses its default long-press duration'; + +export function exportReplayScriptToMaestro(script: string): MaestroExportResult { + const parsed = parseReplayScriptDetailed(script); + return exportReplayActionsToMaestro(parsed.actions, { + actionLines: parsed.actionLines, + metadata: readReplayScriptMetadata(script), + }); +} + +function exportReplayActionsToMaestro( + actions: SessionAction[], + options: { + actionLines?: number[]; + metadata?: ReplayScriptMetadata; + } = {}, +): MaestroExportResult { + const context: ExportContext = { + config: buildInitialConfig(options.metadata), + warnings: [], + unsupported: [], + }; + const commands: MaestroCommand[] = []; + + for (const [index, action] of actions.entries()) { + const line = options.actionLines?.[index] ?? index + 1; + const converted = convertAction(action); + switch (converted.kind) { + case 'commands': + commands.push(...converted.commands); + appendWarnings(context, converted.warnings, action, line); + break; + case 'config': + assignAppId(context, converted.appId, action, line); + commands.push(...converted.commands); + appendWarnings(context, converted.warnings, action, line); + break; + case 'unsupported': + context.unsupported.push({ + line, + action: formatActionForMessage(action), + message: converted.message, + }); + break; + } + } + + if (context.unsupported.length > 0) { + throw new AppError( + 'INVALID_ARGS', + `Cannot export replay to Maestro YAML: unsupported .ad action ${formatUnsupportedList( + context.unsupported, + )}.`, + { unsupported: context.unsupported }, + ); + } + + return { + yaml: formatMaestroYaml(context.config, commands), + warnings: context.warnings, + }; +} + +function buildInitialConfig(metadata: ReplayScriptMetadata | undefined): MaestroExportConfig { + return metadata?.env && Object.keys(metadata.env).length > 0 ? { env: metadata.env } : {}; +} + +const ACTION_CONVERTERS: Record = { + open: convertOpenAction, + click: convertClickAction, + press: convertClickAction, + longpress: convertLongPressAction, + fill: convertFillAction, + type: convertTypeAction, + keyboard: convertKeyboardAction, + back: () => ({ kind: 'commands', commands: ['back'] }), + wait: convertWaitAction, + find: convertFindAction, + screenshot: convertScreenshotAction, + scroll: convertScrollAction, + swipe: convertSwipeAction, +}; + +function convertAction(action: SessionAction): ConvertedAction { + return ( + ACTION_CONVERTERS[action.command]?.(action) ?? { + kind: 'unsupported', + message: `${action.command} has no Maestro equivalent`, + } + ); +} + +function convertOpenAction(action: SessionAction): ConvertedAction { + const [first, second] = action.positionals; + if (!first) return { kind: 'unsupported', message: 'open requires an app id or URL' }; + + if (isUrl(first)) { + return { kind: 'commands', commands: [{ openLink: first }] }; + } + + const launchApp = buildLaunchAppCommand(action, first); + if (second && isUrl(second)) { + return { kind: 'config', appId: first, commands: [launchApp, { openLink: second }] }; + } + if (second) { + return { kind: 'unsupported', message: 'open with a non-URL second argument is unsupported' }; + } + return { kind: 'config', appId: first, commands: [launchApp] }; +} + +function buildLaunchAppCommand(action: SessionAction, appId: string): MaestroCommand { + const options = buildLaunchAppOptions(action); + return options ? { launchApp: { appId, ...options } } : 'launchApp'; +} + +function buildLaunchAppOptions(action: SessionAction): Record | undefined { + const launchArgs = action.flags?.launchArgs; + const options: Record = {}; + if (action.flags?.relaunch === true) options.stopApp = true; + if (action.flags?.clearAppState === true) options.clearState = true; + if (Array.isArray(launchArgs) && launchArgs.length > 0) { + options.launchArguments = launchArgs; + } + return Object.keys(options).length > 0 ? options : undefined; +} + +function convertClickAction(action: SessionAction): ConvertedAction { + const [first, second] = action.positionals; + if (!first) return { kind: 'unsupported', message: `${action.command} requires a target` }; + const tapTarget = readTapTarget(first, second); + if (!tapTarget) return { kind: 'unsupported', message: 'tap target is not Maestro-compatible' }; + + const tapOptions = readRepeatedTapOptions(action); + if (!tapOptions.ok) return { kind: 'unsupported', message: tapOptions.message }; + + if (action.flags?.doubleTap === true) { + return { + kind: 'commands', + commands: [{ doubleTapOn: tapTarget }], + warnings: readIgnoredRepeatedTapOptionWarnings(action, 'doubleTapOn'), + }; + } + if (typeof action.flags?.holdMs === 'number') { + return { + kind: 'commands', + commands: [{ longPressOn: tapTarget }], + warnings: [ + formatLongPressDurationWarning(action.flags.holdMs), + ...readIgnoredRepeatedTapOptionWarnings(action, 'longPressOn'), + ], + }; + } + + return { kind: 'commands', commands: [withTapOptions(tapTarget, tapOptions.options)] }; +} + +function convertLongPressAction(action: SessionAction): ConvertedAction { + const [first, second] = action.positionals; + if (!first) return { kind: 'unsupported', message: 'longpress requires a target' }; + const target = readTapTarget(first, second); + if (!target) + return { kind: 'unsupported', message: 'longpress target is not Maestro-compatible' }; + return { + kind: 'commands', + commands: [{ longPressOn: target }], + warnings: readLongPressDuration(action).map(formatLongPressDurationWarning), + }; +} + +function readLongPressDuration(action: SessionAction): number[] { + const [first, second, third] = action.positionals; + const duration = isNumber(first) && isNumber(second) ? third : second; + return duration && isNumber(duration) ? [Number(duration)] : []; +} + +function formatLongPressDurationWarning(durationMs: number): string { + return `${LONG_PRESS_DURATION_WARNING} instead of ${durationMs}ms`; +} + +function readIgnoredRepeatedTapOptionWarnings( + action: SessionAction, + maestroCommand: 'doubleTapOn' | 'longPressOn', +): string[] { + const warnings: string[] = []; + if (typeof action.flags?.count === 'number' && action.flags.count > 1) { + warnings.push( + `tap --count ${action.flags.count} is not represented by Maestro ${maestroCommand}`, + ); + } + if (typeof action.flags?.intervalMs === 'number' && action.flags.intervalMs > 0) { + warnings.push( + `tap --interval-ms ${action.flags.intervalMs} is not represented by Maestro ${maestroCommand}`, + ); + } + return warnings; +} + +function convertFillAction(action: SessionAction): ConvertedAction { + const [target, text] = action.positionals; + if (!target || text === undefined) { + return { kind: 'unsupported', message: 'fill requires a target and text' }; + } + const tapTarget = readTapTarget(target); + if (!tapTarget) return { kind: 'unsupported', message: 'fill target is not Maestro-compatible' }; + return { + kind: 'commands', + commands: [{ tapOn: tapTarget }, { inputText: text }], + warnings: [ + 'fill exports as tapOn + inputText; Maestro may append text instead of replacing existing field contents', + ], + }; +} + +function convertTypeAction(action: SessionAction): ConvertedAction { + const [text] = action.positionals; + if (text === undefined) return { kind: 'unsupported', message: 'type requires text' }; + const eraseCount = readBackspaceCount(text); + if (eraseCount !== null) return { kind: 'commands', commands: [{ eraseText: eraseCount }] }; + return { kind: 'commands', commands: [{ inputText: text }] }; +} + +function convertKeyboardAction(action: SessionAction): ConvertedAction { + const [subcommand] = action.positionals; + if (subcommand === 'dismiss') return { kind: 'commands', commands: ['hideKeyboard'] }; + if (subcommand === 'enter' || subcommand === 'return') { + return { kind: 'commands', commands: [{ pressKey: 'Enter' }] }; + } + return { kind: 'unsupported', message: `keyboard ${subcommand ?? ''}`.trim() }; +} + +function convertWaitAction(action: SessionAction): ConvertedAction { + const [first, second] = action.positionals; + if (!first) return { kind: 'unsupported', message: 'wait requires a target or duration' }; + if (isNumber(first)) { + return { + kind: 'commands', + commands: [{ waitForAnimationToEnd: { timeout: Number(first) } }], + warnings: [ + 'wait exports as waitForAnimationToEnd and may return before the full duration', + ], + }; + } + if (first === 'text' && second) { + return { + kind: 'commands', + commands: [{ extendedWaitUntil: { visible: second, timeout: readTimeout(action, 17_000) } }], + }; + } + const selector = selectorExpressionToMaestro(first); + if (!selector) return { kind: 'unsupported', message: 'wait selector is not Maestro-compatible' }; + return { + kind: 'commands', + commands: [{ extendedWaitUntil: { visible: selector, timeout: readTimeout(action, 17_000) } }], + }; +} + +function convertFindAction(action: SessionAction): ConvertedAction { + const [kind, query, assertion] = action.positionals; + if (kind !== 'text' || !query || !assertion) { + return { + kind: 'unsupported', + message: 'only find text exists|missing exports to Maestro', + }; + } + if (assertion === 'exists') return { kind: 'commands', commands: [{ assertVisible: query }] }; + if (assertion === 'missing' || assertion === 'not-exists') { + return { kind: 'commands', commands: [{ assertNotVisible: query }] }; + } + return { kind: 'unsupported', message: `find text assertion "${assertion}" is unsupported` }; +} + +function convertScreenshotAction(action: SessionAction): ConvertedAction { + const [name] = action.positionals; + if (!name) return { kind: 'unsupported', message: 'screenshot requires an output path' }; + return { kind: 'commands', commands: [{ takeScreenshot: name }] }; +} + +function convertScrollAction(action: SessionAction): ConvertedAction { + const [direction] = action.positionals; + if (!direction || direction === 'down') return { kind: 'commands', commands: ['scroll'] }; + return { kind: 'unsupported', message: `scroll ${direction} is not exported yet` }; +} + +function convertSwipeAction(action: SessionAction): ConvertedAction { + const swipe = readSwipeGeometry(action); + if (!swipe) return { kind: 'unsupported', message: 'only coordinate swipe exports to Maestro' }; + const count = readSwipeCount(action); + if (count === null) + return { kind: 'unsupported', message: 'swipe count must be a positive integer' }; + const unsupportedFlag = readUnsupportedSwipeFlag(action); + if (unsupportedFlag) return { kind: 'unsupported', message: unsupportedFlag }; + return { + kind: 'commands', + commands: Array.from({ length: count }, () => ({ swipe })), + }; +} + +function readSwipeGeometry(action: SessionAction): SwipeGeometry | undefined { + const [x1, y1, x2, y2, duration] = action.positionals; + if (!isNumber(x1) || !isNumber(y1) || !isNumber(x2) || !isNumber(y2)) return undefined; + return { + start: formatMaestroPoint(x1, y1), + end: formatMaestroPoint(x2, y2), + ...(duration && isNumber(duration) ? { duration: Number(duration) } : {}), + }; +} + +function readSwipeCount(action: SessionAction): number | null { + const count = action.flags?.count ?? 1; + return Number.isInteger(count) && count >= 1 ? count : null; +} + +function readUnsupportedSwipeFlag(action: SessionAction): string | undefined { + if (action.flags?.pauseMs !== undefined) return 'swipe --pause-ms has no Maestro equivalent'; + if (action.flags?.pattern && action.flags.pattern !== 'one-way') { + return 'swipe ping-pong pattern has no Maestro equivalent'; + } + return undefined; +} + +function readTapTarget(first: string, second?: string): unknown | null { + if (isNumber(first) && isNumber(second)) return { point: formatMaestroPoint(first, second) }; + if (first.startsWith('@')) return null; + return selectorExpressionToMaestro(first); +} + +function selectorExpressionToMaestro(expression: string): unknown | null { + let chain: ReturnType; + try { + chain = parseSelectorChain(expression); + } catch { + return expression.includes('=') || expression.includes('||') ? null : expression; + } + const fallbackText = readFallbackTextSelector(chain.selectors); + if (fallbackText !== null) return fallbackText; + if (chain.selectors.length !== 1) return null; + return selectorToMaestro(chain.selectors[0]!); +} + +function readFallbackTextSelector(selectors: Selector[]): string | null { + if (selectors.length <= 1) return null; + const values = selectors.flatMap((selector) => + selector.terms.length === 1 && TEXT_SELECTOR_KEYS.has(selector.terms[0]!.key) + ? [selector.terms[0]!.value] + : [], + ); + if (values.length !== selectors.length || values.length === 0) return null; + const first = values[0]; + if (typeof first !== 'string') return null; + return values.every((value) => value === first) ? first : null; +} + +function selectorToMaestro(selector: Selector): Record | null { + const result: Record = {}; + for (const term of selector.terms) { + if (!appendSelectorTerm(result, term)) return null; + } + return Object.keys(result).length > 0 ? result : null; +} + +function appendSelectorTerm(result: Record, term: SelectorTerm): boolean { + if (TEXT_SELECTOR_KEYS.has(term.key)) { + if (typeof term.value !== 'string' || result.id || result.text || result.label) return false; + result[term.key] = term.value; + return true; + } + if (STATE_SELECTOR_KEYS.has(term.key)) { + if (typeof term.value !== 'boolean') return false; + result[term.key] = term.value; + return true; + } + return false; +} + +function readRepeatedTapOptions( + action: SessionAction, +): { ok: true; options: Record } | { ok: false; message: string } { + const unsupported = readUnsupportedRepeatedTapOption(action); + if (unsupported) return { ok: false, message: unsupported }; + return { ok: true, options: buildRepeatedTapOptions(action) }; +} + +function buildRepeatedTapOptions(action: SessionAction): Record { + const options: Record = {}; + if (typeof action.flags?.count === 'number' && action.flags.count > 1) { + options.repeat = action.flags.count; + } + if (typeof action.flags?.intervalMs === 'number' && action.flags.intervalMs > 0) { + options.delay = action.flags.intervalMs; + } + return options; +} + +function readUnsupportedRepeatedTapOption(action: SessionAction): string | undefined { + if (action.flags?.jitterPx !== undefined) { + return 'tap --jitter-px has no Maestro equivalent'; + } + if (action.flags?.clickButton && action.flags.clickButton !== 'primary') { + return `tap --button ${action.flags.clickButton} has no Maestro equivalent`; + } + return undefined; +} + +function withTapOptions(target: unknown, options: Record): MaestroCommand { + if (Object.keys(options).length === 0) return { tapOn: target }; + if (typeof target === 'string') return { tapOn: { text: target, ...options } }; + if (target && typeof target === 'object' && !Array.isArray(target)) { + return { tapOn: { ...(target as Record), ...options } }; + } + return { tapOn: target }; +} + +function assignAppId( + context: ExportContext, + appId: string, + action: SessionAction, + line: number, +): void { + if (!context.config.appId) { + context.config.appId = appId; + return; + } + if (context.config.appId === appId) return; + context.unsupported.push({ + line, + action: formatActionForMessage(action), + message: + `multiple app ids cannot be represented in one Maestro config ` + + `(${context.config.appId} vs ${appId})`, + }); +} + +function readBackspaceCount(text: string): number | null { + if (text.length === 0) return null; + if (![...text].every((char) => char === '\b')) return null; + return text.length; +} + +function appendWarnings( + context: ExportContext, + warnings: string[] | undefined, + action: SessionAction, + line: number, +): void { + for (const message of warnings ?? []) { + context.warnings.push({ + line, + action: formatActionForMessage(action), + message, + }); + } +} + +function readTimeout(action: SessionAction, fallback: number): number { + const candidate = action.positionals.at(-1); + return candidate && isNumber(candidate) ? Number(candidate) : fallback; +} + +function formatMaestroYaml(config: MaestroExportConfig, commands: MaestroCommand[]): string { + const hasConfig = Object.keys(config).length > 0; + const docs: unknown[] = hasConfig ? [config, commands] : [commands]; + return stringifyMaestroYamlDocuments(docs); +} + +function formatUnsupportedList(entries: MaestroExportWarning[]): string { + return entries + .map((entry) => `line ${entry.line} (${entry.action}): ${entry.message}`) + .join('; '); +} + +function formatActionForMessage(action: SessionAction): string { + return [action.command, ...(action.positionals ?? [])].join(' ').trim(); +} + +function isUrl(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value); +} + +function isNumber(value: string | undefined): value is string { + return value !== undefined && Number.isFinite(Number(value)); +} diff --git a/src/compat/maestro/flow-yaml.ts b/src/compat/maestro/flow-yaml.ts new file mode 100644 index 000000000..96fec12b9 --- /dev/null +++ b/src/compat/maestro/flow-yaml.ts @@ -0,0 +1,19 @@ +import { parseAllDocuments, stringify } from 'yaml'; +import { AppError } from '../../utils/errors.ts'; + +export function parseMaestroYamlDocuments(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); +} + +export function stringifyMaestroYamlDocuments(documents: readonly unknown[]): string { + return `${documents.map((document) => stringify(document).trimEnd()).join('\n---\n')}\n`; +} diff --git a/src/compat/maestro/points.ts b/src/compat/maestro/points.ts index 571e692a5..4b01fe6f5 100644 --- a/src/compat/maestro/points.ts +++ b/src/compat/maestro/points.ts @@ -12,6 +12,10 @@ export type MaestroPoint = y: number; }; +export function formatMaestroPoint(x: number | string, y: number | string): string { + return `${x},${y}`; +} + export function parseAbsolutePoint(value: string): { x: number; y: number } { const match = value.match(/^(\d+),(\d+)$/); if (!match) { diff --git a/src/compat/maestro/replay-flow.ts b/src/compat/maestro/replay-flow.ts index 5dd55b8b6..db9b9a316 100644 --- a/src/compat/maestro/replay-flow.ts +++ b/src/compat/maestro/replay-flow.ts @@ -1,9 +1,9 @@ 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 { parseMaestroYamlDocuments } from './flow-yaml.ts'; import { MAESTRO_RUNTIME_COMMAND } from './runtime-commands.ts'; import { isPlainRecord, normalizeCommandList, normalizePlatform, readEnvMap } from './support.ts'; import type { @@ -22,7 +22,7 @@ export function parseMaestroReplayFlow( } export function readMaestroFlowName(script: string): string | undefined { - const values = parseYamlDocuments(script); + const values = parseMaestroYamlDocuments(script); const { config } = splitMaestroDocuments(values); return config.name; } @@ -31,7 +31,7 @@ function parseMaestroReplayFlowInternal( script: string, context: MaestroParseContext, ): MaestroReplayFlow { - const values = parseYamlDocuments(script); + const values = parseMaestroYamlDocuments(script); const { config, commands } = splitMaestroDocuments(values); const nextContext = { ...context, @@ -201,19 +201,6 @@ function isLikelyTextEntrySelector(selector: string): boolean { ); } -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)); diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 6f12b100f..0ec11b718 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -152,6 +152,17 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.timeoutMs, 240000); }, }, + { + label: 'export replay to maestro yaml', + argv: ['replay', 'export', './flow.ad', '--format', 'maestro', '--out', './flow.yaml'], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'replay'); + assert.deepEqual(parsed.positionals, ['export', './flow.ad']); + assert.equal(parsed.flags.replayExportFormat, 'maestro'); + assert.equal(parsed.flags.out, './flow.yaml'); + }, + }, { label: 'test maestro suite', argv: [ @@ -1027,6 +1038,9 @@ test('usage includes agent workflows, config, environment, and examples footers' test('usageForCommand includes Maestro replay flag', () => { const help = usageForCommand('replay'); if (help === null) throw new Error('Expected replay help text'); + assert.match(help, /replay \| replay export /); + assert.match(help, /--format maestro/); + assert.match(help, /--out /); assert.match(help, /--maestro/); assert.match(help, /doubleTapOn/); assert.match(help, /pasteText/); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 0b1769526..94f52d2f7 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -218,8 +218,10 @@ const CLI_COMMAND_OVERRIDES = { allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS], }, replay: { + usageOverride: 'replay | replay export [--format maestro] [--out ]', positionalArgs: ['path'], - allowedFlags: ['replayMaestro', ...REPLAY_FLAGS, 'timeoutMs'], + allowsExtraPositionals: true, + allowedFlags: ['replayMaestro', 'replayExportFormat', ...REPLAY_FLAGS, 'timeoutMs', 'out'], }, test: { usageOverride: 'test ...', diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 4a9ce9a37..63cd8faab 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -96,6 +96,7 @@ export type CliFlags = RemoteConfigMetroOptions & retentionMs?: number; replayUpdate?: boolean; replayMaestro?: boolean; + replayExportFormat?: 'maestro'; replayEnv?: string[]; replayShellEnv?: Record; failFast?: boolean; @@ -793,6 +794,14 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ `Replay: treat input as a Maestro YAML compatibility flow. ${formatMaestroSupportedSubsetForCli()} ` + `Unsupported syntax fails loudly with a link to ${MAESTRO_COMPAT_TRACKER_URL}`, }, + { + key: 'replayExportFormat', + names: ['--format'], + type: 'enum', + enumValues: ['maestro'], + usageLabel: '--format maestro', + usageDescription: 'Replay export: output format', + }, { key: 'replayEnv', names: ['-e', '--env'], diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index b5e61e2df..bfaa524e2 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -68,6 +68,18 @@ Maestro `env` values use the same replay precedence as `.ad` files: flow `env` i Unsupported Maestro features such as `repeat.while`, full expression predicates beyond boolean literals and `maestro.platform` comparisons, `evalScript`, device utility commands, Android app launch arguments, and Android app state reset are tracked separately because they require neutral Agent Device runtime or device capabilities before they can be mapped safely. +## Export `.ad` scripts to Maestro YAML + +Replay scripts can be exported to a Maestro YAML subset when you need to hand a recorded Agent Device flow to a Maestro runner: + +```bash +agent-device replay export ./workflows/checkout.ad --format maestro --out ./maestro/checkout.yaml +``` + +`replay export` is a local file transform. It does not start the daemon or contact a device. If `--out` is omitted, the YAML is printed to stdout. + +The exporter is intentionally strict. It writes Maestro YAML for compatible flow actions such as app launch, taps, long press, text input, keyboard dismiss/enter, back, text visibility assertions, coordinate swipes, basic scroll, screenshots, and `.ad` `env` directives. Agent-only inspection or maintenance actions such as `snapshot`, `get`, `record`, `trace`, `settings`, and unsupported selector shapes fail with the source line and action instead of being silently dropped. Known semantic differences are reported as warnings; for example, `.ad` `fill` exports as `tapOn` plus `inputText`, which may append text in Maestro rather than replacing existing field contents. + ## Run a lightweight `.ad` suite ```bash