Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Maestro compat: `assertVisible` and `assertNotVisible` now accept `childOf` for ancestor scoping, matching `tapOn` (#1294).
- Breaking: removed deprecated gesture duration and rotate velocity inputs (#1218).
- `swipe x1 y1 x2 y2` no longer accepts a trailing `durationMs` positional; use `gesture pan x1 y1 (x2-x1) (y2-y1) durationMs` for deliberate timed drags.
- Maestro `swipe` operations with a duration continue to normalize to `gesture pan` with the `endpoint-hold` execution profile, preserving the Maestro-compatible fast-swipe-then-hold behavior on iOS.
Expand Down
6 changes: 0 additions & 6 deletions scripts/maestro-conformance/expected-divergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,6 @@ export const FLOW_DIVERGENCES: Record<string, FlowDivergence> = {
unsupported: ['doubleTapOn.retryTapIfNoChange'],
tracking: COMPAT_TRACKER,
},
'upstream/114_child_of_selector': {
classification: 'we-reject',
reason: 'childOf is supported on tapOn but not on assertVisible/assertNotVisible (structural selector).',
unsupported: ['assertVisible.childOf', 'assertNotVisible.childOf'],
tracking: COMPAT_TRACKER,
},
'upstream/119_retry_commands': {
classification: 'we-reject',
reason: 'retry is supported; the flow uses the unsupported per-command waitToSettleTimeoutMs option on a nested tapOn.',
Expand Down
10 changes: 7 additions & 3 deletions scripts/maestro-conformance/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,14 +345,14 @@ function canonicalizeAgentCommand(
kind: 'assert',
mode: 'visible',
timed: false,
selector: agentSelector(command.target),
selector: agentSelector(command.target, command.childOf),
});
case 'assertNotVisible':
return dropUndefined({
kind: 'assert',
mode: 'notVisible',
timed: false,
selector: agentSelector(command.target),
selector: agentSelector(command.target, command.childOf),
});
case 'extendedWaitUntil':
return dropUndefined({
Expand Down Expand Up @@ -421,13 +421,17 @@ function agentTarget(
return { point: { x: target.x, y: target.y, unit: target.space === 'percent' ? 'percent' : 'px' } };
}

function agentSelector(selector: MaestroSelector | undefined): CanonicalSelector | undefined {
function agentSelector(
selector: MaestroSelector | undefined,
childOf?: MaestroSelector,
): CanonicalSelector | undefined {
if (!selector) return undefined;
return dropUndefined({
text: selector.text,
id: selector.id,
enabled: selector.enabled,
selected: selector.selected,
childOf: childOf ? agentSelector(childOf) : undefined,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { expect, test } from 'vitest';
import type { DaemonRequest } from '../../../daemon/types.ts';
import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts';
import type { MaestroObservation } from '../engine-types.ts';
import { parseMaestroProgram } from '../program-ir-parser.ts';
import {
MAESTRO_OBSERVATION_POLL_MS,
maestroSnapshotSignature,
resolveTypedMaestroTarget,
waitForTypedSnapshotStability,
} from '../daemon-runtime-port-observation.ts';
import { makeBaseRequest, makeDependencies, makeSnapshot } from './daemon-runtime-port-fixtures.ts';
import { executeMaestroProgram } from './runtime-port-fixtures.ts';

test('replaces pre-mutation evidence with the stable post-mutation snapshot', async () => {
const requests: DaemonRequest[] = [];
Expand Down Expand Up @@ -280,3 +283,98 @@ test('excludes agent-device presentation metadata from Maestro hierarchy signatu

expect(maestroSnapshotSignature(first)).toBe(maestroSnapshotSignature(second));
});

test('assertVisible and assertNotVisible scope duplicate matching children by childOf ancestor', async () => {
const snapshot = makeSnapshot([
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
{
index: 1,
parentIndex: 0,
type: 'Text',
identifier: 'parent_id_1',
label: 'parent_id_1',
rect: { x: 20, y: 40, width: 120, height: 44 },
},
{
index: 2,
parentIndex: 1,
type: 'Text',
identifier: 'child_id',
label: 'child_id',
rect: { x: 24, y: 44, width: 112, height: 40 },
},
{
index: 3,
parentIndex: 0,
type: 'Text',
identifier: 'parent_id_3',
label: 'parent_id_3',
rect: { x: 20, y: 200, width: 120, height: 44 },
},
{
index: 4,
parentIndex: 0,
type: 'Text',
identifier: 'child_id',
label: 'child_id',
rect: { x: 24, y: 300, width: 112, height: 40 },
},
]);

const port = createDaemonMaestroRuntimePort({
baseReq: makeBaseRequest({ flags: { platform: 'ios', replayBackend: 'maestro' } }),
invoke: async (request) => {
if (request.command === 'snapshot') return { ok: true, data: snapshot };
return { ok: true, data: {} };
},
dependencies: makeDependencies(),
platform: 'ios',
});

const observations: MaestroObservation[] = [];
const originalObserve = port.observe.bind(port);
port.observe = async (request) => {
const result = await originalObserve(request);
observations.push(result);
return result;
};

const program = parseMaestroProgram(
[
'appId: com.example.app',
'---',
'- assertVisible:',
' text: child_id',
' childOf:',
' text: parent_id_1',
'- assertNotVisible:',
' text: child_id',
' childOf:',
' text: parent_id_3',
].join('\n'),
);

const result = await executeMaestroProgram(program, port);

expect(result).toMatchObject({ executed: 2, skipped: 0 });
expect(observations).toHaveLength(2);

const visible = observations[0]!;
const notVisible = observations[1]!;
expect(visible.matched).toBe(true);
expect(visible.evidence).toMatchObject({
selector: { text: 'child_id' },
childOf: { text: 'parent_id_1' },
candidateCount: 1,
visible: true,
ref: 'e3',
});

expect(notVisible.matched).toBe(true);
expect(notVisible.evidence).toMatchObject({
selector: { text: 'child_id' },
childOf: { text: 'parent_id_3' },
candidateCount: 0,
visible: false,
});
});
2 changes: 1 addition & 1 deletion src/compat/maestro/daemon-runtime-port-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export async function observeTypedMaestroCondition(params: {
throwIfAborted(params.context.signal);
const snapshot = await captureRetriableMaestroSnapshot(params, conditionDeadline);
const match = resolveTargetFromSnapshot({
query: { selector: params.condition.selector },
query: { selector: params.condition.selector, childOf: params.condition.childOf },
context: params.context,
snapshot,
platform: params.platform,
Expand Down
5 changes: 3 additions & 2 deletions src/compat/maestro/engine-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type MaestroObservationEvidence = {
visible: boolean;
candidateCount: number;
ref?: string;
childOf?: MaestroSelector;
};

export type MaestroObservation = {
Expand All @@ -69,8 +70,8 @@ export type MaestroObservation = {
};

export type MaestroObservationCondition =
| { kind: 'visible'; selector: MaestroSelector }
| { kind: 'notVisible'; selector: MaestroSelector };
| { kind: 'visible'; selector: MaestroSelector; childOf?: MaestroSelector }
| { kind: 'notVisible'; selector: MaestroSelector; childOf?: MaestroSelector };

export type MaestroRuntimeRequest = {
command: MaestroRuntimeCommand;
Expand Down
17 changes: 13 additions & 4 deletions src/compat/maestro/program-ir-command-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,18 +264,27 @@ function parseAssertion(
return { kind, source, target: parseMaestroSelector(value, kind, context) };
}
const entries = readMapEntries(value, kind, context);
assertOnlyKeys(entries, kind, ['id', 'text', 'enabled', 'selected', 'optional'], context);
assertOnlyKeys(
entries,
kind,
['id', 'text', 'enabled', 'selected', 'optional', 'childOf'],
context,
);
const options = readOptionalCommandOption(entries, kind, context);
return {
const childOf = hasEntry(entries, 'childOf')
? parseMaestroSelector(entryValue(entries, 'childOf'), `${kind}.childOf`, context)
: undefined;
return stripUndefined({
kind,
source,
target: parseMaestroSelectorMapEntries(
entries.filter((entry) => entry.key !== 'optional'),
entries.filter((entry) => entry.key !== 'optional' && entry.key !== 'childOf'),
kind,
context,
),
...options,
};
childOf,
});
}

function parseExtendedWaitUntil(
Expand Down
2 changes: 2 additions & 0 deletions src/compat/maestro/program-ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,14 @@ export type MaestroAssertVisibleCommand = MaestroOptionalCommand & {
kind: 'assertVisible';
source: MaestroSourceLocation;
target: MaestroSelector;
childOf?: MaestroSelector;
};

export type MaestroAssertNotVisibleCommand = MaestroOptionalCommand & {
kind: 'assertNotVisible';
source: MaestroSourceLocation;
target: MaestroSelector;
childOf?: MaestroSelector;
};

export type MaestroExtendedWaitUntilCommand = MaestroOptionalCommand & {
Expand Down
4 changes: 2 additions & 2 deletions src/compat/maestro/replay-plan-step-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ async function executeCommand(
switch (command.kind) {
case 'assertVisible':
await requireObservation(
{ kind: 'visible', selector: command.target },
{ kind: 'visible', selector: command.target, childOf: command.childOf },
state.timing.assertVisibleTimeoutMs,
state,
);
state.executed += 1;
return undefined;
case 'assertNotVisible':
await requireObservation(
{ kind: 'notVisible', selector: command.target },
{ kind: 'notVisible', selector: command.target, childOf: command.childOf },
state.timing.assertNotVisibleTimeoutMs,
state,
);
Expand Down
2 changes: 2 additions & 0 deletions src/compat/maestro/runtime-port-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export async function observeMaestroCondition(
visible: match.visible,
candidateCount: match.candidateCount,
...(match.ref ? { ref: match.ref } : {}),
...(request.condition.childOf ? { childOf: request.condition.childOf } : {}),
};
return {
generation: request.generation,
Expand Down Expand Up @@ -84,6 +85,7 @@ export function observationForTarget(target: MaestroTargetResolution): MaestroOb
visible: target.visible,
candidateCount: target.candidateCount,
...(target.ref ? { ref: target.ref } : {}),
...(target.query.childOf ? { childOf: target.query.childOf } : {}),
};
return {
generation: target.generation,
Expand Down
2 changes: 1 addition & 1 deletion src/compat/maestro/support-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [
'optional target and assertion commands',
'inputText and focused-field eraseText',
'openLink',
'visibility assertions and extendedWaitUntil',
'visibility assertions including childOf and extendedWaitUntil',
'scroll and scrollUntilVisible',
'absolute/percentage swipe and swipe.label',
'screenshots',
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/replay-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Maestro compatibility parses supported YAML into a source-preserving typed progr
- Supported and unsupported capabilities: https://github.com/callstack/agent-device/issues/558
- New focused compatibility request: https://github.com/callstack/agent-device/issues/new

Currently supported areas include app launch with Apple-platform launch arguments and Android/iOS simulator `clearState`, `runFlow` file/inline with `when.platform`, `when.visible`, `when.notVisible`, and limited `when.true` boolean/platform expressions, `onFlowStart` and `onFlowComplete` hooks, deterministic `repeat.times` and retry blocks, `tapOn` including `index`, `childOf`, `label`, and absolute/percentage point taps, `doubleTapOn` and `longPressOn`, `optional` target and assertion commands, `inputText` and focused-field `eraseText`, `openLink`, visibility assertions and `extendedWaitUntil`, `scroll` and `scrollUntilVisible`, absolute/percentage `swipe` and `swipe.label`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, and `stopApp`, and ordered trusted `runScript` file/env scripts with `http.post`, `json`, and `output` variables. `runScript` is supported only as an ordered Maestro compatibility step for trusted file/env scripts; it can make network requests, and is not a native `.ad` command or security sandbox. Script execution uses Node `vm` only for compatibility isolation, not for security; the script timeout bounds synchronous execution, while `http.post` requests are bounded by the helper process timeout. Output keys cannot contain `.` because exported variables are addressed as `output.<key>`.
Currently supported areas include app launch with Apple-platform launch arguments and Android/iOS simulator `clearState`, `runFlow` file/inline with `when.platform`, `when.visible`, `when.notVisible`, and limited `when.true` boolean/platform expressions, `onFlowStart` and `onFlowComplete` hooks, deterministic `repeat.times` and retry blocks, `tapOn` including `index`, `childOf`, `label`, and absolute/percentage point taps, `doubleTapOn` and `longPressOn`, `optional` target and assertion commands, `inputText` and focused-field `eraseText`, `openLink`, visibility assertions including `childOf` and `extendedWaitUntil`, `scroll` and `scrollUntilVisible`, absolute/percentage `swipe` and `swipe.label`, screenshots, keyboard dismiss, basic `pressKey`, `back`, animation waits, and `stopApp`, and ordered trusted `runScript` file/env scripts with `http.post`, `json`, and `output` variables. `runScript` is supported only as an ordered Maestro compatibility step for trusted file/env scripts; it can make network requests, and is not a native `.ad` command or security sandbox. Script execution uses Node `vm` only for compatibility isolation, not for security; the script timeout bounds synchronous execution, while `http.post` requests are bounded by the helper process timeout. Output keys cannot contain `.` because exported variables are addressed as `output.<key>`.

Maestro `env` values use the same replay precedence as `.ad` files: flow `env` is the default, shell `AD_VAR_*` values override it, and CLI `-e KEY=VALUE` wins over both.

Expand Down
Loading