Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentDeviceClient['command']['wait']>[0] | undefined;
const client = createStubClient({
Expand Down
90 changes: 90 additions & 0 deletions src/cli/commands/replay.ts
Original file line number Diff line number Diff line change
@@ -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<ClientCommandHandler>[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 <path>');
}
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<true> {
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 <file.ad>',
);
}
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}`);
}
}
2 changes: 2 additions & 0 deletions src/cli/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,6 +21,7 @@ const dedicatedCliCommandHandlers = {
disconnect: disconnectCommand,
connection: connectionCommand,
auth: authCommand,
replay: replayCommand,
screenshot: screenshotCommand,
diff: diffCommand,
} satisfies ClientCommandHandlerMap;
Expand Down
168 changes: 168 additions & 0 deletions src/compat/maestro/__tests__/export-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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 <ms> 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());
}
7 changes: 6 additions & 1 deletion src/compat/maestro/__tests__/points.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down
Loading
Loading