Skip to content
Merged
26 changes: 26 additions & 0 deletions packages/capture-kit/src/snapshot/snapshot-lines.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isSystemScrollIndicatorLabel } from '@agent-device/kernel/scroll-indicator';
import { formatRole } from '@agent-device/kernel/snapshot';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import type { ElementMatchCandidateDetails } from '@agent-device/kernel/errors';
import {
buildTextPreview,
describeTextSurface,
Expand Down Expand Up @@ -76,6 +77,31 @@ export function formatSnapshotLine(
return `${indent}${ref} [${type}]${textPart}${metadataText}${actionsText}`.trimEnd();
}

/**
* The `matches`/`candidates` pair every `AMBIGUOUS_MATCH` producer owes the
* surfaces: the true total, plus the first ELEMENT_MATCH_CANDIDATE_LIMIT nodes
* rendered as snapshot lines so a printed candidate reads exactly like its row
* in `snapshot -i` (#1597). Owned beside {@link formatSnapshotLine} because the
* cap, the renderer, and this pairing are one contract — the acting refusal,
* the find refusal, and the strict-read door all build their disclosure here
* instead of restating the slice-and-render. The cap is module-local by
* design: this entry surface stays implementation-lazy (ADR 0019), and the
* surfaces' "+N more" marker is computed from `matches - candidates.length`,
* never from the constant, so one declaration here is the whole single source.
*/
const ELEMENT_MATCH_CANDIDATE_LIMIT = 5;

export function elementMatchCandidateDetails(
matchedNodes: readonly SnapshotNode[],
): ElementMatchCandidateDetails {
return {
matches: matchedNodes.length,
candidates: matchedNodes
.slice(0, ELEMENT_MATCH_CANDIDATE_LIMIT)
.map((candidate) => formatSnapshotLine(candidate, 0, false)),
};
}

/**
* Accessibility custom actions render as a named list rather than bracketed
* metadata: they are the element's hidden affordances, and an agent reading the
Expand Down
7 changes: 7 additions & 0 deletions packages/kernel/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ export type NormalizedError = {
details?: ErrorWireDetails;
};

/**
* The `matches`/`candidates` pair an `AMBIGUOUS_MATCH` producer puts on the
* wire. The cap on `candidates` is owned by the one builder that fills this
* shape (`elementMatchCandidateDetails` in capture-kit's line renderer), and
* `readErrorCandidateViews` computes the "+N more" marker from
* `matches - candidates.length`, so no consumer needs the constant itself.
*/
export type ElementMatchCandidateDetails = {
candidates: string[];
matches: number;
Expand Down
9 changes: 5 additions & 4 deletions packages/replay-port/src/daemon-port/target-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
orderByViewportPosition,
} from '@agent-device/selectors/target-evidence';
import { resolveRecordedTarget } from '@agent-device/selectors';
import { resolveUnverifiedWrapperControl } from '@agent-device/selectors/interaction-targeting';
import { resolveElementReportedTwice } from '@agent-device/selectors/interaction-targeting';
import type { TargetAnnotationV1 } from '@agent-device/contracts/replay';
import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence';

Expand Down Expand Up @@ -219,10 +219,11 @@ function resolveSelectorTargetMatches(
}
// A refusal is not always a changed screen. The rows that verify without
// disambiguation — `is <predicate>` and `get attrs` — dispatch through a
// pipeline that resolves one control reported by its own accessibility wrapper
// to the control, so naming no winner here reports a divergence for a screen
// pipeline that resolves one element reported twice (a control under its own
// accessibility wrapper, or a text reporter and its accessibility mirror) to
// that element, so naming no winner here reports a divergence for a screen
// that did not change.
const control = resolveUnverifiedWrapperControl(nodes, resolution.matchedNodes);
const control = resolveElementReportedTwice(nodes, resolution.matchedNodes);
return {
matchedNodes: [...resolution.matchedNodes],
winnerRef: control?.ref ?? '',
Expand Down
185 changes: 185 additions & 0 deletions packages/selectors/src/interaction-targeting.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,188 @@ export const UNVERIFIED_HITTABILITY_WRAPPER_CHAIN_NODES: RawSnapshotNode[] = [
rect: { x: 0, y: 0, width: 393, height: 852 },
},
];

/**
* React Native text as an iOS regular snapshot reports it (#2870), captured live
* from the fixture app's Catalog screen: the paragraph view carries the label and
* the app's own `testID`, and its `RCTAccessibilityElement` child mirrors the
* identical label at the identical rect. Both nodes carry a `hittable` fact, which
* is why the hittability-door wrapper rule above declines this pair and the
* text-echo rule exists. The pair denotes one authored `<Text>`, and the reporter
* is the outer node — the one whose `identifier` an `id=` selector targets.
*/
export const RN_TEXT_ECHO_NODES: RawSnapshotNode[] = [
{
index: 0,
depth: 2,
parentIndex: 2,
type: 'XCUIElementTypeStaticText',
role: 'RCTParagraphComponentView',
subrole: 'UIView',
identifier: 'catalog-scroll-state',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 1,
depth: 3,
parentIndex: 0,
type: 'XCUIElementTypeStaticText',
role: 'RCTAccessibilityElement',
subrole: 'UIAccessibilityElement',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 2,
depth: 1,
parentIndex: 3,
type: 'XCUIElementTypeOther',
rect: { x: 0, y: 0, width: 386, height: 678 },
enabled: true,
hittable: true,
},
{
index: 3,
depth: 0,
type: 'XCUIElementTypeApplication',
label: 'Agent Device Tester',
rect: { x: 0, y: 0, width: 386, height: 678 },
enabled: true,
hittable: false,
},
];

/**
* The closest negative to the text echo: two nodes carrying the same label at the
* same rect in DIFFERENT subtrees. Identical label, rect, and role vocabulary —
* only the ancestry separates them from the pair above, so this is what proves the
* collapse reads structure rather than the description a match shares.
*/
export const RN_TEXT_ECHO_DISTINCT_SUBTREE_NODES: RawSnapshotNode[] = [
{
index: 0,
depth: 1,
parentIndex: 2,
type: 'XCUIElementTypeStaticText',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 3,
type: 'XCUIElementTypeStaticText',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 2,
depth: 0,
type: 'XCUIElementTypeOther',
rect: { x: 0, y: 0, width: 193, height: 678 },
enabled: true,
hittable: true,
},
{
index: 3,
depth: 0,
type: 'XCUIElementTypeOther',
rect: { x: 193, y: 0, width: 193, height: 678 },
enabled: true,
hittable: true,
},
];

/**
* The other closest negative: one ancestry chain whose descendant repeats the
* ancestor's label at a DIFFERENT rect — two runs of the same words, which is two
* elements the caller still has to choose between. Roles mirror the live RN pair
* so the rect is the ONLY fact that differs from the collapsing positive.
*/
export const RN_TEXT_ECHO_OFFSET_RECT_NODES: RawSnapshotNode[] = [
{
index: 0,
depth: 1,
parentIndex: 2,
type: 'XCUIElementTypeStaticText',
role: 'RCTParagraphComponentView',
subrole: 'UIView',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 1,
depth: 2,
parentIndex: 0,
type: 'XCUIElementTypeStaticText',
role: 'RCTAccessibilityElement',
subrole: 'UIAccessibilityElement',
label: 'Catalog scroll: top',
rect: { x: 18, y: 420, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 2,
depth: 0,
type: 'XCUIElementTypeApplication',
rect: { x: 0, y: 0, width: 386, height: 678 },
enabled: true,
hittable: true,
},
];

/**
* The reportage negative: one ancestry chain with the identical label at the
* identical rect — the shape geometry cannot distinguish — where the descendant
* is an AUTHORED element (a nested `<Text>` or a `<View>` carrying the same
* accessibilityLabel, view-backed role/subrole), not the accessibility element
* the platform reports for the reporter. Same frame, same label, two authored
* elements: the collapse rule's `isReportedAccessibilityElement` clause keeps
* this ambiguous.
*/
export const RN_TEXT_ECHO_AUTHORED_CHILD_NODES: RawSnapshotNode[] = [
{
index: 0,
depth: 1,
parentIndex: 2,
type: 'XCUIElementTypeStaticText',
role: 'RCTParagraphComponentView',
subrole: 'UIView',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 1,
depth: 2,
parentIndex: 0,
type: 'RCTParagraphComponentView',
role: 'RCTParagraphComponentView',
subrole: 'UIView',
label: 'Catalog scroll: top',
rect: { x: 18, y: 168, width: 350, height: 17 },
enabled: true,
hittable: true,
},
{
index: 2,
depth: 0,
type: 'XCUIElementTypeApplication',
rect: { x: 0, y: 0, width: 386, height: 678 },
enabled: true,
hittable: true,
},
];
90 changes: 90 additions & 0 deletions packages/selectors/src/interaction-targeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,96 @@ function resolveUnverifiedWrapperControlWithIndex(
: null;
}

/**
* The mirror half of the RN pair is not a view the app authored: it is the
* synthetic element the platform reports for the reporter's accessibility
* subtree, and it carries that reportage in its role/subrole
* (`RCTAccessibilityElement` / `UIAccessibilityElement`, measured live via
* `snapshot --raw`). Requiring it on every non-reporter candidate is what
* distinguishes "one element the platform reported twice" from "two authored
* elements that happen to share a label and a frame" — geometry cannot tell
* those apart, only the reportage can. An authored child (a nested `<Text>`
* styled to the same label at the same place, a `<View>` carrying the same
* accessibilityLabel) has view-backed role/subrole and keeps the refusal.
*/
function isReportedAccessibilityElement(node: SnapshotNode): boolean {
const roles = [node.type, node.role, node.subrole].map((value) => normalizeType(value ?? ''));
return roles.some((role) => role.includes('accessibilityelement'));
}

/**
* The React Native text shape, captured live from the fixture Catalog screen: a
* `RCTParagraphComponentView` reporting the accessibility label (and the app's
* `testID`) with its own `RCTAccessibilityElement` child repeating the identical
* label at the identical rect. React Native exposes one authored `<Text>` this way,
* so every label selector on RN text answers twice and the uniqueness rows would
* refuse a line of text that is plainly on screen.
*
* The pair denotes one element, and the surviving one is the OUTER reporter: it
* carries the identifier the app authored, it is the node the first-match rows
* already answer with, and it is the node the interactive snapshot publishes —
* `collectIosRepeatedStaticSuppression` keeps the outer reporter and suppresses the
* mirror, which is why `snapshot -i` has always listed that line once while a
* regular capture listed it twice. After the collapse, a read names the row an
* interactive snapshot showed.
*
* Narrowest rule, and every clause is evidence rather than convenience:
* - one ancestry chain — matches in distinct subtrees stay ambiguous;
* - identical non-empty labels and rects agreeing within wrapper slack — a nested
* `<Text>` that repeats a word at its own position is a second run of text, not
* a mirror, and a distinct rect proves it;
* - every non-reporter candidate is a reported accessibility element (above) —
* same label AND same frame is exactly the case where geometry cannot
* distinguish a mirror from a second authored element, so the reportage is
* required and an authored same-frame child stays ambiguous;
* - no candidate is a semantic touch target — a button labelled like its own static
* text is two roles the caller still has to choose between (that shape is the
* wrapper rule above's job, through the hittability door it keeps);
* - unlike the wrapper rule, candidates MAY carry hittability facts: the platform
* *does* report them for this pair, which is exactly why the wrapper rule declines
* it and this one exists.
*/
function resolveTextEchoReporterWithIndex(
candidates: readonly SnapshotNode[],
index: ActionableTouchIndex,
): SnapshotNode | null {
if (candidates.length < 2) return null;
if (!candidatesFormSingleAncestryChain(candidates, index.nodesByIndex)) return null;
if (candidates.some((candidate) => isSemanticTouchTarget(candidate))) return null;
const reporter = candidates.reduce((outermost, candidate) =>
(candidate.depth ?? 0) < (outermost.depth ?? 0) ? candidate : outermost,
);
const reporterLabel = reporter.label?.trim();
if (!reporterLabel) return null;
const reporterRect = normalizeRect(reporter.rect);
if (!reporterRect) return null;
const mirrorsOneReporter = candidates.every(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
(candidate) =>
(candidate === reporter || isReportedAccessibilityElement(candidate)) &&
candidate.label?.trim() === reporterLabel &&
agreesWithinWrapperSlack(normalizeRect(candidate.rect), reporterRect),
);
return mirrorsOneReporter ? reporter : null;
}

/**
* The structural rules that recognize a refused candidate set as ONE element the
* platform reported twice, and name the node it should resolve to: a control under
* its own accessibility wrapper, or an authored text reporter and its accessibility
* mirror. Both the read door and the replay verification gate consume this, so a
* screen cannot resolve one way live and another way under replay.
*/
export function resolveElementReportedTwice(
nodes: SnapshotNode[],
candidates: readonly SnapshotNode[],
): SnapshotNode | null {
const index = buildActionableTouchIndex(nodes);
return (
resolveUnverifiedWrapperControlWithIndex(candidates, index) ??
resolveTextEchoReporterWithIndex(candidates, index)
);
}

function agreesWithinWrapperSlack(rect: Rect | null, controlRect: Rect): boolean {
if (!rect) return false;
return (
Expand Down
Loading
Loading