diff --git a/CHANGELOG.md b/CHANGELOG.md index 7301f3b3f4..afea45c5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Fixed (ios): a local Simulator snapshot taken through the host AX bridge once again carries the + accessibility `selected` state, so `is selected`, a `selected=true` selector, and a Maestro + `selected:` qualifier match the active control. When 0.21.0 made the AX bridge the Simulator's + snapshot source, the bridge's node reader mapped the traits word into `enabled` but never into + `selected`, so an active bottom tab, chosen segment, or checked row published no `selected` field + and every `selected:` match failed on the iOS Simulator — a fact the 0.20.x XCTest tree had always + supplied and that `snapshots.md` still promises. The reader now derives `selected` from the + selected-trait bit of the same traits word, publishing `selected: true` only when set and omitting + it otherwise so the two producers cannot be told apart by a `selected:` selector. The bit is pinned + by the guest's real captured words, so a producer encoding change cannot drop the fact silently + again. - Fixed (ios): an interactive snapshot no longer drops the rows of a list whose row hosts a scroll view of its own. Ownership of a scroll indicator is now read from its parent edge — a visible band derives only when the indicator's parent is itself a scroll type — instead of walking ancestors for diff --git a/packages/platform-apple/src/snapshot-source/tree.test.ts b/packages/platform-apple/src/snapshot-source/tree.test.ts index e98163c42d..749e182ef5 100644 --- a/packages/platform-apple/src/snapshot-source/tree.test.ts +++ b/packages/platform-apple/src/snapshot-source/tree.test.ts @@ -190,6 +190,51 @@ test('the bridge tree reads enabled from the NotEnabled trait', () => { assert.throws(() => decode(''), traitsInvalid); }); +test('the bridge tree reads selected from the selected trait, matching the XCTest tree', () => { + const tab = (word?: unknown) => ({ + [automationType]: 9, + [label]: 'Albums', + [frame]: { X: 20, Y: 700, Width: 120, Height: 48 }, + ...(word === undefined ? {} : { [traits]: word }), + [children]: [], + }); + const decode = (word?: unknown) => + decodeSnapshotBridgeTree( + { [application]: 'Application', [children]: [tab(word)] }, + { truncated: false }, + limits, + ).nodes[1]; + const selected = (word?: unknown) => decode(word)?.selected; + const enabled = (word?: unknown) => decode(word)?.enabled; + + const buttonTrait = 1n; + const selectedTrait = 1n << 3n; + const privateHighTrait = 1n << 60n; + const word = (traits: bigint) => traits.toString(); + // Real guest captures from a React Navigation bottom tab bar: the active tab differs from the + // inactive tabs by exactly bit 3, and the active tab also carries the label. This pins the bit so + // a producer change cannot silently drop `selected:` matching the way the 0.21.0 bridge did. + const selectedTabTraits = 8858370057n; + const inactiveTabTraits = 8858370049n; + + assert.equal(selected(word(buttonTrait | selectedTrait)), true); + assert.equal(selected(word(selectedTabTraits)), true, 'active tab reports selected'); + assert.equal(selected(word(inactiveTabTraits)), undefined, 'inactive tab omits selected'); + assert.equal(selected(word(buttonTrait)), undefined, 'unselected omits selected'); + assert.equal(selected(word(0n)), undefined, 'no selected bit omits selected'); + assert.equal( + selected(word(privateHighTrait | selectedTrait)), + true, + 'a word past double precision keeps bit 3', + ); + assert.equal(selected(word(privateHighTrait | 7n)), undefined, 'no carry into bit 3'); + assert.equal(selected(), undefined, 'no traits word leaves selected unknown'); + // The two facts are read from one word without interfering: bit 3 is selection, bit 8 is disabled. + const disabledSelected = word(selectedTabTraits | (1n << 8n)); + assert.equal(selected(disabledSelected), true); + assert.equal(enabled(disabledSelected), false); +}); + test('the bridge tree rejects unknown fields, invalid frames, and bounded overflows', () => { assert.throws( () => decodeSnapshotBridgeTree({ [children]: [], unknown: true }, { truncated: false }, limits), diff --git a/packages/platform-apple/src/snapshot-source/tree.ts b/packages/platform-apple/src/snapshot-source/tree.ts index 7cc9fcdd03..21265b3252 100644 --- a/packages/platform-apple/src/snapshot-source/tree.ts +++ b/packages/platform-apple/src/snapshot-source/tree.ts @@ -118,6 +118,14 @@ const NODE_KEYS = new Set(Object.values(ATTRIBUTE)); */ const NOT_ENABLED_TRAIT = 1n << 8n; +/** + * The selected-state trait the guest reader reports for a control the app marked selected — the + * active tab in a tab bar, a chosen segment, a checked row. The runner path answers + * `selected: true` for the same node, so the bridge derives the fact from this bit. A node that is + * not selected omits the field, matching the runner, which publishes `selected` only when true. + */ +const SELECTED_TRAIT = 1n << 3n; + /** * A WebKit page — Safari's, or a `WKWebView`'s — lives in a WebContent process and reaches UIKit's * tree as an `AXRemoteElement` under the web view, with its children in that other process. The @@ -220,7 +228,11 @@ function nodeFacts( const baseClass = optionalString(value[ATTRIBUTE.elementBaseType]); const automationType = optionalInteger(value[ATTRIBUTE.automationType]); const frame = frameFromGuest(value[ATTRIBUTE.frame]); - const enabled = enabledFromTraits(value[ATTRIBUTE.traits]); + const traits = traitsFromGuest(value[ATTRIBUTE.traits]); + const enabled = traits === undefined ? undefined : (traits & NOT_ENABLED_TRAIT) === 0n; + // Publishes `selected: true` only when the selected bit is set and omits it otherwise — the same + // shape the XCTest tree produces, so a `selected:` selector cannot tell the producers apart. + const selected = traits === undefined || (traits & SELECTED_TRAIT) === 0n ? undefined : true; return { index, ...(parentIndex === undefined ? {} : { parentIndex }), @@ -240,6 +252,7 @@ function nodeFacts( : {}), ...(frame ? { rect: frame } : {}), ...(enabled === undefined ? {} : { enabled }), + ...(selected === undefined ? {} : { selected }), depth, }; } @@ -342,13 +355,17 @@ function optionalScalar(value: unknown): string | undefined { return undefined; } -/** The guest sends the uint64 traits word as a decimal string so no bit is lost to a double. */ -function enabledFromTraits(value: unknown): boolean | undefined { +/** + * The guest sends the uint64 traits word as a decimal string so no bit is lost to a double. One + * parse feeds every trait fact the tree publishes — `enabled` and `selected` — so a malformed word + * fails the same way no matter which fact is read. + */ +function traitsFromGuest(value: unknown): bigint | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== 'string' || !/^\d{1,20}$/.test(value)) { throw snapshotSourceError('malformed-tree', 'traits-invalid'); } - return (BigInt(value) & NOT_ENABLED_TRAIT) === 0n; + return BigInt(value); } function optionalInteger(value: unknown): number | undefined {