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
3 changes: 0 additions & 3 deletions src/contracts/__tests__/interaction-guarantees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,6 @@ test('acknowledged gaps are visible and bounded', () => {
// (umbrella: https://github.com/callstack/agent-device/issues/1081).
assert.deepEqual(gaps.sort(), [
'direct-ios-selector/disambiguation',
'direct-ios-selector/errorTaxonomy',
'direct-ios-selector/nonHittable',
'direct-ios-selector/occlusion',
'direct-ios-selector/responseIdentity',
'maestro-non-hittable-fallback/errorTaxonomy',
]);
Expand Down
21 changes: 9 additions & 12 deletions src/contracts/interaction-guarantees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,9 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
trackingIssue: GAPS_UMBRELLA_ISSUE,
},
occlusion: {
kind: 'waived',
reason:
'gap: no explicit covered-element check on the direct path; closure strategy is delegation-on-error plus contract scenarios, since XCTest isHittable only approximates occlusion.',
trackingIssue: GAPS_UMBRELLA_ISSUE,
kind: 'delegated',
to: 'runtime-selector',
via: 'runner ELEMENT_NOT_FOUND/AMBIGUOUS_MATCH fall back to tree-based resolution (isDirectIosSelectorFallbackError delegateSemanticFailures; non-maestro dispatches only) — XCTest skips covered/non-hittable matches, so the runtime path raises the covered-element refusal with its hint',
},
offscreen: {
// Decision: TapPointPolicy (pure geometry, parity-tested against the
Expand All @@ -229,10 +228,9 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
parityTable: 'contracts/fixtures/tap-point-policy.json',
},
nonHittable: {
kind: 'waived',
reason:
'gap: non-hittable matches are skipped runner-side (ELEMENT_NOT_FOUND) instead of promoted/annotated; closure strategy is delegation-on-error into the runtime path.',
trackingIssue: GAPS_UMBRELLA_ISSUE,
kind: 'delegated',
to: 'runtime-selector',
via: 'runner ELEMENT_NOT_FOUND (non-hittable matches are skipped runner-side) falls back to tree-based resolution (isDirectIosSelectorFallbackError delegateSemanticFailures; non-maestro dispatches only), which promotes to a hittable ancestor or annotates targetHittable/hint',
},
responseConstruction: SHARED_RESPONSE_CONSTRUCTION,
responseIdentity: {
Expand All @@ -246,10 +244,9 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
via: '--verify disables the direct path (readDirectIosSelectorTapTarget / fill flags.verify check)',
},
errorTaxonomy: {
kind: 'waived',
reason:
'gap: ELEMENT_NOT_FOUND/AMBIGUOUS_MATCH lack the selector diagnostics and hints the runtime path attaches; closure strategy is delegation-on-error for the failure shapes.',
trackingIssue: GAPS_UMBRELLA_ISSUE,
kind: 'delegated',
to: 'runtime-selector',
via: 'runner ELEMENT_NOT_FOUND/AMBIGUOUS_MATCH fall back to tree-based resolution (isDirectIosSelectorFallbackError delegateSemanticFailures; non-maestro dispatches only), which attaches the shared no-match diagnostics, ambiguous shape, and hints',
},
},
},
Expand Down
39 changes: 34 additions & 5 deletions src/daemon/__tests__/direct-ios-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,47 @@ test('runner ELEMENT_OFFSCREEN always falls back to tree-based resolution', () =
const error = new AppError('ELEMENT_OFFSCREEN', 'element resolved off-screen at (-161, 265)');
assert.equal(isDirectIosSelectorFallbackError(error), true);
assert.equal(isDirectIosSelectorFallbackError(error, { allowElementNotFound: false }), true);
assert.equal(isDirectIosSelectorFallbackError(error, { delegateSemanticFailures: false }), true);
});

test('runner ELEMENT_NOT_FOUND falls back only when the caller allows it', () => {
test('runner ELEMENT_NOT_FOUND falls back for query callers that allow it', () => {
const error = new AppError('ELEMENT_NOT_FOUND', 'element not found');
assert.equal(isDirectIosSelectorFallbackError(error), false);
assert.equal(isDirectIosSelectorFallbackError(error, { allowElementNotFound: true }), true);
});

test('semantic failures delegate to the runtime path for interaction dispatches (ADR 0011)', () => {
const notFound = new AppError('ELEMENT_NOT_FOUND', 'element not found');
const ambiguous = new AppError('AMBIGUOUS_MATCH', 'multiple');
assert.equal(
isDirectIosSelectorFallbackError(notFound, { delegateSemanticFailures: true }),
true,
);
assert.equal(
isDirectIosSelectorFallbackError(ambiguous, { delegateSemanticFailures: true }),
true,
);
});

test('maestro replay dispatches preserve the runner semantic error shapes (no fallback)', () => {
const notFound = new AppError('ELEMENT_NOT_FOUND', 'element not found');
const ambiguous = new AppError('AMBIGUOUS_MATCH', 'multiple');
assert.equal(
isDirectIosSelectorFallbackError(notFound, { delegateSemanticFailures: false }),
false,
);
assert.equal(
isDirectIosSelectorFallbackError(ambiguous, { delegateSemanticFailures: false }),
false,
);
});

test('AMBIGUOUS_MATCH does not fall back on the query path (allowElementNotFound callers)', () => {
const ambiguous = new AppError('AMBIGUOUS_MATCH', 'multiple');
assert.equal(isDirectIosSelectorFallbackError(ambiguous), false);
assert.equal(isDirectIosSelectorFallbackError(ambiguous, { allowElementNotFound: true }), false);
});

test('transport-level COMMAND_FAILED errors fall back, semantic ones do not', () => {
assert.equal(
isDirectIosSelectorFallbackError(new AppError('COMMAND_FAILED', 'fetch failed')),
Expand All @@ -30,8 +63,4 @@ test('transport-level COMMAND_FAILED errors fall back, semantic ones do not', ()
isDirectIosSelectorFallbackError(new AppError('COMMAND_FAILED', 'element covered by overlay')),
false,
);
assert.equal(
isDirectIosSelectorFallbackError(new AppError('AMBIGUOUS_MATCH', 'multiple')),
false,
);
});
31 changes: 29 additions & 2 deletions src/daemon/direct-ios-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,43 @@ function isRunnerNativeSelectorKey(key: string): key is DirectIosSelectorTarget[

export function isDirectIosSelectorFallbackError(
error: unknown,
options: { allowElementNotFound?: boolean } = {},
options: {
/**
* Read/query callers (wait/get/is): a runner ELEMENT_NOT_FOUND re-resolves
* against the daemon tree, but AMBIGUOUS_MATCH still surfaces as-is.
*/
allowElementNotFound?: boolean;
/**
* ADR 0011 delegation-on-error for interaction dispatches: the runner's
* semantic failure shapes (ELEMENT_NOT_FOUND, AMBIGUOUS_MATCH) fall back
* to the tree-based runtime path, which supplies runtime disambiguation,
* non-hittable promotion/annotation, occlusion refusal, and rich selector
* diagnostics/hints. Must stay OFF for Maestro replay dispatches
* (allowNonHittableCoordinateFallback): replay matching is intentionally
* runner-native, so those error shapes must surface unchanged.
*/
delegateSemanticFailures?: boolean;
} = {},
): boolean {
const appError = asAppError(error);
if (appError.code === 'ELEMENT_NOT_FOUND') return options.allowElementNotFound === true;
if (appError.code === 'ELEMENT_NOT_FOUND') {
return options.delegateSemanticFailures === true || options.allowElementNotFound === true;
}
if (appError.code === 'AMBIGUOUS_MATCH') return options.delegateSemanticFailures === true;
// The runner refuses to tap a hittable match whose frame is outside the app
// frame (closed drawer, off-viewport carousel). The tree-based path either
// prefers an on-screen candidate or raises the actionable offscreen_selector
// error, so always fall back.
if (appError.code === 'ELEMENT_OFFSCREEN') return true;
if (appError.code !== 'COMMAND_FAILED') return false;
// Transport-failure classification stays message-based deliberately: the
// sniffed shapes originate at 4+ scattered throw sites (runner-transport
// deadline errors, runner-contract connect errors, runner-session's invalid
// response) plus raw undici "fetch failed" TypeErrors that are only wrapped
// into AppError at this boundary — and isRetryableRunnerError performs the
// same message sniffing for retry policy. A typed transport marker needs a
// wrapping layer around all of them in one change; tracked as Tier-3 error
// cleanup, not worth entangling with this fallback decision.
const message = appError.message.toLowerCase();
return (
message.includes('fetch failed') ||
Expand Down
125 changes: 110 additions & 15 deletions src/daemon/handlers/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,17 +657,38 @@ test('click simple iOS id selector falls back to snapshot coordinates on transpo
}
});

test('click simple iOS id selector does not snapshot-fallback on runner element miss', async () => {
test('click simple iOS id selector falls back to snapshot resolution on runner element miss', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-direct-selector-element-miss';
sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }));

mockDispatch.mockImplementation(async (_device, command, _positionals, _out, context) => {
mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => {
if (command === 'press' && (context as Record<string, unknown>)?.directElementSelector) {
throw new AppError('ELEMENT_NOT_FOUND', 'element not found');
}
if (command === 'snapshot') {
throw new Error('snapshot fallback should not run');
return {
nodes: attachRefs([
{
index: 0,
type: 'Window',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
{
index: 1,
parentIndex: 0,
type: 'XCUIElementTypeButton',
identifier: 'submit',
rect: { x: 20, y: 80, width: 120, height: 40 },
enabled: true,
hittable: true,
},
]),
backend: 'xctest',
};
}
if (command === 'press') {
return { x: Number(positionals[0]), y: Number(positionals[1]), pressed: true };
}
return {};
});
Expand All @@ -685,24 +706,58 @@ test('click simple iOS id selector does not snapshot-fallback on runner element
contextFromFlags,
});

expect(response?.ok).toBe(false);
if (response?.ok === false) {
expect(response.error.code).toBe('ELEMENT_NOT_FOUND');
expect(response?.ok).toBe(true);
const pressCalls = mockDispatch.mock.calls.filter((call) => call[1] === 'press');
expect(pressCalls.length).toBe(2);
expect(pressCalls[1]?.[2]).toEqual(['80', '100']);
if (response?.ok) {
expect(response.data?.selectorChain).toContain('id="submit"');
}
expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(0);
});

test('click simple iOS id selector does not snapshot-fallback on ambiguous runner match', async () => {
test('click simple iOS id selector falls back to runtime disambiguation on ambiguous runner match', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-direct-selector-ambiguous';
sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }));

mockDispatch.mockImplementation(async (_device, command, _positionals, _out, context) => {
mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => {
if (command === 'press' && (context as Record<string, unknown>)?.directElementSelector) {
throw new AppError('AMBIGUOUS_MATCH', 'Selector matched multiple elements');
}
if (command === 'snapshot') {
throw new Error('snapshot fallback should not run');
return {
nodes: attachRefs([
{
index: 0,
type: 'Window',
rect: { x: 0, y: 0, width: 390, height: 844 },
},
// Off-screen drawer twin: runtime disambiguation prefers the
// visible candidate instead of failing like the runner did.
{
index: 1,
parentIndex: 0,
type: 'XCUIElementTypeButton',
identifier: 'submit',
rect: { x: -300, y: 80, width: 120, height: 40 },
enabled: true,
hittable: true,
},
{
index: 2,
parentIndex: 0,
type: 'XCUIElementTypeButton',
identifier: 'submit',
rect: { x: 20, y: 80, width: 120, height: 40 },
enabled: true,
hittable: true,
},
]),
backend: 'xctest',
};
}
if (command === 'press') {
return { x: Number(positionals[0]), y: Number(positionals[1]), pressed: true };
}
return {};
});
Expand All @@ -720,13 +775,53 @@ test('click simple iOS id selector does not snapshot-fallback on ambiguous runne
contextFromFlags,
});

expect(response?.ok).toBe(false);
if (response?.ok === false) {
expect(response.error.code).toBe('AMBIGUOUS_MATCH');
}
expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(0);
expect(response?.ok).toBe(true);
const pressCalls = mockDispatch.mock.calls.filter((call) => call[1] === 'press');
expect(pressCalls.length).toBe(2);
expect(pressCalls[1]?.[2]).toEqual(['80', '100']);
});

test.each([
['ELEMENT_NOT_FOUND', 'element not found'],
['AMBIGUOUS_MATCH', 'Selector matched multiple elements'],
] as const)(
'maestro-flagged click keeps runner %s error without snapshot fallback',
async (code, message) => {
const sessionStore = makeSessionStore();
const sessionName = `ios-maestro-direct-selector-${code}`;
sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }));

mockDispatch.mockImplementation(async (_device, command, _positionals, _out, context) => {
if (command === 'press' && (context as Record<string, unknown>)?.directElementSelector) {
throw new AppError(code, message);
}
if (command === 'snapshot') {
throw new Error('snapshot fallback should not run for maestro replay dispatches');
}
return {};
});

const response = await handleInteractionCommands({
req: {
token: 't',
session: sessionName,
command: 'click',
positionals: ['id="submit"'],
flags: { maestro: { allowNonHittableCoordinateFallback: true } },
},
sessionName,
sessionStore,
contextFromFlags,
});

expect(response?.ok).toBe(false);
if (response?.ok === false) {
expect(response.error.code).toBe(code);
}
expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(0);
},
);

test('click simple iOS id selector waits for snapshot path after pending gesture stabilization', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-direct-selector-after-swipe';
Expand Down
8 changes: 7 additions & 1 deletion src/daemon/handlers/interaction-touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,13 @@ async function dispatchDirectIosSelectorInteraction(params: {
actionFinishedAt,
});
} catch (error) {
if (!isDirectIosSelectorFallbackError(error)) {
// ADR 0011 delegation-on-error: semantic runner failures fall back to the
// tree-based runtime path — except for Maestro replay dispatches, whose
// runner-native error shapes must be preserved.
const fallback = isDirectIosSelectorFallbackError(error, {
delegateSemanticFailures: selector.allowNonHittableCoordinateFallback !== true,
});
if (!fallback) {
return { ok: false, error: normalizeError(error) };
}
emitDiagnostic({
Expand Down
Loading
Loading