Skip to content

Commit f74ad06

Browse files
committed
fix: tighten maestro compat support plumbing
1 parent f1b1a74 commit f74ad06

12 files changed

Lines changed: 213 additions & 87 deletions

File tree

scripts/run-test-app-maestro-suite.mjs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,14 @@ if (options.openTarget) {
7272
runAgentDevice(['wait', 'Agent Device Tester', '30000', '--platform', options.platform, ...options.passthrough]);
7373
}
7474

75-
for (const flow of flows) {
76-
runAgentDevice(['replay', flow, '--maestro', '--platform', options.platform, ...options.passthrough]);
77-
}
75+
runAgentDevice([
76+
'test',
77+
options.flowDir,
78+
'--maestro',
79+
'--platform',
80+
options.platform,
81+
...options.passthrough,
82+
]);
7883

7984
if (options.close) {
8085
runAgentDevice(['close']);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import fs from 'node:fs';
2+
import { expect, test } from 'vitest';
3+
import { getFlagDefinitions } from '../../../utils/cli-flags.ts';
4+
import {
5+
MAESTRO_COMPAT_SUPPORTED_CAPABILITIES,
6+
MAESTRO_COMPAT_TRACKER_URL,
7+
MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES,
8+
formatMaestroCapabilityList,
9+
} from '../support-matrix.ts';
10+
11+
test('Maestro CLI help uses the shared compatibility support matrix', () => {
12+
const flag = getFlagDefinitions().find((definition) => definition.key === 'replayMaestro');
13+
expect(flag?.usageDescription).toContain(
14+
`Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`,
15+
);
16+
expect(flag?.usageDescription).toContain(MAESTRO_COMPAT_TRACKER_URL);
17+
});
18+
19+
test('Maestro replay docs stay in sync with the compatibility support matrix', () => {
20+
const docs = fs.readFileSync('website/docs/docs/replay-e2e.md', 'utf8');
21+
const plainDocs = docs.replace(/`/g, '');
22+
for (const capability of MAESTRO_COMPAT_SUPPORTED_CAPABILITIES) {
23+
expect(plainDocs).toContain(capability);
24+
}
25+
for (const capability of MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES) {
26+
expect(plainDocs).toContain(capability);
27+
}
28+
});

src/compat/maestro/runtime-flow.ts

Lines changed: 21 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { type CommandFlags } from '../../core/dispatch.ts';
2-
import type { DaemonRequest, DaemonResponse, SessionAction } from '../../daemon/types.ts';
2+
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
33
import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts';
44
import {
5-
batchStepToSessionAction,
5+
batchStepsToSessionActions,
6+
invokeReplayActionBlock,
7+
invokeReplayRetryBlock,
8+
} from '../../replay/control-flow-runtime.ts';
9+
import {
610
captureMaestroRawSnapshot,
711
errorResponse,
812
readSnapshotState,
@@ -53,16 +57,13 @@ export async function invokeMaestroRetry(params: {
5357
return errorResponse('INVALID_ARGS', 'retry.maxRetries must be a non-negative integer.');
5458
}
5559

56-
const steps = (params.batchSteps ?? []).map(batchStepToSessionAction);
57-
let lastResponse: DaemonResponse | undefined;
58-
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
59-
const response = await invokeMaestroRetryAttempt(params, steps, attempt);
60-
if (response.ok) {
61-
return { ok: true, data: { attempts: attempt + 1, retried: attempt > 0 } };
62-
}
63-
lastResponse = response;
64-
}
65-
return lastResponse ?? errorResponse('COMMAND_FAILED', 'retry commands failed.');
60+
return await invokeReplayRetryBlock({
61+
actions: batchStepsToSessionActions(params.batchSteps),
62+
maxRetries,
63+
line: params.line,
64+
step: params.step,
65+
invokeReplayAction: params.invokeReplayAction,
66+
});
6667
}
6768

6869
function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowWhenCondition {
@@ -118,40 +119,16 @@ async function invokeMaestroRunFlowWhenSteps(
118119
},
119120
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
120121
): Promise<DaemonResponse> {
121-
const steps = (params.batchSteps ?? []).map(batchStepToSessionAction);
122-
for (const [index, action] of steps.entries()) {
123-
// Preserve stable parent-step ordering for nested runtime commands while
124-
// keeping the substep distinguishable in traces.
125-
const response = await params.invokeReplayAction({
126-
action,
127-
line: params.line,
128-
step: params.step + index / 1000,
129-
});
130-
if (!response.ok) return response;
131-
}
122+
const response = await invokeReplayActionBlock({
123+
actions: batchStepsToSessionActions(params.batchSteps),
124+
line: params.line,
125+
step: params.step,
126+
invokeReplayAction: params.invokeReplayAction,
127+
});
128+
if (!response.ok) return response;
132129

133130
return {
134131
ok: true,
135-
data: { ran: steps.length, condition: condition.mode, selector: condition.selector },
132+
data: { ran: response.data?.ran, condition: condition.mode, selector: condition.selector },
136133
};
137134
}
138-
139-
async function invokeMaestroRetryAttempt(
140-
params: {
141-
line: number;
142-
step: number;
143-
invokeReplayAction: MaestroReplayInvoker;
144-
},
145-
steps: SessionAction[],
146-
attempt: number,
147-
): Promise<DaemonResponse> {
148-
for (const [index, action] of steps.entries()) {
149-
const response = await params.invokeReplayAction({
150-
action,
151-
line: params.line,
152-
step: params.step + attempt + index / 1000,
153-
});
154-
if (!response.ok) return response;
155-
}
156-
return { ok: true, data: { ran: steps.length } };
157-
}

src/compat/maestro/runtime-geometry.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Rect, SnapshotNode } from '../../utils/snapshot.ts';
2+
import { interiorCoordinate, pointInsideRect } from '../../utils/rect-center.ts';
23
import { normalizeType } from '../../utils/snapshot-processing.ts';
34
import type { MaestroSnapshotTarget } from './runtime-targets.ts';
45

@@ -96,13 +97,6 @@ function clampCoordinate(value: number, min: number, max: number): number {
9697
return Math.round(Math.min(max, Math.max(min, value)));
9798
}
9899

99-
function pointInsideRect(rect: Rect): { x: number; y: number } {
100-
return {
101-
x: interiorCoordinate(rect.x, rect.width),
102-
y: interiorCoordinate(rect.y, rect.height),
103-
};
104-
}
105-
106100
function shouldBiasMaestroVisibleTextTap(
107101
node: SnapshotNode,
108102
isVisibleTextSelector: boolean,
@@ -117,13 +111,3 @@ function shouldBiasMaestroVisibleTextTap(
117111
if (rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight) return false;
118112
return type === 'cell' || type === 'other' || scrollableTextContainer;
119113
}
120-
121-
function interiorCoordinate(origin: number, size: number): number {
122-
// Maestro flows often expose hidden E2E controls as 1x1 views at the screen
123-
// edge. Preserve zero-origin taps for those controls instead of nudging them
124-
// outside their tiny rect by applying normal center/bounds clamping.
125-
if (size <= 1) return Math.floor(origin);
126-
const min = Math.ceil(origin);
127-
const max = Math.floor(origin + size - 1);
128-
return clampCoordinate(origin + size / 2, min, max);
129-
}

src/compat/maestro/runtime-support.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { CommandFlags } from '../../core/dispatch.ts';
21
import {
32
getSnapshotReferenceFrame,
43
type TouchReferenceFrame,
@@ -74,18 +73,3 @@ function rememberMaestroReferenceFrame(scope: ReplayVarScope, data: unknown): vo
7473
const frame = getSnapshotReferenceFrame(snapshot);
7574
if (frame) maestroReferenceFrameCache.set(scope, frame);
7675
}
77-
78-
export function batchStepToSessionAction(
79-
step: NonNullable<CommandFlags['batchSteps']>[number],
80-
): SessionAction {
81-
const action: SessionAction = {
82-
ts: Date.now(),
83-
command: step.command,
84-
positionals: step.positionals ?? [],
85-
flags: step.flags ?? {},
86-
};
87-
if (step.runtime && typeof step.runtime === 'object') {
88-
action.runtime = step.runtime as SessionAction['runtime'];
89-
}
90-
return action;
91-
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [
2+
'app launch with Apple-platform launch arguments and iOS simulator clearState',
3+
'runFlow file/inline with when.platform, when.visible, when.notVisible, and limited when.true boolean/platform expressions',
4+
'onFlowStart and onFlowComplete hooks',
5+
'deterministic repeat.times',
6+
'tapOn including optional, index, childOf, label, and absolute/percentage point taps',
7+
'doubleTapOn and longPressOn',
8+
'inputText, focused-field eraseText, and pasteText',
9+
'openLink',
10+
'visibility assertions and extendedWaitUntil',
11+
'scroll and scrollUntilVisible',
12+
'absolute/percentage swipe and swipe.label',
13+
'screenshots',
14+
'keyboard dismiss',
15+
'basic pressKey, back, animation waits, and stopApp',
16+
'ordered trusted runScript file/env scripts with http.post, json, and output variables',
17+
] as const;
18+
19+
export const MAESTRO_COMPAT_UNSUPPORTED_CAPABILITIES = [
20+
'repeat.while',
21+
'full expression predicates beyond boolean literals and maestro.platform comparisons',
22+
'evalScript',
23+
'device utility commands',
24+
'Android app launch arguments',
25+
'Android app state reset',
26+
] as const;
27+
28+
export const MAESTRO_COMPAT_TRACKER_URL =
29+
'https://github.com/callstackincubator/agent-device/issues/558';
30+
31+
export const MAESTRO_NEW_ISSUE_URL =
32+
'https://github.com/callstackincubator/agent-device/issues/new';
33+
34+
export function formatMaestroSupportedSubsetForCli(): string {
35+
return `Supported subset: ${formatMaestroCapabilityList(MAESTRO_COMPAT_SUPPORTED_CAPABILITIES)}.`;
36+
}
37+
38+
export function formatMaestroCapabilityList(capabilities: readonly string[]): string {
39+
return capabilities.length > 1
40+
? `${capabilities.slice(0, -1).join(', ')}, and ${capabilities.at(-1)}`
41+
: (capabilities[0] ?? '');
42+
}

src/compat/maestro/support.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import type { SessionAction } from '../../daemon/types.ts';
22
import { AppError } from '../../utils/errors.ts';
3+
import { MAESTRO_COMPAT_TRACKER_URL, MAESTRO_NEW_ISSUE_URL } from './support-matrix.ts';
34
import type { MaestroCommand, MaestroFlowConfig, MaestroParseContext } from './types.ts';
45

5-
const MAESTRO_COMPAT_TRACKER_URL = 'https://github.com/callstackincubator/agent-device/issues/558';
6-
const MAESTRO_NEW_ISSUE_URL = 'https://github.com/callstackincubator/agent-device/issues/new';
7-
86
export function action(
97
command: string,
108
positionals: string[] = [],

src/replay/control-flow-runtime.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { CommandFlags } from '../core/dispatch.ts';
2+
import type { DaemonResponse, SessionAction } from '../daemon/types.ts';
3+
4+
export type ReplayActionBlockInvoker = (params: {
5+
action: SessionAction;
6+
line: number;
7+
step: number;
8+
}) => Promise<DaemonResponse>;
9+
10+
function batchStepToSessionAction(
11+
step: NonNullable<CommandFlags['batchSteps']>[number],
12+
): SessionAction {
13+
const action: SessionAction = {
14+
ts: Date.now(),
15+
command: step.command,
16+
positionals: step.positionals ?? [],
17+
flags: step.flags ?? {},
18+
};
19+
if (step.runtime && typeof step.runtime === 'object') {
20+
action.runtime = step.runtime as SessionAction['runtime'];
21+
}
22+
return action;
23+
}
24+
25+
export function batchStepsToSessionActions(
26+
batchSteps: CommandFlags['batchSteps'] | undefined,
27+
): SessionAction[] {
28+
return (batchSteps ?? []).map(batchStepToSessionAction);
29+
}
30+
31+
export async function invokeReplayActionBlock(params: {
32+
actions: SessionAction[];
33+
line: number;
34+
step: number;
35+
invokeReplayAction: ReplayActionBlockInvoker;
36+
}): Promise<DaemonResponse> {
37+
for (const [index, action] of params.actions.entries()) {
38+
const response = await params.invokeReplayAction({
39+
action,
40+
line: params.line,
41+
step: params.step + index / 1000,
42+
});
43+
if (!response.ok) return response;
44+
}
45+
return { ok: true, data: { ran: params.actions.length } };
46+
}
47+
48+
export async function invokeReplayRetryBlock(params: {
49+
actions: SessionAction[];
50+
maxRetries: number;
51+
line: number;
52+
step: number;
53+
invokeReplayAction: ReplayActionBlockInvoker;
54+
}): Promise<DaemonResponse> {
55+
let lastResponse: DaemonResponse | undefined;
56+
for (let attempt = 0; attempt <= params.maxRetries; attempt += 1) {
57+
const response = await invokeReplayActionBlock({
58+
actions: params.actions,
59+
line: params.line,
60+
step: params.step + attempt,
61+
invokeReplayAction: params.invokeReplayAction,
62+
});
63+
if (response.ok) {
64+
return { ok: true, data: { attempts: attempt + 1, retried: attempt > 0 } };
65+
}
66+
lastResponse = response;
67+
}
68+
return (
69+
lastResponse ?? {
70+
ok: false,
71+
error: { code: 'COMMAND_FAILED', message: 'retry commands failed.' },
72+
}
73+
);
74+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { expect, test } from 'vitest';
2+
import { interiorCoordinate, pointInsideRect } from '../rect-center.ts';
3+
4+
test('interiorCoordinate preserves one-pixel edge controls', () => {
5+
expect(interiorCoordinate(0, 1)).toBe(0);
6+
});
7+
8+
test('pointInsideRect clamps center point inside the rect bounds', () => {
9+
expect(pointInsideRect({ x: 0.2, y: 10.2, width: 10, height: 5 })).toEqual({
10+
x: 5,
11+
y: 13,
12+
});
13+
});

src/utils/cli-flags.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS,
66
type ScreenshotRequestFlags,
77
} from '../commands/capture-screenshot-options.ts';
8+
import {
9+
MAESTRO_COMPAT_TRACKER_URL,
10+
formatMaestroSupportedSubsetForCli,
11+
} from '../compat/maestro/support-matrix.ts';
812

913
export type CliFlags = RemoteConfigMetroOptions &
1014
ScreenshotRequestFlags & {
@@ -760,8 +764,8 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
760764
type: 'boolean',
761765
usageLabel: '--maestro',
762766
usageDescription:
763-
'Replay: treat input as a Maestro YAML compatibility flow. Supported subset: launchApp without state-reset side effects, runFlow file/inline with when.platform, onFlowStart/onFlowComplete, deterministic repeat.times, ordered trusted runScript, tapOn, doubleTapOn, longPressOn, inputText, pasteText, openLink, assertVisible, assertNotVisible, assertTrue literal true/false, extendedWaitUntil, scroll, absolute/percentage swipe, takeScreenshot, hideKeyboard, pressKey back/enter/home, back, waitForAnimationToEnd, stopApp/killApp, setAirplaneMode, setLocation, setOrientation, supported setPermissions targets, and startRecording/stopRecording. ' +
764-
'Unsupported syntax fails loudly with a link to https://github.com/callstackincubator/agent-device/issues/558',
767+
`Replay: treat input as a Maestro YAML compatibility flow. ${formatMaestroSupportedSubsetForCli()} ` +
768+
`Unsupported syntax fails loudly with a link to ${MAESTRO_COMPAT_TRACKER_URL}`,
765769
},
766770
{
767771
key: 'replayEnv',

0 commit comments

Comments
 (0)