fix(replay): retarget identity-empty press containers to their labeled descendant (#1280)#1286
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Review of What's right: single substitution point feeding both writers (no half-retarget possible by construction); the shared Blocker — flag 1 violates the design's own rule 5 (recording-time only).
Guard question — the blanket Other flags adjudicated:
With the flag-1 fix + its test, this is ready for the live validation flow; merge judgment after both. |
…ot the retargeted descendant Review blocker on #1286 (flag 1 adjudicated): describeResolvedInteractionNode was computing describeNonHittableTarget from the retargeted descendant, so every retargeted press on a non-hittable title TextView would emit a false `targetHittable: false` + misleading hint on the exact happy path the fix serves — a live-response regression violating the design's recording-time-only rule. Split the fields by what they are FOR: recording-coupled fields (node as evidence source, selectorChain, refLabel — they become the .ad step) keep following the retargeted descendant; the response-semantic describeNonHittableTarget (targetHittable + hint) reverts to the original node, describing what was actually dispatched. Documented in the function comment and the ADR amendment; new load-bearing test (fails against the pre-fix line): a hittable container with a non-hittable labeled child presses with no targetHittable/hint while chain/evidence/refLabel belong to the descendant.
|
Blocker fixed in
New test: Guard ( Gates re-run on |
|
Re-review at
All CI checks are green and GitHub currently reports mergeable, but the branch is behind current |
…-node Extract session-target-evidence.ts's demoteNonUniqueId into a shared demoteNonUniqueLocalIdentity (target-identity-node.ts), and export build.ts's normalizeSelectorText. Both become shared building blocks a third call site (#1280's press-retarget identity-empty check) reuses instead of re-deriving the id-demotion rule and value/text normalization a third way. No behavior change.
…d descendant (#1280) Android list-row presses target a clickable container (role="linearlayout") with no id, no label, no value/text — its title lives on a labeled descendant (the android:id/title TextView, whose own id #1272 already demotes for being non-unique). The container's identity is role-only and shared by every row, so replay disambiguates positionally and mis-binds under reorder (measured matchCount 12, 20/20 identity-mismatch). Retarget at record time: when a press/click/fill resolves to an identity-empty container (rule 1), substitute its first labeled descendant in document order (rule 2), but only when the container's subtree has no other interactive/hittable node (rule 3, fail-closed — a trailing Switch/Checkbox must not retarget, since a tap at the descendant's center vs the container's could land on different controls). Guard-blocked or label-less subtrees record exactly as today. Implemented once at the single recording choke point (describeResolvedInteractionNode, resolution.ts): the returned node feeds BOTH buildSelectorChainForNode's chain and (downstream, via recordedTargetCapture) computeTargetEvidence, so the two writers can never half-retarget. Recording-time only — resolveSelectorChain and live press/fill dispatch are unchanged; the tap point is already fixed against the original container before this substitution runs. Adds an ADR 0012 decision 3 amendment (mirroring #1269's), a press-retarget unit/guard/cross-invariant suite (including an RN FlatList iOS parity fixture), and a reorder+insert e2e proving the retargeted recording rebinds by role+label where the un-retargeted container recording refuses.
…ot the retargeted descendant Review blocker on #1286 (flag 1 adjudicated): describeResolvedInteractionNode was computing describeNonHittableTarget from the retargeted descendant, so every retargeted press on a non-hittable title TextView would emit a false `targetHittable: false` + misleading hint on the exact happy path the fix serves — a live-response regression violating the design's recording-time-only rule. Split the fields by what they are FOR: recording-coupled fields (node as evidence source, selectorChain, refLabel — they become the .ad step) keep following the retargeted descendant; the response-semantic describeNonHittableTarget (targetHittable + hint) reverts to the original node, describing what was actually dispatched. Documented in the function comment and the ADR amendment; new load-bearing test (fails against the pre-fix line): a hittable container with a non-hittable labeled child presses with no targetHittable/hint while chain/evidence/refLabel belong to the descendant.
…l; harden the guard (#1280 re-review) Maintainer re-review corrections, four findings: P1a (side channel): the runtime response is now entirely container-based — node, selectorChain, refLabel, point, resolution disclosure, hittability all describe the dispatched container, restoring the response-identity contract. The retarget travels as an optional recordingTarget {node, selectorChain, refLabel} on the runtime result (contracts/interaction.ts), consumed only at the recording boundary (interaction-touch-response.ts): the recorded action entry — the .ad writer's result.selectorChain source — takes the descendant chain/ref-label and recordedTargetCapture feeds the descendant node to computeTargetEvidence, while both wire payloads keep container materials. Daemon-route regression proves response container-based + recorded entry, target-v1 evidence, and the physically written .ad line descendant-based. P1b (fill): removed from retarget scope — a fill chain carries editable=true constraints a label descendant can never satisfy, saving an unreplayable script. click/press only; replay test proves the recorded fill chain on an identity-empty editable container still resolves uniquely. P2a (duplicate container ids): the identity-empty predicate now evaluates from the DEMOTED identity view — dropped the extractNodeText probe whose raw-identifier fallback resurrected an id that had been demoted for non-uniqueness, which made duplicated-container-id rows skip the retarget they need most. Fixture proves retarget fires; unique-id contrast unchanged. P2b (guard): replaced the private role-fragment list with the canonical interactive classification — isSemanticTouchTarget (exported from core/interaction-targeting.ts, the same policy hittable-ancestor promotion uses) plus the hittable flag; the module moves to src/core/press-retarget.ts since selectors -> core would be a layering back-edge. Added the geometric containment condition: the selected descendant's rect center must lie inside the container's rect (missing rects fail closed) — the replay tap point must be provably within the original activation region. Tests: nested Cell (role the old list missed) blocks; out-of-bounds descendant blocks; rect-less container blocks. ADR 0012 decision-3 amendment rewritten to the side-channel design, click/press-only scope, demoted-view rule, and both guard halves. The daemon regression runs on the iOS runtime path (direct-iOS is recording-gated) so the unit lane spends no real wall-clock on Android adb dialog probes.
686cb70 to
c45f2d6
Compare
|
Re-review addressed. Rebased onto current P1 (side channel) — the runtime response is now ENTIRELY container-based. P1 (fill) — removed from scope. Retarget is click/press only ( P2 (duplicate container ids) — identity-empty now evaluates from the DEMOTED view. The P2 (guard) — canonical policy + geometry. The private fragment list is deleted; the guard is ADR decision-3 amendment + Status bullet rewritten: side-channel design, click/press-only scope, demoted-view rule, both guard halves, recording-only statement. PR body updated to match, including the note that post-fix live record → reorder → replay device evidence is pending and coordinated separately (shared wave-3 validation flow); the One remark: the canonical Gates on the rebased tree: PR stays draft pending the live device evidence. |
|
Exact-head re-review ( All current CI is green and the PR is mergeable. Residual pre-merge evidence: the post-fix live Android record → reorder/insert → replay run is still pending; attach it before merge, particularly to validate real-device hittability/guard behavior. This is not a remaining code-review finding. |
|
Live device evidence, part 1 of 2 — the hittability/guard question is answered: the retarget fires on a real Android Settings tree and the recorded artifact is exactly the designed shape. Setup: combined tree Recorded press step from the live flow ( No Part 2 (pending a low-contention window): the n=20 replay protocol and the deterministic Battery record→insert→replay leg. A first n=20 attempt was deliberately stopped at 2/20 when this shared machine's load hit 101–166 (unrelated concurrent sessions) and produced 30s adb capture-timeouts — those two runs are archived as environment artifacts, not counted. The fresh n=20 + insert leg run automatically when the machine quiets and will be posted here. |
|
Summary
Fixes #1280 — sibling to #1269 / #1272. An Android list-row press targets a clickable container
(
role="linearlayout") with no id, no label, no value — its visible title lives on a labeleddescendant (the
android:id/titleTextView, whose own id #1272 already demotes for beingnon-unique). The container's identity is role-only and shared by every row, so replay disambiguates
purely positionally and mis-binds under reorder (measured
matchCount: 12, 20/20 identity-mismatch,per the issue). #1272's id demotion can't cover it: a press container has no id to demote.
The design
Record-time retarget to the labeled descendant, via a recording-only side channel. When a
click/press resolves to an identity-empty container, the RECORDED step (the
.adline's selectorchain and the
target-v1evidence) keys off the container's first labeled descendant — while the liveresponse stays container-based end-to-end.
demoted for being non-unique), no label, and no value — all evaluated from the DEMOTED identity
view, so a container carrying its own duplicated id does not read as identity-bearing through
extractNodeText's raw-identifier fallback.canonical total order) with a non-empty normalized label, searched over the container's whole subtree.
hittable, and nothing thecanonical interactive-role classification (
isSemanticTouchTarget,core/interaction-targeting.ts— the same policy hittable-ancestor promotion uses; no parallel list) names as independently
tappable. A row with a trailing
Switch/Checkboxmust NOT retarget — a tap at thedescendant-label center vs. the container center could activate different controls.
rect — the replay tap point must be provably within the original activation region. Missing rects
fail closed.
When either blocks (or no labeled descendant exists), the container records exactly as today.
fillis excluded: its recorded chain carrieseditable=trueconstraints a label descendantcannot satisfy — retargeting would save an unreplayable script. Scope is click/press only.
Recording-only side channel. The runtime response —
node,selectorChain,refLabel,point,resolution disclosure,
targetHittable/hint — describes the DISPATCHED container end-to-end(preserving the runtime-selector response-identity contract). The retarget travels as an optional
recordingTarget: { node, selectorChain, refLabel }on the runtime result, consumed exclusively atthe recording boundary (
interaction-touch-response.ts): the recorded action entry (the.adwriter's
result.selectorChainsource) andcomputeTargetEvidence's node take the descendant; bothwire payloads keep container materials; the side channel is never serialized.
Scope: recording-time only.
resolveSelectorChain, dispatch, and the live tap point areuntouched. Replay re-resolves the descendant and taps its center, which the guard proved lies inside
the original activation region with no competing control.
Implementation map
src/core/press-retarget.ts—resolvePressRecordingTarget(rules 1–3). Lives incorebesidethe canonical
isSemanticTouchTargetit consumes (aselectors → coreimport would be a layeringback-edge).
src/commands/interaction/runtime/resolution.ts—describeResolvedInteractionNodestayscontainer-based;
pressRecordingTargetOverridecomputes the side channel once (node + chain +ref-label together, so the recorded entry and its evidence can never half-retarget).
src/daemon/handlers/interaction-touch-response.ts— the recording boundary: the recorded/historypayload takes the descendant chain/ref-label;
recordedTargetCaptureprefers the side-channel nodefor evidence;
responseDatauntouched.src/contracts/interaction.ts—RecordingTargetOverride, optional on ref/selector runtime results.demoteNonUniqueLocalIdentity(extracted from fix: demote non-unique ids from writer identity/selector chain (#1269) #1272's writer, reused by theidentity-empty check) and exported
isSemanticTouchTarget.Tests
src/core/press-retarget.test.ts): retarget fires on the Android list-row PRESS: clickable container has no selective identity (label on child) — the identity gap #1269's id-demotion can't cover #1280 fixture shape; descendant's ownshared id demoted per fix: demote non-unique ids from writer identity/selector chain (#1269) #1272 (chain = role+label); duplicated-container-id fixture retargets (P2a) with
a unique-id contrast; hittable-Switch and role-typed-Checkbox guards; nested-
Cell(canonical role theold private list missed) blocks; out-of-bounds descendant center blocks; rect-less container blocks;
RN FlatList iOS parity; cross-invariant (chain resolves back to the exact evidence node) across
retargeted/guard-blocked/normal cases.
resolution.test.ts): press response is entirely container-based with the descendant only onrecordingTarget; fill on an identity-empty editable container does NOT retarget and its recordedchain (
role="edittext" editable=true) still resolves uniquely — the saved script stays replayable.interaction-target-evidence.test.ts): record a retargeted press throughhandleInteractionCommands→ the daemon response is container-based (chain, hittability, noside-channel leak) while the recorded entry, its
target-v1evidence, AND the written.adline aredescendant-based.
session-replay-target-classification.test.ts): record a press on a row container viathe retarget → reorder + insert a row →
classifyReplayTargetrebinds the descendant by role+label(
matchCount: 1, verified); the un-retargeted container recording refuses under the same reorder.Docs
ADR 0012 decision 3 amendment (#1280) + Status bullet: side-channel design, click/press-only scope,
demoted-view identity-empty rule, both guard halves (canonical classification + geometry), and the
explicit recording-only statement.
Live validation — pending
Post-fix live Android record → reorder/insert → replay device evidence is pending and coordinated
separately as the shared wave-3 validation flow (the n=20 attachment on #1280 is the pre-fix failure
baseline). Per the first review round, the validation flow's recorded-artifact assertion (step-3 chain
must belong to the descendant) runs first; if the guard's
hittable === trueclause blocks on realtrees, the exemption decision is a follow-up with that capture as evidence.
Remarks for the maintainer
SEMANTIC_TOUCH_ROLE_FRAGMENTSlist does not name Android'sedittext(the oldprivate list did). The guard now blocks it only via the
hittableflag or, for fill semantics,never reaches it (fill excluded). If an always-block for editable roles is wanted, that's a one-line
addition to the canonical list — deliberately not made unilaterally since it also changes
hittable-ancestor promotion.
click @refnative-ref fast path records no chain at all (pre-existing, fix: demote non-unique ids from writer identity/selector chain (#1269) #1272-declared gap) —unchanged here.
Links: #1280, #1269, #1272.
Test plan
pnpm typecheckpnpm exec oxlint --deny-warningspnpm format:checkpnpm check:layeringpnpm check:fallow --base origin/main(exit 0; single inherited duplication finding)pnpm check:affected --base origin/main --run(all tests pass; one run tripped only the slow-test wall-clock gate on pre-existing contention-sensitive tests while a parallel worktree ran vitest — both files re-pass within budget in isolation and are untouched by this diff)