Skip to content

fix(selectors): collapse an unverified-hittability wrapper chain to its control - #2482

Merged
thymikee merged 3 commits into
callstack:mainfrom
prateekranka:fix/unverified-hittability-wrapper-chain
Sep 11, 2026
Merged

thymikee merged 3 commits into
callstack:mainfrom
prateekranka:fix/unverified-hittability-wrapper-chain

Conversation

@prateekranka

@prateekranka prateekranka commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Collapse an unverified-hittability wrapper chain to its control

Fixes #2480

What changes

A SwiftUI toolbar wrapper (Other) and its control (Button) share one accessibility identifier. Regular iOS snapshots omit hittability evidence, and the two rects differ by under a point per edge, so press 'id="scoring_home_button"' fails with:

Error (AMBIGUOUS_MATCH): Selector matched 2 distinct actionable elements: id="scoring_home_button"
Candidates: @e3 [other] "Home"  @e4 [button] "Home"

findPreferredActionableDescendant requires verified hittability, which regular iOS snapshots never provide, so the documented wrapper collapse cannot fire for the most common SwiftUI toolbar shape.

New module packages/selectors/src/interaction-targeting-wrapper-chain.ts resolves the deepest semantic touch target when every candidate lacks hittability evidence, all rects agree within 1 pt of slack, and every candidate above the control is a non-actionable wrapper. A candidate carrying any hittability fact keeps the existing rules; a chain of two real controls (a cell and the button inside it) keeps the ambiguity refusal.

Scope of this change

classifyActionableTouchCandidates is reached only from the action-target resolution (src/commands/interaction/runtime/selector-action-resolution.ts, called by resolution.ts). This change therefore covers press/click/fill/longpress, the is target verification, and the post-action settle observation.

wait does not run through that path: src/daemon/wait-runtime.ts builds its own selector runtime, and its multi-match refusal is produced there. The wait case is not fixed by this change and stays open (see "Runner side" below). An earlier version of this description attributed a wait improvement to this change; that attribution was wrong, and the device-lane rows below are press-only.

Tests

  • interaction-targeting-wrapper-chain.test.ts (new): the collapse, the slack boundary, the "a hittability fact still wins" case, and refuses a chain of two real controls that share one rect (a semantic ancestor with a child button, no hittability, rects 0.5 pt apart).
  • interaction-targeting.test.ts, selector-action-resolution.test.ts: the pair resolves to the button.
  • Red before / green after: on the base the new tests fail with the live message Selector matched 2 distinct actionable elements: id="scoring_home_button". Removing only the nested-control guard fails the new negative case while the captured Other/Button success case still passes (1 failed | 4 passed).

Device lane (iPhone 15 simulator, iOS 26.5, Xcode 26.6; 0.21.0 as the control)

Same screen state in both runs: scoring_home_button -> Other [20,63,36,36] + Button [21,63,35,36].

command 0.21.0 this branch
press 'id="scoring_home_button"' --settle rc 1 AMBIGUOUS_MATCH rc 0, Tapped id="scoring_home_button" (38, 81)
wait 'id="new_game_button"' 8000 (consequence: the press left the scoring screen) rc 1 (timeout) rc 0

Gates

pnpm check:affected --run: 304 files / 2011 tests, all runnable checks passed.

Runner side (not in this PR)

The wait refusal is produced by the selector runtime that wait builds for itself, and on iOS that is the native XCTest runner (apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift:1683 and RunnerTests+Interaction.swift:238), which does not use the shared structural-equivalence rules. I can prepare that patch; it needs a simulator lane run. Which shape do you prefer - delegate to the shared rules, or mirror them?

…ts control

A SwiftUI toolbar wrapper and its control share one identifier, and regular iOS snapshots omit hittability evidence. findPreferredActionableDescendant requires verified hittability, and the wrapper's rect differs by under a point per edge, so press/fill saw two distinct actionable elements for one control and refused with AMBIGUOUS_MATCH.

Resolve the deepest semantic touch target when every candidate lacks hittability evidence and all rects agree within sub-pixel slack. Candidates carrying any hittability fact keep the existing rules.
@thymikee

Copy link
Copy Markdown
Member

The new fallback also accepts two real nested controls. With a Cell and child Button sharing a selector, no hittability fields, and rectangles within 1 pt, the old rules keep their distinct semantic targets ambiguous; resolveUnverifiedWrapperControl now picks the child because it checks only that the deepest candidate is semantic. That can silently press the wrong control. Restrict this fallback to non-actionable wrappers around one control and add a negative regression for a semantic ancestor, alongside the existing Other/Button success case.

…wrappers

Review follow-up on callstack#2482. The unverified-hittability collapse accepted any
ancestry chain whose rects agreed within a point, so a cell and the button
inside it (both actionable, no hittability evidence) collapsed to the
descendant: a silent wrong-control press where the previous rules refused as
ambiguous. The fallback now requires every candidate above the control to be a
non-actionable wrapper, and a negative regression covers the semantic-ancestor
case next to the captured Other/Button success case.

Gate: pnpm check:affected --run - 304 files / 2011 tests, all runnable checks passed.
@prateekranka

Copy link
Copy Markdown
Contributor Author

Addressed in 8f1e50be5.

The fallback now requires every candidate above the control to be a non-actionable wrapper (isSemanticTouchTarget is false for each), so a cell and the button inside it keep the ambiguity refusal instead of collapsing to the descendant. The captured Other/Button chain still resolves, because an XCUIElementTypeOther wrapper is not a semantic touch target.

New regression: refuses a chain of two real controls that share one rect — a semantic ancestor with a child button, no hittability evidence, rects 0.5 pt apart — asserts the fallback returns null. It sits next to the captured success case and the existing verified-hittability and slack-boundary refusals.

Gate: pnpm check:affected --run — 304 files / 2011 tests, all runnable checks passed. The device lane is unchanged: on an iPhone 15 simulator the patched build presses scoring_home_button where 0.21.0 refuses with AMBIGUOUS_MATCH.

@prateekranka

Copy link
Copy Markdown
Contributor Author

Red evidence for that regression, on the previous commit: with the wrapsOnlyNonActionable guard removed, refuses a chain of two real controls that share one rect fails with an AssertionError, while the captured Other/Button success case, the verified-hittability refusal, the slack-boundary refusal, and the non-semantic-deepest refusal all still pass (1 failed | 4 passed). The new case therefore pins exactly the over-permissive collapse and nothing else.

@thymikee

Copy link
Copy Markdown
Member

The nested-control guard at 8f1e50b addresses the earlier finding, and the negative regression targets it correctly. One scope claim still needs correction: this helper is called by action-target resolution, not selector wait, so the reported wait improvement cannot be attributed to this change. Please narrow the claim to press and leave the native wait case explicitly open, or add the missing wait-path change and evidence. GitHub currently reports no checks for this head.

@prateekranka

Copy link
Copy Markdown
Contributor Author

Corrected - the scope claim is narrowed, and the earlier attribution is withdrawn.

classifyActionableTouchCandidates is reached only from the action-target resolution (src/commands/interaction/runtime/selector-action-resolution.ts, called by resolution.ts), so this change covers press/click/fill/longpress, the is target verification, and the post-action settle observation.

wait builds its own selector runtime in src/daemon/wait-runtime.ts and does not consume that classifier, so no wait improvement can be attributed to this change. The PR body now states this explicitly, the device-lane table is press-only, and the native wait case stays open under "Runner side" — happy to prepare that patch against the runner once you say which shape you want (delegate to the shared equivalence rules, or mirror them).

Press-path device evidence is unchanged and reproducible on the same screen state: 0.21.0 refuses with AMBIGUOUS_MATCH, this branch taps scoring_home_button and the scoring screen closes.

@thymikee

Copy link
Copy Markdown
Member

The corrected scope and press-only evidence address the remaining review concern at 8f1e50b. Ready for human review; GitHub still reports no checks for this head. Keep the native wait work open when handling #2480, since this PR only resolves the action-target path.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 11, 2026
@thymikee

Copy link
Copy Markdown
Member

Thanks! Please fix coverage

@thymikee

Copy link
Copy Markdown
Member

The Coverage failure at 8f1e50b is related to this change: the new static import of interaction-targeting-wrapper-chain adds one eagerly evaluated module to three selector entry points. Restore the import budget at the shared owning module and rerun the gate; this is not a coverage-percentage failure. The behavior review and press evidence remain clean, so ready-for-human stays applied, but this gate still blocks merge.

The extracted module grew the eager closure of three budgeted entries by one
module each -- interaction-targeting.ts 13 -> 14, selector-pipeline.ts 25 -> 26,
absence-observation-resolution.ts likewise -- and the eager-closure gate
ratchets that closure against the merge-base: an entry surface that drags more
of the repo onto the import path is a loading-shape regression whatever the
reason. The rule has exactly one consumer, so it now lives beside the
classification that asks it and is no longer an exported surface.

Its tests move to the owning module's test file and exercise
`classifyActionableTouchCandidates`, the boundary the command actually calls.
Each of the four refusals fails when its own guard is mutated: the hittability
condition, the 1 pt slack, the non-actionable-wrapper condition, and the
semantic-control condition.
@thymikee

Copy link
Copy Markdown
Member

Pushed 6839c9e6f7 to unblock Coverage — that job wasn't short on line coverage, it was the
eager-closure gate: interaction-targeting-wrapper-chain.ts added a static edge that grew three
budgeted entries by one module each (interaction-targeting.ts 13→14, selector-pipeline.ts
25→26, absence-observation-resolution.ts +1). That gate ratchets against the merge-base, has no
exception table, and its own advice is to give the new code a home in a module the closure already
evaluates.

So the rule now lives in interaction-targeting.ts — its only consumer, and the module that already
owns candidate classification — as a private helper, and its tests moved to
interaction-targeting.test.ts, exercising classifyActionableTouchCandidates (the boundary the
command calls) instead of the helper. Behavior is unchanged; the PR's device-lane rows still describe
this head. Each refusal still fails when its own guard is mutated: the hittability condition, the
1 pt slack, the non-actionable-wrapper condition, the semantic-control condition.

scripts/__tests__/eager-closure-budgets.test.ts is green (588), and pnpm check:affected --run
passes every runnable gate: 2,010 tests / 303 files plus format, lint, types, layering, Fallow and
build.

One housekeeping item: the "Tests" bullet still names
interaction-targeting-wrapper-chain.test.ts, which no longer exists — leaving that wording to you.
If you'd rather keep the rule in its own file, the other already-evaluated home is
touch-semantics.ts; making the edge lazy instead would force classifyActionableTouchCandidates
async, which I'd avoid.

@thymikee

thymikee commented Sep 11, 2026

Copy link
Copy Markdown
Member

The move at 6839c9e preserves the helper and its guards while removing the extra eager import. Coverage now passes, and the moved tests exercise the production classifier. No code findings; ready for human review, with the earlier press evidence still applicable. Please correct the Tests section to name interaction-targeting.test.ts; the standalone wrapper-chain test file was removed. The iOS smoke job has since failed with RUNNER_BUSY at the WebView page wait, matching the main-lane signature and appearing unrelated to this helper move.

@thymikee
thymikee merged commit c94e66e into callstack:main Sep 11, 2026
12 of 13 checks passed
thymikee added a commit that referenced this pull request Sep 11, 2026
A control reported through its own accessibility wrapper answers a selector
twice, and a regular iOS snapshot omits unverified hittability, so the ladder
that relates a wrapper to its control cannot fire. #2482 collapsed that chain for
mutating resolution only: `press` tapped the toolbar button while `is visible`
and `get attrs` reported "Selector did not match" and `screenshot --crop-on`
refused the same screen as two nodes.

Export the collapse beside the classification that asks for it and apply it where
a read row's answer was a refusal. Rows that resolve before any refusal are
untouched, and a candidate set the rule does not recognize as one control - a
cell and the button inside it, or matches in distinct subtrees - still refuses.

Replay verifies a recorded target by resolving its recorded selector again under
the same row's refusal rules, so a step whose screen had not changed verified as
an identity mismatch on its first replay. Verification names the collapsed control
too, which is the node dispatch acted on and the node the recorded identity
carries.
thymikee added a commit that referenced this pull request Sep 12, 2026
#2501)

A control reported through its own accessibility wrapper answers a selector
twice, and a regular iOS snapshot omits unverified hittability, so the ladder
that relates a wrapper to its control cannot fire. #2482 collapsed that chain for
mutating resolution only: `press` tapped the toolbar button while `is visible`
and `get attrs` reported "Selector did not match" and `screenshot --crop-on`
refused the same screen as two nodes.

Export the collapse beside the classification that asks for it and apply it where
a read row's answer was a refusal. Rows that resolve before any refusal are
untouched, and a candidate set the rule does not recognize as one control - a
cell and the button inside it, or matches in distinct subtrees - still refuses.

Replay verifies a recorded target by resolving its recorded selector again under
the same row's refusal rules, so a step whose screen had not changed verified as
an identity mismatch on its first replay. Verification names the collapsed control
too, which is the node dispatch acted on and the node the recorded identity
carries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One SwiftUI toolbar control reaches press/wait as two actionable elements (AMBIGUOUS_MATCH)

2 participants