Files
callstack__agent-device/test/integration/interaction-contract/direct-ios-selector.contract.test.ts
Michał Pierzchała c93dcdbc90 feat: disclose selector resolution in interaction responses (#1193)
* feat: disclose selector resolution in interaction responses

Implements ADR-0012 migration step 1 (decision 2). Adds an additive
`resolution` field to press/click/fill/longpress responses: runtime-selector
carries the full pre-action diagnostic shape (unique or disambiguated with
matchCount/winnerDiagnostic/tiebreak/bounded alternatives), runtime-ref and
native-ref carry the exact ref-provenance shape, direct-ios-selector carries
the explicit not-observed marker, and coordinate/maestro-non-hittable-fallback
stay inapplicable (no field). The comparator in selectors-resolve.ts now
records which criterion (visible/deepest/smallest-area) decided each
disambiguation without changing resolveSelectorChain's winner.

Extends the ADR-0011 guarantee matrix with the resolutionDisclosure guarantee
across all six dispatch paths, wires the shared response builder and MCP
output schema, adds digest-level trimming (drops alternatives, keeps the
verdict/counts), and proves via contract tests that resolution diagnostics
are never ref-issued or MCP-pinned and cannot be reused as @ref targets.

* fix: address resolution disclosure review findings

* refactor: make resolution-disclosure choices self-evident

Replace the direct-iOS/maestro message-sniffing (and its justification
paragraph) with an explicit maestroFallback flag passed from the dispatch
site that already owns the path decision, and shrink every why-this-is-OK
paragraph to one-line constraint statements per the maintainer directive.

* fix: usage-based maestro fallback disclosure + spec label-fallback

Blocker 1: the runner-payload source now carries maestroFallbackUsed derived
from the runner's actual execution outcome (the usedNonHittableFallback
message bit RunnerTests+CommandExecution.swift reports, the same signal
directIosSelectorFallbackDetails already keys on) instead of the permission
flag. A fallback-allowed dispatch that hit its element normally discloses
direct-ios/not-observed; only an actually-executed coordinate fallback is the
inapplicable maestro cell. Contract tests cover both sides.

Blocker 2: ADR-0012 decision 2 now defines the ref/label-fallback disclosure
(runtime-ref trailing-label recovery via tryResolveRefNode's fallbackLabel;
native-ref stays exact because the backend receives only the ref handle),
amends the matrix-cell enumeration, layer-3 coverage list, and validation
bullet, and the runtime-ref contract suite proves the label-fallback shape.

* fix: honest runtime-ref registry cells for label recovery

The disambiguation cell no longer claims refs identify exactly one node by
construction — trailing-label recovery is a first-match lookup without the
ranking, now an intentional waiver whose outcome the label-fallback
disclosure surfaces per-response. resolutionDisclosure.via points at
tryResolveRefNode (now exported), the resolver producing both exact and
label-fallback, with direct unit coverage of both outcomes.

* docs: correct native-ref exactness rationale and tiebreak doc

Native-ref forwards fallbackLabel to the backend; exact is justified by
non-observability of any backend-side label recovery, not by non-forwarding.
The tiebreak doc now states the derived winner-vs-runner-up decisive margin.

* fix: disclose Maestro fill fallback usage
2026-07-11 09:09:44 +02:00

280 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts';
import { AppError } from '../../../src/kernel/errors.ts';
import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts';
import { scenarioName } from './coverage-manifest.ts';
import { DIRECT_IOS_SELECTOR_COVERAGE } from './direct-ios-selector.coverage.ts';
import {
RUNNER_CHANGED_NODES,
RUNNER_CLOSED_DRAWER_NODES,
RUNNER_CONTINUE_NODES,
RUNNER_COVERED_NODES,
RUNNER_NON_HITTABLE_NODES,
} from './fixtures.ts';
import {
runnerSnapshotEntry,
runnerTapEntry,
runnerTapErrorEntry,
runnerTypeEntry,
withIosContractDaemon,
} from './daemon-harness.ts';
// ADR 0011 Layer 3, direct-ios-selector path: a simple selector click on an
// iOS session is sent to the XCTest runner without a daemon tree capture.
// Path forcing is natural: `click <single-term selector>` with default flags
// takes the direct path; the transcript proves it (the runner receives a
// selector-keyed tap, not a coordinate one).
const scenario = (guarantee: InteractionGuarantee): string =>
scenarioName(DIRECT_IOS_SELECTOR_COVERAGE, guarantee);
const RECORDING_TARGET_NODES = [
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
{
index: 1,
parentIndex: 0,
type: 'Button',
identifier: 'continue',
label: 'Continue',
rect: { x: 40, y: 160, width: 120, height: 44 },
enabled: true,
hittable: true,
},
{
index: 2,
parentIndex: 0,
type: 'TextField',
identifier: 'email',
label: 'Email',
rect: { x: 40, y: 240, width: 280, height: 44 },
enabled: true,
hittable: true,
},
] as const;
test(scenario('responseConstruction'), async () => {
await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Continue']);
const data = assertRpcOk(click);
// The direct path really ran: the single runner call is a selector-keyed
// tap, with no snapshot capture before it.
const tapRequest = transcript.calls[0]?.request as Record<string, unknown> | undefined;
assert.equal(transcript.calls[0]?.command, 'ios.runner.tap');
assert.equal(tapRequest?.selectorKey, 'label');
assert.equal(tapRequest?.selectorValue, 'Continue');
// Canonical runner-payload response set from the shared construction site.
assert.equal(data.x, 150);
assert.equal(data.y, 200);
assert.equal(data.selector, 'label=Continue');
assert.match(String(data.message), /Tapped label=Continue/);
});
});
test(scenario('resolutionDisclosure'), async () => {
await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon) => {
const click = await daemon.callCommand('click', ['label=Continue']);
const data = assertRpcOk(click);
// No daemon tree exists on this path: the disclosure never fabricates a
// match count or candidates.
assert.deepEqual(data.resolution, { source: 'direct-ios', kind: 'not-observed' });
});
});
test('recorded simple iOS selector click and fill use runtime resolution and persist target-v1 evidence', async () => {
await withIosContractDaemon(
[
runnerSnapshotEntry(RECORDING_TARGET_NODES),
runnerTapEntry({ x: 100, y: 182 }),
runnerSnapshotEntry(RECORDING_TARGET_NODES),
runnerTypeEntry({ x: 180, y: 262 }),
],
async (daemon, transcript) => {
assertRpcOk(await daemon.callCommand('click', ['id=continue']));
assertRpcOk(await daemon.callCommand('fill', ['id=email', 'ada@example.com']));
assert.equal(transcript.calls[0]?.command, 'ios.runner.snapshot');
assert.equal(transcript.calls[1]?.command, 'ios.runner.tap');
assert.equal(transcript.calls[2]?.command, 'ios.runner.snapshot');
assert.equal(transcript.calls[3]?.command, 'ios.runner.type');
for (const call of [transcript.calls[1], transcript.calls[3]]) {
const request = call?.request as Record<string, unknown> | undefined;
assert.equal(request?.selectorKey, undefined);
}
const actions = daemon
.session()
?.actions.filter((action) => action.command === 'click' || action.command === 'fill');
assert.deepEqual(
actions?.map((action) => action.command),
['click', 'fill'],
);
const targetIdentities = actions?.map((action) => {
const evidence = action.targetEvidence as Record<string, unknown> | undefined;
return {
id: evidence?.id,
role: evidence?.role,
label: evidence?.label,
verification: evidence?.verification,
};
});
assert.deepEqual(targetIdentities, [
{ id: 'continue', role: 'button', label: 'Continue', verification: 'verified' },
{ id: 'email', role: 'textfield', label: 'Email', verification: 'verified' },
]);
},
{ saveScript: true },
);
});
test(scenario('offscreen'), async () => {
await withIosContractDaemon(
[
// The runner refuses a hittable match whose frame lies outside the app
// frame (TapPointPolicy); the daemon must fall back to the runtime path,
// which refuses with the actionable offscreen shape instead of tapping.
runnerTapErrorEntry(
new AppError('ELEMENT_OFFSCREEN', 'Element frame is outside the app frame'),
),
runnerSnapshotEntry(RUNNER_CLOSED_DRAWER_NODES),
],
async (daemon) => {
const click = await daemon.callCommand('click', ['label=Explore']);
const error = assertRpcError(
click,
'COMMAND_FAILED',
/off-screen element and is not safe to click/,
);
const details = error.details as Record<string, unknown> | undefined;
assert.equal(details?.reason, 'offscreen_selector');
assert.ok(typeof error.hint === 'string');
},
);
});
test(scenario('verifyEvidence'), async () => {
await withIosContractDaemon(
[
runnerSnapshotEntry(RUNNER_CONTINUE_NODES),
runnerTapEntry({ x: 200, y: 322 }),
runnerSnapshotEntry(RUNNER_CHANGED_NODES),
],
async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Continue'], { verify: true });
const data = assertRpcOk(click);
// --verify disables the direct path: the first runner call is the
// runtime path's tree capture, and the tap is coordinate-keyed.
assert.equal(transcript.calls[0]?.command, 'ios.runner.snapshot');
const tapRequest = transcript.calls.find((call) => call.command === 'ios.runner.tap')
?.request as Record<string, unknown> | undefined;
assert.equal(tapRequest?.selectorKey, undefined);
assert.equal(tapRequest?.x, 200);
const evidence = data.evidence as Record<string, unknown> | undefined;
assert.ok(evidence, 'click --verify must return evidence');
assert.equal(evidence.changedFromBefore, true);
assert.equal(typeof evidence.digest, 'string');
// The verify capture's tree must never be serialized into the response.
assert.equal(data.nodes, undefined);
},
);
});
test(scenario('settleObservation'), async () => {
await withIosContractDaemon(
[
// --settle disables the direct path: runtime tree capture, coordinate
// tap, then the settle loop's two stable captures of the changed tree.
runnerSnapshotEntry(RUNNER_CONTINUE_NODES),
runnerTapEntry({ x: 200, y: 322 }),
runnerSnapshotEntry(RUNNER_CHANGED_NODES),
runnerSnapshotEntry(RUNNER_CHANGED_NODES),
],
async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label=Continue'], {
settle: true,
settleQuietMs: 25,
timeoutMs: 2_000,
});
const data = assertRpcOk(click);
assert.equal(transcript.calls[0]?.command, 'ios.runner.snapshot');
const tapRequest = transcript.calls.find((call) => call.command === 'ios.runner.tap')
?.request as Record<string, unknown> | undefined;
assert.equal(tapRequest?.selectorKey, undefined);
assert.equal(tapRequest?.x, 200);
const settle = data.settle as Record<string, unknown> | undefined;
assert.ok(settle, 'click --settle must return a settle observation');
assert.equal(settle.settled, true);
assert.equal(typeof settle.refsGeneration, 'number');
const diff = settle.diff as
| { summary: Record<string, unknown>; lines: Array<Record<string, unknown>> }
| undefined;
assert.ok(diff, 'settle observation must carry the diff');
assert.deepEqual(diff.summary, { additions: 1, removals: 1, unchanged: 1 });
// The settled tree itself is never serialized into the response.
assert.equal(data.nodes, undefined);
},
);
});
test(scenario('errorTaxonomy'), async () => {
await withIosContractDaemon(
[
runnerTapErrorEntry(new AppError('ELEMENT_NOT_FOUND', 'element not found')),
// Runtime fallback resolution: interactive-only capture, then the
// full-tree retry — neither contains the selector.
runnerSnapshotEntry(RUNNER_CONTINUE_NODES),
runnerSnapshotEntry(RUNNER_CONTINUE_NODES),
],
async (daemon) => {
const click = await daemon.callCommand('click', ['label=Missing']);
const error = assertRpcError(click, 'COMMAND_FAILED', /did not match/);
// The delegated taxonomy: runtime diagnostics and the actionable hint,
// not the runner's bare "element not found".
assert.ok(typeof error.hint === 'string' && error.hint.length > 0);
},
);
});
test(scenario('occlusion'), async () => {
await withIosContractDaemon(
[
runnerTapErrorEntry(new AppError('ELEMENT_NOT_FOUND', 'element not found')),
runnerSnapshotEntry(RUNNER_COVERED_NODES),
runnerSnapshotEntry(RUNNER_COVERED_NODES),
],
async (daemon) => {
const click = await daemon.callCommand('click', ['label="Save draft"']);
const error = assertRpcError(click, 'COMMAND_FAILED', /covered by another visible element/);
const details = error.details as Record<string, unknown> | undefined;
assert.equal(details?.interactionBlocked, 'covered');
},
);
});
test(scenario('nonHittable'), async () => {
await withIosContractDaemon(
[
runnerTapErrorEntry(new AppError('ELEMENT_NOT_FOUND', 'element not found')),
runnerSnapshotEntry(RUNNER_NON_HITTABLE_NODES),
runnerTapEntry({ x: 200, y: 330 }),
],
async (daemon, transcript) => {
const click = await daemon.callCommand('click', ['label="Recents row"']);
const data = assertRpcOk(click);
// Delegation really happened: the second tap is coordinate-keyed.
const fallbackTap = transcript.calls.at(-1)?.request as Record<string, unknown> | undefined;
assert.equal(transcript.calls.at(-1)?.command, 'ios.runner.tap');
assert.equal(fallbackTap?.selectorKey, undefined);
assert.equal(data.targetHittable, false);
assert.match(String(data.hint ?? ''), /hittable: false/);
},
);
});