mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
c93dcdbc90
* 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
280 lines
11 KiB
TypeScript
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/);
|
|
},
|
|
);
|
|
});
|