fix(ios): Simulator AX bridge reports enabled from the NotEnabled trait - #2644
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Disabled bridge controls may still be dispatched as taps.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds Simulator AX trait capture to expose disabled-state information consistently with XCTest.
Changes:
- Requests and decodes
UIAccessibilityTraitNotEnabled, including large trait values. - Updates protocol versioning, fixtures, tests, native bridge requests, and changelog.
File summaries
| File | Summary |
|---|---|
packages/platform-apple/src/snapshot-source/tree.ts |
Decodes enabled; moderate (3 votes): the fact does not reach bridge tappability guards, so disabled controls may still be tapped. |
packages/platform-apple/src/snapshot-source/tree.test.ts |
Adds trait validation tests; nit (1 vote): assert failureCode directly instead of matching error text. |
packages/platform-apple/src/snapshot-source/protocol.ts |
Updates protocol attributes and source version. |
packages/platform-apple/src/snapshot-source/protocol.test.ts |
Verifies native and TypeScript vocabulary alignment. |
packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json |
Updates wire vocabulary fixtures. |
CHANGELOG.md |
Documents the behavior change. |
apple/snapshot-bridge/SnapshotBridgeRuntime.m |
Requests accessibility traits from the native bridge. |
Review details
Suppressed comments (1)
packages/platform-apple/src/snapshot-source/tree.test.ts:174
- These new assertions identify the failure by matching rendered error text (
/traits-invalid/) instead of theSnapshotSourceError.failureCodefield. That couples the regression test to message formatting; assert the typedfailureCode === 'traits-invalid'as the neighboring protocol tests do, so wording can evolve independently.
assert.throws(() => decode('256'), /traits-invalid/);
assert.throws(() => decode(1.5), /traits-invalid/);
assert.throws(() => decode(-1), /traits-invalid/);
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Live evidence (4eba74c)Recording of the patched build driving the React Native disabled-state repro on an iPhone 17 Pro simulator (iOS 26.5), simulator on the left, test runner log on the right: Video: https://github.com/okwasniewski/agent-device/releases/download/evidence/pr-2644/proof-pr-2644.mp4 What it shows:
Bridge-served snapshots from the same session (nodes carry Full JSON: snap-raw-final.json, snap-regular.json. The |
There was a problem hiding this comment.
🔵 Needs a closer look
The native trait fixture is missing, and the summary still reports 7 instead of 9 reviewed files.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
packages/capture-kit/src/ios-snapshot-engine/geometry.ts:142
- The PR summary says this change touches 7 files, but the supplied diff contains 9 changed files, including both
protocol.test.tsandgeometry.tsin addition to the listed groups. Please correct the touched-file count so the summary matches the actual scope.
apple/snapshot-bridge/SnapshotBridgeRuntime.m:295
- The native fixture suite exercises this runtime, but none of its snapshots includes
XC_kAXXCAttributeTraits; the vocabulary test only checks that the literal is present. A regression in the requested attribute's NSNumber-to-wire conversion could therefore dropenabledwhile the TypeScript tests still pass. Please add a native-runtime fixture case containing both256and the2^53trait word and assert they survive the bridge response.
kAttributeTraits,
packages/capture-kit/src/ios-snapshot-engine/engine.test.ts:327
- The PR summary says this change touches 7 files, but the diff also includes
packages/capture-kit/src/ios-snapshot-engine/geometry.tsandengine.test.ts, for 9 files total. Please update the touched-file count so the summary and validation scope accurately describe the change.
test('a source-declared disabled node is not actionable without hittability evidence', () => {
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Integration Tests on 4eba74c caught a real consequence of the fold change: the navigation title rule promotes a disabled title field to an enabled Button, and that Button inherited the folded |
There was a problem hiding this comment.
🟡 Changes recommended
The trait bit mask is incorrect, and sequential replacement patches can restore retracted fields.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
packages/capture-kit/src/ios-snapshot-engine/geometry.ts:142
- This changes a pure geometry invariant, but the new assertion is only a single fixed engine case. Extend the existing fast-check/property coverage to generate unavailable-hittability acquisitions with both enabled states and assert that
enabled: falsenever produceshittable: true; otherwise a regression to the old early-return behavior would still pass the property suite.
return sourceHittable === false || !enabled ? { hittable: false } : {};
packages/platform-apple/src/snapshot-source/tree.test.ts:168
- The new traits parser is a pure boundary with an unsafe-integer/BigInt edge, but coverage is limited to these hand-picked values. Add a fast-check or parser-fuzz property around 2^53 (and invalid numeric forms) so the bit-8 derivation and typed rejection cannot regress silently.
const buttonTrait = 1;
const notEnabledTrait = 256;
const toggleButtonTrait = 2 ** 53;
assert.equal(decode(buttonTrait)?.enabled, true);
assert.equal(decode(buttonTrait + notEnabledTrait)?.enabled, false);
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Lite
| * `UIAccessibilityTraitNotEnabled`, the trait UIKit sets on a disabled control. The runner path | ||
| * answers `enabled: false` for the same node, so the bridge derives the fact from this bit. | ||
| */ | ||
| const NOT_ENABLED_TRAIT = 1n << 8n; |
|
Reviewed at d1ffcb9.
packages/capture-kit/src/ios-snapshot-engine/presentation.test.ts:531 adds 54 lines to a file already over the size-ratchet limit (994 -> 1048), and that is the one test failing in Coverage. Could the new navigation-title test and the sequential-patch regression move into a new transitions.test.ts that mirrors transitions.ts, so presentation.test.ts stays at or below 994 lines? The navigation-title rule in transitions.ts:36 also fires on the XCTest runner path, where the disabled field already folds to In platform-apple's tree.ts:309, the guest forwards the uint64 trait word as an NSNumber, and protocol.ts:183 decodes it with JSON.parse into a double; Coverage fails on exactly one test, the test-file-size ratchet on presentation.test.ts (994 -> 1048), and the 54 lines this PR adds cause it. The retraction leak comes from reading the rule order and The replacement-retraction leak and moving the new test out of presentation.test.ts both need to be resolved before this is ready to merge. |
|
Thanks Michał, all four landed in 9d54c0b.
Live: a third bridge binary compiled from the new source, and the bridge-served raw snapshot still reports |
The bridge requested no state attribute, so a disabled control read as a plain
button and clients could not observe it; the XCTest runner answered the same
screen with `enabled: false`. The guest now reads `XC_kAXXCAttributeTraits`
and the decoder derives `enabled` from `UIAccessibilityTraitNotEnabled`. The
word is masked as a BigInt because `UIAccessibilityTraitToggleButton` is 2^53,
so every switch carries a value past the safe integer range; a missing word
leaves `enabled` unknown and a non-integer or negative one is a malformed tree.
Source version v1.6.0 rebuilds the cached bridge.
Verified on an iPhone 17 Pro simulator (iOS 26.5) against a React Native app
with `Pressable disabled` and `accessibilityState={{ disabled: true }}`: the
bridge-served raw snapshot now carries `enabled: false` on both controls, and a
client `toBeDisabled` assertion that failed on 0.21.1 passes.
…ut hittability evidence Review found the new `enabled` fact never reached presentation on the bridge path: hittability evidence is unavailable there, so the fold returned before consulting `enabled`, and a disabled control kept an open `hittable`. The fold now marks a node the source declared disabled as `hittable: false` regardless of evidence availability, which is what the runner path already presents. The tree test asserts the typed `failureCode` instead of matching error text.
… hittability The navigation title rule presents a disabled title field as an enabled Button for the whole row, but the fold had already marked the field `hittable: false` from its disabled state, so the Button carried a claim it contradicted; the cloud iOS provider scenario caught it. A replacement patch may now retract a fact by setting it `undefined`, and the rule retracts `hittable`, leaving the row's actionability to the evidence the acquisition actually has.
…aits word exactly Review (thymikee): `currentReplacement` rebuilt a node from its source under the stored replacement, so a later patch on the same node — a scroll clip, a row label — brought back a fact an earlier rule had retracted: the web Heading's `value`, and the promoted navigation title's `hittable`. The stored replacement is now authoritative, as every other reader of the map already treats it; a regression applies two patches in sequence. The guest sends the accessibility traits word as a decimal string and the decoder parses it with BigInt, so a bit set past 2^53 cannot round into or out of the NotEnabled bit on the way through JSON; the test covers a word past 2^60. The navigation title test moves to transitions.test.ts, keeping presentation.test.ts under the size ratchet. The CHANGELOG names the two presentation effects: bridge disabled controls leave the interactive and Maestro atomic-dispatch counts, and the runner-path title Button joins them.
There was a problem hiding this comment.
🟡 Changes recommended
Trait decoding has unresolved correctness issues, and native serialization lacks coverage for exact large trait values.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
apple/snapshot-bridge/SnapshotBridgeRuntime.m:192
- The native conversion added here is not exercised by the existing native snapshot fixture: its synthetic attribute dictionaries never contain the new traits attribute, and the runtime tests only check scenario exit codes. Add a fixture case that supplies an
NSNumbertraits value, including a value above 2^53, and assert the bridge emits the exact decimal string; otherwise a regression here could silently reintroduce precision loss while the current tests remain green.
if ([name isEqualToString:kAttributeTraits] && [value isKindOfClass:NSNumber.class]) {
return ((NSNumber *)value).stringValue;
packages/platform-apple/src/snapshot-source/tree.ts:118
UIAccessibilityTraitNotEnabledis bit 11 (1 << 11) in the UIKit traits word; bit 8 isUIAccessibilityTraitStaticText. Because the bridge is forwarding that UIKitUIAccessibilityTraitsvalue, a normal disabled button will carry bit 11 and this mask will leave it asenabled: true, so the reported regression remains unfixed. Use the bit-11 mask and update the trait fixtures accordingly.
const NOT_ENABLED_TRAIT = 1n << 8n;
packages/platform-apple/src/snapshot-source/tree.ts:309
- The decoder treats an explicitly present
XC_kAXXCAttributeTraits: nullas if the word were missing, returning an unknownenabledvalue. This conflicts with the documented contract that only a missing word is unknown and every non-digit value istraits-invalid; malformed bridge responses should not silently erase the state fact.
if (value === undefined || value === null) return undefined;
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Lite
| if (typeof value !== 'string' || !/^\d{1,20}$/.test(value)) { | ||
| throw snapshotSourceError('malformed-tree', 'traits-invalid'); | ||
| } | ||
| return (BigInt(value) & NOT_ENABLED_TRAIT) === 0n; |
9d54c0b to
ee22c24
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The accessibility disabled-trait mask must use UIKit’s bit 10 instead of bit 8.
Review details
Suppressed comments (1)
packages/platform-apple/src/snapshot-source/tree.ts:118
UIAccessibilityTraitNotEnabledis not bit 8 (1 << 8is the StaticText trait); UIKit defines the disabled trait at bit 10 (1 << 10, value 1024). With this mask, a disabled control carrying the real NotEnabled trait will still be decoded asenabled: true, so the bridge does not fix the reported mismatch. Update the mask and the regression fixture to use the platform constant/value.
const NOT_ENABLED_TRAIT = 1n << 8n;
- Files reviewed: 13/13 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
On the suppressed review note at Bit 10 is SearchField. The mask stays |
|
Reviewed at ee22c24. The new commit resolves the four points from the earlier review (d1ffcb9). Later patches no longer bring back retracted facts, presentation.test.ts is back under the size limit, the CHANGELOG names the runner-path and Maestro effects, and the traits word now crosses the wire as a decimal string that is parsed exactly. CI is green, and the PR body reports a live Simulator run at this commit that covers the new string path. Two optional notes: the decoder still accepts a 20-digit string above 2^64-1 instead of rejecting it as traits-invalid (tree.ts#L311), and the new regression calls the replacement helpers directly instead of running a full presentation pass with a clipped web Heading (tree.test.ts#L22). |

Summary
The Simulator AX bridge requested no state attribute, so a bridge-served snapshot never carried
enabled. A disabled control read as a plain button while the XCTest runner answered the same screen withenabled: false(see #2638).The guest now requests
XC_kAXXCAttributeTraits, andtree.tsderivesenabledfromUIAccessibilityTraitNotEnabled(bit 8). A missing word leavesenabledunknown; anything but a digit string istraits-invalid. Source version v1.6.0 rebuilds cached bridges.Review follow-ups:
foldedHittabilitypresents a source-declared disabled node ashittable: falseeven without hittability evidence (runner parity; tap dispatch unchanged). The navigation title rule retracts that foldedhittablefrom the Button it promotes, and the stored replacement is now authoritative so later patches cannot resurrect a retracted fact. The traits word travels as a decimal string and is parsed exactly. Both presentation effects are in the CHANGELOG.13 files touched.
Validation
Tested at ee22c24.
snapshot-source,ios-snapshot-engine, the size ratchet, andtest/integration/provider-scenarios: 376 passed.pnpm lint,pnpm typecheck,pnpm buildclean.pnpm check:affected --run: one failure inplatform-web/src/agent-browser-lifecycle.test.ts, untouched here (its fake pid 101 is a live process on this host). CI Repo Guards failed once on the tmpdir-model timing test; it passes 24/24 locally and no script changed. Please rerun.Pressable disabled: bridge-served raw snapshot reportsenabled: falseon both disabled controls, regular projection addshittable: false, and a clienttoBeDisabledassertion that failed on 0.21.1 passes. Recording and snapshot JSON: https://github.com/okwasniewski/agent-device/releases/tag/evidence/pr-2644.selected(trait bit 3) is a follow-up.