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
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,46 @@ test('computes expensive target evidence only for the command policies that cons
expect(relational).not.toHaveProperty('dispatchSelector');
});

test('projects an out-of-range index miss onto target evidence with its typed failure reason', () => {
const snapshot = makeSnapshot([
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
{
index: 1,
parentIndex: 0,
type: 'Button',
kind: formatRole('Button'),
identifier: 'not-now',
label: 'Not Now',
rect: { x: 20, y: 700, width: 120, height: 44 },
},
{
index: 2,
parentIndex: 0,
type: 'Button',
kind: formatRole('Button'),
identifier: 'not-now',
label: 'Not Now',
rect: { x: 20, y: 760, width: 120, height: 44 },
},
]);

const miss = resolveTypedMaestroTarget({
context: { generation: 3, env: {} },
snapshot,
platform: 'ios',
query: { selector: { id: 'not-now', index: 2 }, purpose: 'tap', timeoutMs: 0 },
});

expect(miss).toMatchObject({
generation: 3,
matched: true,
visible: true,
candidateCount: 2,
failureReason: 'index-out-of-range',
});
expect(miss).not.toHaveProperty('rect');
});

test('matches iOS Maestro ids on semantic nodes suppressed from interactive presentation', async () => {
const snapshot = makeSnapshot([
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ function targetMatchFromResolution(
matched: resolution.evidence.matched,
visible: resolution.evidence.visible,
candidateCount: resolution.evidence.candidateCount,
failureReason: resolution.failureReason,
...(resolution.evidence.ref ? { ref: resolution.evidence.ref } : {}),
...(viewport ? { viewport } : {}),
};
Expand Down
1 change: 1 addition & 0 deletions packages/maestro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
type MaestroSelector,
type MaestroSinglePointerGestureInput,
type MaestroSnapshotTargetQuery,
type MaestroTargetFailureReason,
type MaestroTargetMatch,
type MaestroTargetQuery,
type MaestroTargetResolution,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from 'vitest';
import { maestroObservationMatches } from '../runtime-port-observation.ts';

const visibleCondition = { kind: 'visible' as const, selector: { text: 'Not Now' } };
const notVisibleCondition = {
kind: 'notVisible' as const,
selector: { text: 'Not Now' },
timeoutMs: 0,
};

test('a visible condition requires an actionable resolution, not just matched evidence', () => {
const resolved = { generation: 0, matched: true, visible: true, candidateCount: 1 };

expect(maestroObservationMatches(visibleCondition, resolved)).toBe(true);
// An out-of-range `index` over otherwise visible matches leaves matched and
// visible evidence with no selected element; the condition must not hold.
expect(
maestroObservationMatches(visibleCondition, {
...resolved,
failureReason: 'index-out-of-range',
}),
).toBe(false);
expect(
maestroObservationMatches(visibleCondition, {
...resolved,
matched: false,
visible: false,
failureReason: 'selector-did-not-match',
}),
).toBe(false);
});

test('a notVisible condition is the strict complement of an actionable resolution', () => {
const resolved = { generation: 0, matched: true, visible: true, candidateCount: 1 };

expect(maestroObservationMatches(notVisibleCondition, resolved)).toBe(false);
expect(
maestroObservationMatches(notVisibleCondition, {
...resolved,
failureReason: 'index-out-of-range',
}),
).toBe(true);
expect(
maestroObservationMatches(notVisibleCondition, {
...resolved,
matched: false,
visible: false,
failureReason: 'selector-did-not-match',
}),
).toBe(true);
});
29 changes: 29 additions & 0 deletions packages/maestro/src/internal/__tests__/runtime-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,35 @@ describe('MaestroRuntimePort', () => {
expect(operations.resolveGestureViewport).not.toHaveBeenCalled();
});

test('reports an out-of-range index with its own message and typed reason', async () => {
const operations = makeOperations({
resolveTarget: async (_input, context) => ({
generation: context.generation,
matched: true,
visible: true,
candidateCount: 2,
failureReason: 'index-out-of-range',
ref: undefined,
}),
tapOn: async () => undefined,
});
const command = parseMaestroProgram('---\n- tapOn:\n text: Not Now\n index: 2\n')
.commands[0]!;

await expect(
createMaestroRuntimePort(operations).execute({
command: command as Extract<typeof command, { kind: 'tapOn' }>,
generation: 0,
env: {},
invalidateObservation: vi.fn(),
}),
).rejects.toMatchObject({
message:
'Maestro target "text":"Not Now", "index":2 matched 2 element(s); its index is out of range.',
details: { reason: 'maestro-test-failure', targetFailureReason: 'index-out-of-range' },
});
});

test('reports optional tap misses to the interpreter without hiding infrastructure failures', async () => {
const tapOn = vi.fn(async () => undefined);
const missingOperations = makeOperations({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,62 @@ test('typed childOf reports a scoped miss when only an outside child matches', (
});
});

test('reports an out-of-range index as its own typed failure instead of a visible miss', () => {
const snapshot = makeSnapshot([
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 320, height: 640 } },
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Not Now',
rect: { x: 16, y: 500, width: 120, height: 44 },
},
{
index: 2,
parentIndex: 0,
type: 'Button',
label: 'Not Now',
rect: { x: 16, y: 560, width: 120, height: 44 },
},
]);

expect(
resolveMaestroTargetFromSnapshot(
snapshot,
{ selector: { text: 'Not Now', index: 2 } },
'android',
),
).toMatchObject({
ok: false,
failureReason: 'index-out-of-range',
message: 'Maestro selector matched 2 visible element(s); index 2 is out of range.',
evidence: { matched: true, visible: true, candidateCount: 2 },
});
});

test('reports matched-but-invisible targets with a typed no-visible-match reason', () => {
const snapshot = makeSnapshot([
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 320, height: 640 } },
{
index: 1,
parentIndex: 0,
type: 'Button',
label: 'Hidden',
rect: { x: 16, y: 500, width: 120, height: 44 },
visibleToUser: false,
},
]);

expect(
resolveMaestroTargetFromSnapshot(snapshot, { selector: { text: 'Hidden' } }, 'android'),
).toMatchObject({
ok: false,
failureReason: 'no-visible-match',
message: 'Maestro selector matched 1 element(s), but none were visible.',
evidence: { matched: true, visible: false, candidateCount: 1 },
});
});

test('keeps Maestro-visible app content matchable while a React Native overlay is present', () => {
const snapshot = makeSnapshot([
{
Expand Down
2 changes: 2 additions & 0 deletions packages/maestro/src/internal/facade-runtime-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { literalFromMaestroRegex } from './selector-regex.ts';
import {
resolveMaestroTargetFromSnapshot,
hasMaestroRecursiveRelations,
type MaestroTargetFailureReason,
type MaestroTargetQuery as MaestroSnapshotTargetQuery,
type MaestroTargetResolution,
} from './runtime-targets.ts';
Expand Down Expand Up @@ -91,6 +92,7 @@ export type {
MaestroSelector,
MaestroSinglePointerGestureInput,
MaestroSnapshotTargetQuery,
MaestroTargetFailureReason,
MaestroTargetMatch,
MaestroTargetQuery,
MaestroTargetResolution,
Expand Down
35 changes: 30 additions & 5 deletions packages/maestro/src/internal/runtime-port-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ export async function observeMaestroCondition(

export function maestroObservationMatches(
condition: MaestroObservationCondition,
match: Pick<MaestroTargetMatch, 'matched' | 'visible'>,
match: Pick<MaestroTargetMatch, 'matched' | 'visible' | 'failureReason'>,
): boolean {
return condition.kind === 'visible'
? match.matched && match.visible
: !match.matched || !match.visible;
// A typed failure reason means no element was selected (for example an
// out-of-range `index` over matches that are otherwise visible), so the
// observed selector holds only when a target actually resolved.
const holds = match.matched && match.visible && match.failureReason === undefined;
return condition.kind === 'visible' ? holds : !holds;
}

export async function resolveMaestroTarget(
Expand All @@ -63,9 +65,10 @@ export async function resolveMaestroTarget(
);
const validated = validateTargetMatch(match, request.generation);
if (!validated.matched || !validated.visible || !validated.rect) {
throw maestroTestFailure('Maestro target did not resolve to a visible element.', {
throw maestroTestFailure(maestroTargetFailureMessage(selector, validated), {
selector,
candidateCount: validated.candidateCount,
...(validated.failureReason ? { targetFailureReason: validated.failureReason } : {}),
});
}
return {
Expand Down Expand Up @@ -93,6 +96,28 @@ export function observationForTarget(target: MaestroTargetResolution): MaestroOb
};
}

function maestroTargetFailureMessage(selector: MaestroSelector, match: MaestroTargetMatch): string {
const selectorText = formatMaestroSelector(selector);
switch (match.failureReason) {
case 'selector-did-not-match':
return `Maestro target ${selectorText} did not match any element.`;
case 'no-visible-match':
return `Maestro target ${selectorText} matched ${match.candidateCount} element(s), but none were visible.`;
case 'index-out-of-range':
return `Maestro target ${selectorText} matched ${match.candidateCount} element(s); its index is out of range.`;
case 'no-usable-geometry':
return `Maestro target ${selectorText} did not provide usable geometry.`;
default:
return 'Maestro target did not resolve to a visible element.';
}
}

function formatMaestroSelector(selector: MaestroSelector): string {
const entries = Object.entries(selector).filter(([, value]) => value !== undefined);
if (entries.length === 0) return 'selector';
return entries.map(([key, value]) => `"${key}":${JSON.stringify(value)}`).join(', ');
}

function isRect(value: unknown): value is { x: number; y: number; width: number; height: number } {
if (!value || typeof value !== 'object') return false;
const rect = value as Record<string, unknown>;
Expand Down
8 changes: 8 additions & 0 deletions packages/maestro/src/internal/runtime-port-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
MaestroObservationCondition,
MaestroObservationEvidence,
} from './engine-types.ts';
import type { MaestroTargetFailureReason } from './runtime-targets.ts';

export type MaestroRuntimeReadContext = {
readonly appId?: string;
Expand Down Expand Up @@ -40,6 +41,13 @@ export type MaestroTargetMatch = {
readonly ref?: string;
readonly dispatchSelector?: MaestroDispatchSelector;
readonly surfaceSignature?: string;
/**
* The typed reason for a failed resolution. An out-of-range `index` or a degenerate
* candidate geometry leaves a resolution matched and visible with no target, and
* `MaestroTargetMatch` is observation evidence too, where `matched`/`visible` ARE the
* outcome — so the absence of a dispatch target travels as its own reason.
*/
readonly failureReason?: MaestroTargetFailureReason;
};

export type MaestroDispatchSelector = {
Expand Down
Loading
Loading