fix(android): carry the checked state of a checkable control on the snapshot node - #2913
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Add regression coverage for checked-state unchanged detection and selector digests, including true, false, and absent cases.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Adds Android checked state propagation from helper XML through snapshots, attributes, digests, diffs, and rendered output.
Changes:
- Serializes and parses checked states for Android checkable controls.
- Displays
[checked]/[unchecked]and detects toggle changes. - Updates tests, documentation, and changelog.
| File | Description |
|---|---|
website/docs/docs/snapshots.md |
Documents Android checked metadata. |
website/docs/docs/commands.md |
Updates helper limitation documentation. |
src/daemon/response-views.ts |
Includes checked state in selector digests. |
src/commands/output/snapshot.test.ts |
Tests checked markers. |
src/commands/capture/runtime/snapshot-unchanged.ts |
Compares checked state for freshness. |
src/__tests__/android-ui-hierarchy.test.ts |
Tests published checked state. |
packages/platform-android/src/ui-hierarchy.ts |
Parses checked XML attributes. |
packages/platform-android/src/ui-hierarchy-node.ts |
Adds checked to Android nodes. |
packages/platform-android/src/ui-hierarchy-builder.ts |
Publishes checked state. |
packages/platform-android/src/__tests__/ui-hierarchy-checked.test.ts |
Tests checked propagation. |
packages/kernel/src/snapshot.ts |
Extends snapshot contracts. |
packages/capture-kit/src/snapshot/snapshot-lines.ts |
Renders checked markers. |
packages/capture-kit/src/snapshot/snapshot-freshness/android.ts |
Documents freshness behavior. |
packages/capture-kit/src/snapshot/snapshot-diff.ts |
Compares checked state markers. |
packages/capture-kit/src/snapshot/__tests__/snapshot-diff-checked.test.ts |
Tests checked diffs. |
CHANGELOG.md |
Records the Android fix. |
android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java |
Serializes checked state from Android controls. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| identifier: true, | ||
| enabled: true, | ||
| selected: true, | ||
| checked: true, |
There was a problem hiding this comment.
Added in e5bba1b: { checked: true } and { checked: false } sit in the "detects presentation changes" table, so an absent-to-false flip is a change too.
| 'identifier', | ||
| 'enabled', | ||
| 'selected', | ||
| 'checked', |
There was a problem hiding this comment.
Added in e5bba1b: the attrs digest test covers checked: true, checked: false, and a node without the fact, which stays absent.
thymikee
left a comment
There was a problem hiding this comment.
Reviewed at d2d4d35. stateMarkers is the right place for the rendering/diff state vocabulary. The remaining issue is that the PR now has a third state-folding lane, interaction outcome, and it still uses the pre-checked shape. Copilot already covers the missing tests; this is the behavioral divergence.
| const markers: string[] = []; | ||
| if (node.enabled === false) markers.push('disabled'); | ||
| if (node.selected === true) markers.push('selected'); | ||
| if (node.checked !== undefined) markers.push(node.checked ? 'checked' : 'unchecked'); |
There was a problem hiding this comment.
stateMarkers now declares which states a comparison weighs, and this PR applies that answer in snapshot-unchanged.ts and the Android freshness comment. A third state signature still disagrees: interactionSurfaceSemanticKey in src/daemon/interaction-outcome-policy.ts:595 folds enabled, selected, and hittable but not checked. Android is checked's only producer, so a tap whose only effect is a toggle can be changed on the diff lane but unchanged on the interaction-outcome lane, causing retry or delayed-recheck work for a gesture that actually worked. Please either fold checked into that key, or make the exclusion explicit and test it. If including it, also account for discriminatingSurfaceChangedWithinRect in src/daemon/scroll-movement.ts:385, where an unrelated in-container toggle now counts as scroll movement.
There was a problem hiding this comment.
Folded in, e5bba1b. interactionSurfaceSemanticKey now spreads stateMarkers(node) for its state segments, so the outcome lane, the unchanged-snapshot comparison, and the diff weigh one list; a checked-only flip classifies changed instead of feeding a no-change retry that would tap the switch back.
For the scroll lane: the flip alone never reaches discriminatingSurfaceChangedWithinRect, since classifyBaselineSurfaceEvidence matches on the flip-tolerant identity and reads it as unchanged. It does reach it beside an unrelated change, such as the status clock ticking, and there a key-matched view read the flipped switch as content moving inside the container. The function now matches entries on identity where one exists, told apart by document order when repeated, and on key otherwise, so a state flip at the same rect is not movement. haveIdenticalDiscriminatingSurfaces keeps its key-matched veto, as its comment asks.
Tests: a checked-only flip is changed on the outcome lane; a flip at the same rect is no movement while a moved row is; the scroll claim is withheld (change-outside-container) for a flipped toggle inside the container beside a clock tick; both checked answers invalidate snapshot reuse and survive the selector digest.
|
Reviewed at d2d4d35. The change looks correct: Not blocking: the two The on-device run comes from the PR description; I did not repeat it. The Android Smoke Tests job was still running at review time. It builds the helper this PR changes, so a green run there confirms the change on an emulator. |
4131653 to
dc8f367
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The checked state is not yet included in the settle signature, and state-derived identity can misclassify toggle changes as movement.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (3)
Resolved since last review (1)
| /** Checked state of a checkable control (switch, checkbox, radio); absent means not checkable or unavailable. */ | ||
| checked?: boolean; |
|
needs rebase |
|
One finding is still open at dc8f367, so this stays blocking.
The PR currently has merge conflicts with main and needs a rebase before it can land. Smoke Tests, which builds and runs the Android snapshot helper this PR touches, is still queued, so it has not yet produced a result to weigh in. Repo Guards was cancelled rather than failed, and this diff's logic changes fall outside what that gate checks beyond the normal lint and type gates. I did not run the daemon or Maestro suites locally for this pass; the read is from the diff and existing tests. I also did not do a live device run for commits 2-4, since they are TS decision logic over snapshots already captured on-device in the earlier commit and reviewed clean before. |
dc8f367 to
4be8eba
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Stabilize checkable-node identity when labels or values change with checked state, and add regression coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
| return { | ||
| key: `${semanticKey}|#${occurrence}`, | ||
| ...(identity ? { identity } : {}), | ||
| content: interactionSurfaceContent(node, identity), |
|
Folded |
|
The code looks ready at c7236cb. The comment sniffing described in dc8f367 is gone, and the checked-state fix now carries through the snapshot node as before. Not blocking: the two-line comment above Smoke Tests and Coverage were still in progress at review time, and Smoke Tests exercises the Android snapshot helper (AccessibilityTreeXml.java) this PR touches, so merge should wait for both to finish. I did not run the Maestro or daemon suites locally, and did not do a live on-device Android run for this change, since it's TS logic over a checked field that earlier commits in this branch already captured from a real device XML fixture. |
|
Trimmed the comment above |
c7236cb to
67b5525
Compare
…napshot node The snapshot helper never serialized `checked` or `checkable`, and the host reads only the helper's XML, so no later layer could recover it: a switch, checkbox, or radio button looked the same on or off, `get attrs` had no `checked` field, and snapshot text showed the control as plain. The helper now writes `checked` on every node Android reports as checkable, with both answers, so an unchecked switch reads `false`, a node that cannot be checked reads nothing, and so does a helper older than the attribute. The parser, the Android hierarchy node, and the published snapshot node carry it to `get attrs`, the unchanged-snapshot comparison, the selector digest, and the `[checked]` and `[unchecked]` markers in snapshot text. Both answers render because the diff compares the state and a checkable control rendered as plain would hide that it toggles.
…rints, and a scroll's container check matches content by identity Review follow-up. The interaction surface key folded enabled and selected but not checked, so a tap whose only effect was a toggle read as a no-op on the outcome lane while the diff called it a change, and a no-change retry would tap the switch straight back. The key now spreads stateMarkers, so the outcome lane, the unchanged-snapshot comparison, and the diff weigh one list. With checked in the key, a flip inside a scroll container beside an unrelated change elsewhere (the status clock) read as content moving within the container. discriminatingSurfaceChangedWithinRect now matches entries on the flip-tolerant identity where one exists, told apart by document order when repeated, so a state flip at the same rect is not movement. Tests: a checked-only flip is a change on the outcome lane; a flip at the same rect is not movement and a moved row is; the scroll claim is withheld for a flipped toggle inside the container; checked in both answers invalidates snapshot reuse and survives the selector digest.
…th state markers The two hand-written pre-signatures carried the literal enabled and unselected segments the key no longer writes for a plain node, so the post-tap capture read as changed and the retry never fired.
…anonymous toggle is not movement Review follow-up. The within-container movement check fell back to `key` for an entry without an identity, and `key` carries the checked state, so an unlabelled switch a swipe brushed still read as content moving. Every entry now carries `content`: the identity where the node has one, else its type and role; the check compares on that alone.
…is not a no-op to retap maestroSnapshotSignature hashed label, value, enabled, selected, focused and bounds; a switch, checkbox or radio whose only observable effect is its checked state produced the same signature before and after the tap, so the settle and the no-change retry read the tap as a no-op and could tap it back. checked joins the hashed object the way the interaction outcome key already weighs it.
67b5525 to
6dae0a1
Compare


Summary
The helper never serialized
checkedorcheckable, and the host reads only its XML, so a switch, checkbox, or radio button looked the same on or off:get attrshad nocheckedfield and snapshot text showed the control as plain.The helper now writes
checkedon every checkable node, with both answers: an unchecked switch readsfalse, a node that cannot be checked reads nothing, like an older helper. The parser, the Android node, and the published snapshot node carry it toget attrs, the unchanged-snapshot comparison, the selector digest, and snapshot text, which marks[checked]or[unchecked]. One function,stateMarkers, now yields the printed states, thediffkey, and the interaction-outcome key, so a toggle tap reads as a changed pair and not as a no-op to retry. A scroll's within-container check matches content by identity, so a flip is not movement.22 files. No
HELPER_API_VERSIONbump, as withselected(#2515). Nois checkedpredicate orchecked=selector key here.Validation
Tested at dc8f367 (rebased onto 86d719d).
pnpm check:affected --run --base 86d719dc3passed: format, lint, typecheck, layering, fallow, build, 854 test files, 6,687 tests.Rebuilt helper on an API 36 emulator, React Native app:
Switch,CheckBox, andRadioButtoncarrycheckedinsnapshot --jsonandget attrs, text nodes do not, andpress @switch --settlereports- [unchecked]then+ [checked].Risk: a
--settlethat read a toggle tap as unchanged now reports the flip; the outcome lane does too.