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
34 changes: 34 additions & 0 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig
(a shared Android framework resource id such as `android:id/title`, or a reused RN `FlatList` `testID`)
falls back to role+label in both `computeTargetEvidence`'s `target-v1` tuple and
`buildSelectorChainForNode`'s chain (#1269).
- Decision 3 amendment, record-time press retarget to a labeled descendant — a click/press whose
resolved winner is an identity-empty container (no surviving id, no label, no value, from the demoted
identity view) is recorded against its first labeled descendant via a recording-only side channel, so
both writers key off a node with selective identity while the live response stays container-based
end-to-end; a container whose subtree holds a competing interactive node (canonical classification or
hittable flag), or whose selected descendant's center falls outside the container rect, is recorded
unchanged; `fill` is excluded (#1280).

**Accepted but NOT yet implemented** (this amendment; tracked by #1235 — repair-transaction lifecycle):
the R7 repair-transaction keep-alive and its distinct `resume.repairSessionHeld` signal, the ARMED →
Expand Down Expand Up @@ -329,6 +336,33 @@ A recorded `id` never matches a node without that id.
> case, but the rule is capture-time uniqueness, not an `android:id/*` namespace heuristic: a reused RN
> `FlatList` `testID` hits the same demotion on iOS.

> **Amendment (#1280).** A **click/press** whose resolved winner is an *identity-empty* container — no
> id survives #1269's demotion (absent, or demoted for being non-unique), no label, and no value, all
> evaluated from the demoted identity view (a raw identifier that did not survive demotion never counts
> as identity or text) — is **recorded** against its first labeled descendant instead of the container
> itself, so the recorded selector chain and the `target-v1` tuple both carry a selective role+label
> identity rather than a bare, tree-wide-shared role. The measured case is Android: a list row's
> clickable node is a label-less `role="linearlayout"` container whose visible title lives on a child
> `TextView`. "First labeled descendant" means the first node in document order (this section's
> canonical total order) within the container's whole subtree whose normalized label is non-empty.
> `fill` is deliberately excluded: its recorded chain carries `editable=true` constraints a label
> descendant cannot satisfy, which would save an unreplayable script. The substitution is guarded
> fail-closed, twice over: (i) it fires only when the subtree contains **no competing interactive node**
> — none flagged `hittable`, and none whose role the canonical interactive-role classification
> (`isSemanticTouchTarget`, the same policy hittable-ancestor promotion uses) names as an independently
> tappable control — a row with a trailing `Switch` or `Checkbox` must not retarget, because a tap at
> the labeled descendant's center and a tap at the container's center could land on different controls
> once the descendant, not the container, is what replay taps; and (ii) the selected descendant's rect
> center must lie **inside the container's rect** — the replay tap point must be provably within the
> original activation region (missing rects fail closed). A container either guard blocks, or for which
> no descendant carries a label, is recorded exactly as before this amendment. This is a
> **recording-only side channel**: the live response — resolved node, selector chain, ref-label,
> tap point, resolution disclosure, and `targetHittable`/hint — describes the dispatched container
> end-to-end, and `resolveSelectorChain`/dispatch are unchanged; only the recorded action entry (the
> `.ad` writer's source) and its `target-v1` evidence consume the retargeted descendant. The rule is
> platform-agnostic: an RN `FlatList` row (`Cell`) that is label-less with a labeled `Text` child hits
> the same substitution on iOS.

**Ancestry.** The chain is the nearest **K = 8** ancestors of the target, ordered **leaf→root** (nearest
ancestor first), each entry `{ role, label? }` under the same normalization (`role` may be the empty
string when the node has no type; `label` is omitted when empty). Truncation drops entries from the
Expand Down
98 changes: 98 additions & 0 deletions src/commands/interaction/runtime/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BackendSnapshotOptions } from '../../../backend.ts';
import { ref, selector } from './selector-read.ts';
import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts';
import { tryResolveRefNode } from './resolution.ts';
import { parseSelectorChain, resolveSelectorChain } from '../../../selectors/index.ts';
import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts';
import type { Point } from '../../../kernel/snapshot.ts';
import {
Expand Down Expand Up @@ -231,6 +232,103 @@ test('runtime press omits targetHittable and hint when the resolved node is hitt
assert.equal(result.hint, undefined);
});

// The #1280 measured shape: a hittable, identity-empty LinearLayout row
// container whose title lives on a NON-hittable TextView child.
function identityEmptyRowSnapshot(containerType = 'LinearLayout') {
return makeSnapshotState([
{
index: 0,
depth: 0,
type: 'FrameLayout',
rect: { x: 0, y: 0, width: 400, height: 800 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: containerType,
rect: { x: 0, y: 100, width: 300, height: 48 },
hittable: true,
},
{
index: 2,
depth: 2,
parentIndex: 1,
type: 'TextView',
label: 'Connected devices',
rect: { x: 0, y: 100, width: 300, height: 48 },
hittable: false,
},
]);
}

test('runtime press #1280 retarget: the response is entirely container-based; the descendant rides only the recordingTarget side channel', async () => {
const calls: Point[] = [];
const device = createInteractionDevice(identityEmptyRowSnapshot(), {
platform: 'android',
tap: async (_context, point) => {
calls.push(point);
},
});

const result = await device.interactions.press(selector('role=linearlayout'), {
session: 'default',
});

// Dispatch: the tap lands at the CONTAINER's center.
assert.deepEqual(calls, [{ x: 150, y: 124 }]);
assert.equal(result.kind, 'selector');
// The whole runtime response describes the dispatched container — node,
// chain, hittability. The descendant's `hittable: false` must not leak.
assert.equal(result.node?.type, 'LinearLayout');
assert.deepEqual(result.selectorChain, ['role="linearlayout"']);
assert.equal(result.targetHittable, undefined);
assert.equal(result.hint, undefined);
// The retarget rides ONLY on the recording-only side channel.
assert.equal(result.recordingTarget?.node.label, 'Connected devices');
assert.deepEqual(result.recordingTarget?.selectorChain, [
'role="textview" label="Connected devices"',
'label="Connected devices"',
]);
assert.equal(result.recordingTarget?.refLabel, 'Connected devices');
});

test('runtime fill #1280: fill is excluded from retargeting — the chain stays on the editable container and resolves for replay', async () => {
// An identity-empty EDITABLE container (no id/label/value) with a labeled
// non-editable TextView child. Retargeting a fill would record a chain
// whose `editable=true` constraint the label descendant can never satisfy
// — an unreplayable script — so fill must record as before, no retarget.
const snapshot = identityEmptyRowSnapshot('EditText');
const calls: Array<{ point: Point; text: string }> = [];
const device = createInteractionDevice(snapshot, {
platform: 'android',
fill: async (_context, point, text) => {
calls.push({ point, text });
},
});

const result = await device.interactions.fill(selector('role=edittext'), 'hello', {
session: 'default',
});

assert.equal(result.kind, 'selector');
assert.deepEqual(calls, [{ point: { x: 150, y: 124 }, text: 'hello' }]);
// No side channel: fill never retargets.
assert.equal(result.recordingTarget, undefined);
// The recorded chain belongs to the container and carries the editable
// constraint...
assert.deepEqual(result.selectorChain, ['role="edittext" editable=true']);
// ...and it resolves back to the editable container on the record-time
// tree — the saved script stays replayable.
const resolved = resolveSelectorChain(
snapshot.nodes,
parseSelectorChain(result.selectorChain!.join(' || ')),
{ platform: 'android', requireRect: true, requireUnique: true },
);
assert.equal(resolved?.node.type, 'EditText');
});

test('runtime fill surfaces targetHittable and a hint for a non-hittable selector match (Maps pin case, #1037)', async () => {
const calls: Array<{ point: Point; text: string }> = [];
const device = createInteractionDevice(mapPinAnnotationSnapshot(), {
Expand Down
39 changes: 38 additions & 1 deletion src/commands/interaction/runtime/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type SelectorResolution,
} from '../../../selectors/index.ts';
import { buildSelectorChainForNode } from '../../../selectors/build.ts';
import { resolvePressRecordingTarget } from '../../../core/press-retarget.ts';
import {
findNodeByLabel,
normalizeType,
Expand All @@ -31,6 +32,7 @@ import { truncateUtf8 } from '../../../utils/truncate-utf8.ts';
import type {
InteractionTarget,
PointTarget,
RecordingTargetOverride,
ResolutionDiagnosticEntry,
ResolutionDisclosure,
ResolvedInteractionTarget,
Expand Down Expand Up @@ -383,7 +385,9 @@ function buildResolutionDiagnosticEntry(
}

// Shared tail of a resolved ref/selector interaction target: the node itself
// plus everything derived from it for the response.
// plus everything derived from it for the response. Every response field
// describes the DISPATCHED node — the #1280 retarget rides only on the
// `recordingTarget` side channel below.
function describeResolvedInteractionNode(
runtime: AgentDeviceRuntime,
node: SnapshotNode,
Expand All @@ -398,6 +402,7 @@ function describeResolvedInteractionNode(
hint?: string;
preActionNodes: SnapshotState['nodes'];
resolution: ResolutionDisclosure;
recordingTarget?: RecordingTargetOverride;
} {
return {
node,
Expand All @@ -409,6 +414,38 @@ function describeResolvedInteractionNode(
...describeNonHittableTarget(node, action),
preActionNodes: nodes,
resolution,
...pressRecordingTargetOverride(runtime, node, nodes, action),
};
}

/**
* #1280 (ADR 0012 decision 3 amendment): the recording-only side channel.
* When a click/press resolves to an identity-empty container, the RECORDED
* step retargets to its first labeled descendant — node, chain, and
* ref-label computed together here so the recorded action entry and its
* `target-v1` evidence can never half-retarget. The response payloads never
* consume this (see `interaction-touch-response.ts`). `fill` is deliberately
* excluded: its chain carries `editable=true` constraints a label descendant
* cannot satisfy, which would record an unreplayable selector.
*/
function pressRecordingTargetOverride(
runtime: AgentDeviceRuntime,
node: SnapshotNode,
nodes: SnapshotState['nodes'],
action: InteractionAction,
): { recordingTarget?: RecordingTargetOverride } {
if (action !== 'click' && action !== 'press') return {};
const recordingNode = resolvePressRecordingTarget(node, nodes);
if (recordingNode === node) return {};
return {
recordingTarget: {
node: recordingNode,
selectorChain: buildSelectorChainForNode(recordingNode, runtime.backend.platform, {
action: 'click',
nodes,
}),
refLabel: resolveRefLabel(recordingNode, nodes),
},
};
}

Expand Down
17 changes: 17 additions & 0 deletions src/contracts/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ export type ResolutionDisclosure =
| { source: 'ref'; phase: 'pre-action'; kind: 'label-fallback' }
| { source: 'direct-ios'; kind: 'not-observed' };

/**
* #1280 (ADR 0012 decision 3 amendment): recording-only side channel — the
* labeled descendant an identity-empty press container was retargeted to.
* Consumed exclusively at the recording boundary
* (`interaction-touch-response.ts`): the recorded action entry and its
* `target-v1` evidence key off this node/chain/ref-label, while every
* response payload keeps describing the dispatched container. Never
* serialized into a response.
*/
export type RecordingTargetOverride = {
node: SnapshotNode;
selectorChain: string[];
refLabel?: string;
};

export type ResolvedInteractionTarget =
| {
kind: 'point';
Expand All @@ -86,6 +101,7 @@ export type ResolvedInteractionTarget =
hint?: string;
preActionNodes?: SnapshotNode[];
resolution?: ResolutionDisclosure;
recordingTarget?: RecordingTargetOverride;
}
| {
kind: 'selector';
Expand All @@ -98,6 +114,7 @@ export type ResolvedInteractionTarget =
hint?: string;
preActionNodes?: SnapshotNode[];
resolution?: ResolutionDisclosure;
recordingTarget?: RecordingTargetOverride;
};

/**
Expand Down
9 changes: 8 additions & 1 deletion src/core/interaction-targeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,14 @@ function findPreferredActionableDescendant(
return current === node ? null : current;
}

function isSemanticTouchTarget(node: SnapshotNode): boolean {
/**
* THE canonical interactive-role classification for touch: a node whose
* type/role/subrole names a control that independently receives taps. Shared
* by the hittable-ancestor promotion above and #1280's press-retarget
* competing-descendant guard (`press-retarget.ts`) — one list, never a
* parallel copy.
*/
export function isSemanticTouchTarget(node: SnapshotNode): boolean {
const roles = [node.type, node.role, node.subrole].map((value) => normalizeType(value ?? ''));
return roles.some(isSemanticTouchRole);
}
Expand Down
Loading
Loading