From fb8c028c6ee3923d5e17f2626727d9730a6862d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 19:08:06 +0200 Subject: [PATCH 1/4] feat: export replays to maestro yaml --- README.md | 2 +- src/__tests__/cli-client-commands.test.ts | 38 ++ src/cli/commands/replay.ts | 67 +++ src/cli/commands/router.ts | 2 + .../maestro/__tests__/export-flow.test.ts | 90 ++++ src/compat/maestro/__tests__/points.test.ts | 7 +- src/compat/maestro/export-flow.ts | 467 ++++++++++++++++++ src/compat/maestro/flow-yaml.ts | 19 + src/compat/maestro/points.ts | 4 + src/compat/maestro/replay-flow.ts | 19 +- src/utils/__tests__/args.test.ts | 14 + src/utils/cli-command-overrides.ts | 4 +- src/utils/cli-flags.ts | 9 + website/docs/docs/replay-e2e.md | 12 + 14 files changed, 735 insertions(+), 19 deletions(-) create mode 100644 src/cli/commands/replay.ts create mode 100644 src/compat/maestro/__tests__/export-flow.test.ts create mode 100644 src/compat/maestro/export-flow.ts create mode 100644 src/compat/maestro/flow-yaml.ts 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..6ac39214d 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -625,6 +625,44 @@ 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('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..e904a9f85 --- /dev/null +++ b/src/cli/commands/replay.ts @@ -0,0 +1,67 @@ +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'; + +export const replayCommand: ClientCommandHandler = async ({ positionals, flags }) => { + if (positionals[0] !== 'export') { + if (flags.replayExportFormat !== undefined || flags.out !== undefined) { + throw new AppError( + 'INVALID_ARGS', + 'replay --format/--out are only supported with replay export.', + ); + } + return false; + } + 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}`); + } + + 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, + sourcePath, + ...(outputPath ? { path: outputPath } : { yaml: result.yaml }), + warnings: result.warnings, + }, + () => outputPath ?? result.yaml, + ); + return true; +}; 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..3122a5263 --- /dev/null +++ b/src/compat/maestro/__tests__/export-flow.test.ts @@ -0,0 +1,90 @@ +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('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..443d7ad7b --- /dev/null +++ b/src/compat/maestro/export-flow.ts @@ -0,0 +1,467 @@ +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 }; + +const TEXT_SELECTOR_KEYS = new Set(['id', 'text', 'label']); +const STATE_SELECTOR_KEYS = new Set(['enabled', 'selected']); + +export function exportReplayScriptToMaestro(script: string): MaestroExportResult { + const parsed = parseReplayScriptDetailed(script); + return exportReplayActionsToMaestro(parsed.actions, { + actionLines: parsed.actionLines, + metadata: readReplayScriptMetadata(script), + }); +} + +export 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 } : {}; +} + +function convertAction(action: SessionAction): ConvertedAction { + switch (action.command) { + case 'open': + return convertOpenAction(action); + case 'click': + case 'press': + return convertClickAction(action); + case 'longpress': + return convertLongPressAction(action); + case 'fill': + return convertFillAction(action); + case 'type': + return convertTypeAction(action); + case 'keyboard': + return convertKeyboardAction(action); + case 'back': + return { kind: 'commands', commands: ['back'] }; + case 'wait': + return convertWaitAction(action); + case 'find': + return convertFindAction(action); + case 'screenshot': + return convertScreenshotAction(action); + case 'scroll': + return convertScrollAction(action); + case 'swipe': + return convertSwipeAction(action); + default: + return { 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 launchArgs = action.flags?.launchArgs; + const hasOptions = + action.flags?.relaunch === true || + action.flags?.clearAppState === true || + (Array.isArray(launchArgs) && launchArgs.length > 0); + if (!hasOptions) return 'launchApp'; + return { + launchApp: { + appId, + ...(action.flags?.relaunch === true ? { stopApp: true } : {}), + ...(action.flags?.clearAppState === true ? { clearState: true } : {}), + ...(Array.isArray(launchArgs) && launchArgs.length > 0 + ? { launchArguments: launchArgs } + : {}), + }, + }; +} + +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 }] }; + } + if (typeof action.flags?.holdMs === 'number') { + return { kind: 'commands', commands: [{ longPressOn: tapTarget }] }; + } + + 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 }] }; +} + +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 [x1, y1, x2, y2, duration] = action.positionals; + if (!isNumber(x1) || !isNumber(y1) || !isNumber(x2) || !isNumber(y2)) { + return { kind: 'unsupported', message: 'only coordinate swipe exports to Maestro' }; + } + const swipe = { + start: formatMaestroPoint(x1, y1), + end: formatMaestroPoint(x2, y2), + ...(duration && isNumber(duration) ? { duration: Number(duration) } : {}), + }; + const count = action.flags?.count ?? 1; + if (!Number.isInteger(count) || count < 1) { + return { kind: 'unsupported', message: 'swipe count must be a positive integer' }; + } + if (action.flags?.pauseMs !== undefined) + return { kind: 'unsupported', message: 'swipe --pause-ms has no Maestro equivalent' }; + if (action.flags?.pattern && action.flags.pattern !== 'one-way') { + return { kind: 'unsupported', message: 'swipe ping-pong pattern has no Maestro equivalent' }; + } + return { + kind: 'commands', + commands: Array.from({ length: count }, () => ({ swipe })), + }; +} + +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 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; + } + if (action.flags?.jitterPx !== undefined) { + return { ok: false, message: 'tap --jitter-px has no Maestro equivalent' }; + } + if (action.flags?.clickButton && action.flags.clickButton !== 'primary') { + return { + ok: false, + message: `tap --button ${action.flags.clickButton} has no Maestro equivalent`, + }; + } + return { ok: true, options }; +} + +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 From 9fa2a8ff136a04a5aa88d579f2cc0c6f434a9c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 19:26:47 +0200 Subject: [PATCH 2/4] refactor: simplify maestro export conversion --- src/compat/maestro/export-flow.ts | 146 +++++++++++++++++------------- 1 file changed, 81 insertions(+), 65 deletions(-) diff --git a/src/compat/maestro/export-flow.ts b/src/compat/maestro/export-flow.ts index 443d7ad7b..efe6b2782 100644 --- a/src/compat/maestro/export-flow.ts +++ b/src/compat/maestro/export-flow.ts @@ -35,6 +35,13 @@ type ConvertedAction = | { 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']); @@ -46,7 +53,7 @@ export function exportReplayScriptToMaestro(script: string): MaestroExportResult }); } -export function exportReplayActionsToMaestro( +function exportReplayActionsToMaestro( actions: SessionAction[], options: { actionLines?: number[]; @@ -103,36 +110,29 @@ function buildInitialConfig(metadata: ReplayScriptMetadata | undefined): Maestro 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 { - switch (action.command) { - case 'open': - return convertOpenAction(action); - case 'click': - case 'press': - return convertClickAction(action); - case 'longpress': - return convertLongPressAction(action); - case 'fill': - return convertFillAction(action); - case 'type': - return convertTypeAction(action); - case 'keyboard': - return convertKeyboardAction(action); - case 'back': - return { kind: 'commands', commands: ['back'] }; - case 'wait': - return convertWaitAction(action); - case 'find': - return convertFindAction(action); - case 'screenshot': - return convertScreenshotAction(action); - case 'scroll': - return convertScrollAction(action); - case 'swipe': - return convertSwipeAction(action); - default: - return { kind: 'unsupported', message: `${action.command} has no Maestro equivalent` }; - } + return ( + ACTION_CONVERTERS[action.command]?.(action) ?? { + kind: 'unsupported', + message: `${action.command} has no Maestro equivalent`, + } + ); } function convertOpenAction(action: SessionAction): ConvertedAction { @@ -154,22 +154,19 @@ function convertOpenAction(action: SessionAction): ConvertedAction { } 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 hasOptions = - action.flags?.relaunch === true || - action.flags?.clearAppState === true || - (Array.isArray(launchArgs) && launchArgs.length > 0); - if (!hasOptions) return 'launchApp'; - return { - launchApp: { - appId, - ...(action.flags?.relaunch === true ? { stopApp: true } : {}), - ...(action.flags?.clearAppState === true ? { clearState: true } : {}), - ...(Array.isArray(launchArgs) && launchArgs.length > 0 - ? { launchArguments: 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 { @@ -287,28 +284,40 @@ function convertScrollAction(action: SessionAction): ConvertedAction { } 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 { kind: 'unsupported', message: 'only coordinate swipe exports to Maestro' }; - } - const swipe = { + 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; - if (!Number.isInteger(count) || count < 1) { - return { kind: 'unsupported', message: 'swipe count must be a positive integer' }; - } - if (action.flags?.pauseMs !== undefined) - return { kind: 'unsupported', message: 'swipe --pause-ms has no Maestro equivalent' }; + 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 { kind: 'unsupported', message: 'swipe ping-pong pattern has no Maestro equivalent' }; + return 'swipe ping-pong pattern has no Maestro equivalent'; } - return { - kind: 'commands', - commands: Array.from({ length: count }, () => ({ swipe })), - }; + return undefined; } function readTapTarget(first: string, second?: string): unknown | null { @@ -368,6 +377,12 @@ function appendSelectorTerm(result: Record, term: SelectorTerm) 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; @@ -375,16 +390,17 @@ function readRepeatedTapOptions( 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 { ok: false, message: 'tap --jitter-px has no Maestro equivalent' }; + return 'tap --jitter-px has no Maestro equivalent'; } if (action.flags?.clickButton && action.flags.clickButton !== 'primary') { - return { - ok: false, - message: `tap --button ${action.flags.clickButton} has no Maestro equivalent`, - }; + return `tap --button ${action.flags.clickButton} has no Maestro equivalent`; } - return { ok: true, options }; + return undefined; } function withTapOptions(target: unknown, options: Record): MaestroCommand { From 0de157ba2f81a1adfb5f6d4f0331ade846a0b38a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 19:59:22 +0200 Subject: [PATCH 3/4] fix: warn on maestro long press duration export --- .../maestro/__tests__/export-flow.test.ts | 38 +++++++++++++++++++ src/compat/maestro/export-flow.ts | 24 +++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/compat/maestro/__tests__/export-flow.test.ts b/src/compat/maestro/__tests__/export-flow.test.ts index 3122a5263..1fac03530 100644 --- a/src/compat/maestro/__tests__/export-flow.test.ts +++ b/src/compat/maestro/__tests__/export-flow.test.ts @@ -65,6 +65,44 @@ wait 500 ]); }); + 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('rejects native-only replay actions', () => { expect(() => exportReplayScriptToMaestro(`open com.example.app diff --git a/src/compat/maestro/export-flow.ts b/src/compat/maestro/export-flow.ts index efe6b2782..1ac2d03ff 100644 --- a/src/compat/maestro/export-flow.ts +++ b/src/compat/maestro/export-flow.ts @@ -44,6 +44,8 @@ type SwipeGeometry = { 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); @@ -182,7 +184,11 @@ function convertClickAction(action: SessionAction): ConvertedAction { return { kind: 'commands', commands: [{ doubleTapOn: tapTarget }] }; } if (typeof action.flags?.holdMs === 'number') { - return { kind: 'commands', commands: [{ longPressOn: tapTarget }] }; + return { + kind: 'commands', + commands: [{ longPressOn: tapTarget }], + warnings: [formatLongPressDurationWarning(action.flags.holdMs)], + }; } return { kind: 'commands', commands: [withTapOptions(tapTarget, tapOptions.options)] }; @@ -194,7 +200,21 @@ function convertLongPressAction(action: SessionAction): ConvertedAction { const target = readTapTarget(first, second); if (!target) return { kind: 'unsupported', message: 'longpress target is not Maestro-compatible' }; - return { kind: 'commands', commands: [{ longPressOn: target }] }; + 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 convertFillAction(action: SessionAction): ConvertedAction { From 278ff8d662bee3ce2cec9a69836b9b543b5bde45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 10 Jun 2026 20:04:48 +0200 Subject: [PATCH 4/4] fix: tighten replay export edge cases --- src/__tests__/cli-client-commands.test.ts | 27 +++++++ src/cli/commands/replay.ts | 73 ++++++++++++------- .../maestro/__tests__/export-flow.test.ts | 40 ++++++++++ src/compat/maestro/export-flow.ts | 29 +++++++- 4 files changed, 142 insertions(+), 27 deletions(-) diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 6ac39214d..9c91fd872 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -663,6 +663,33 @@ click text="Continue" 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 index e904a9f85..6263753c1 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -6,36 +6,34 @@ import { resolveUserPath } from '../../utils/path-resolution.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; -export const replayCommand: ClientCommandHandler = async ({ positionals, flags }) => { +type ReplayCommandParams = Parameters[0]; + +export const replayCommand: ClientCommandHandler = async (params) => { + const { positionals } = params; if (positionals[0] !== 'export') { - if (flags.replayExportFormat !== undefined || flags.out !== undefined) { - throw new AppError( - 'INVALID_ARGS', - 'replay --format/--out are only supported with replay export.', - ); - } - return false; + return handleReplayRunCommand(params); } - if (positionals.length > 2) { + 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 export accepts exactly one input path: replay export ', + 'replay --format/--out are only supported with 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}`); - } + 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.'); @@ -56,7 +54,7 @@ export const replayCommand: ClientCommandHandler = async ({ positionals, flags } writeCommandOutput( flags, { - format, + format: flags.replayExportFormat ?? 'maestro', sourcePath, ...(outputPath ? { path: outputPath } : { yaml: result.yaml }), warnings: result.warnings, @@ -64,4 +62,29 @@ export const replayCommand: ClientCommandHandler = async ({ positionals, flags } () => 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/compat/maestro/__tests__/export-flow.test.ts b/src/compat/maestro/__tests__/export-flow.test.ts index 1fac03530..a337d5252 100644 --- a/src/compat/maestro/__tests__/export-flow.test.ts +++ b/src/compat/maestro/__tests__/export-flow.test.ts @@ -103,6 +103,46 @@ press text="Retry" --hold-ms 1500 ]); }); + 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 diff --git a/src/compat/maestro/export-flow.ts b/src/compat/maestro/export-flow.ts index 1ac2d03ff..1b21fb307 100644 --- a/src/compat/maestro/export-flow.ts +++ b/src/compat/maestro/export-flow.ts @@ -181,13 +181,20 @@ function convertClickAction(action: SessionAction): ConvertedAction { if (!tapOptions.ok) return { kind: 'unsupported', message: tapOptions.message }; if (action.flags?.doubleTap === true) { - return { kind: 'commands', commands: [{ doubleTapOn: tapTarget }] }; + 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)], + warnings: [ + formatLongPressDurationWarning(action.flags.holdMs), + ...readIgnoredRepeatedTapOptionWarnings(action, 'longPressOn'), + ], }; } @@ -217,6 +224,24 @@ 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) {