Skip to content

Commit 044e149

Browse files
authored
fix(ios): report keyboard focus from the AX bridge's is-editing trait (#3163)
The simulator AX bridge published focused only from hasFocus, so a focused TextField or TextEditor never read as focused. Derive it from traits bit 21, the word XCTest maps hasKeyboardFocus to.
1 parent 9a245d0 commit 044e149

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

‎packages/platform-apple/src/snapshot-source/tree.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,31 @@ test('the bridge tree reads selected from the selected trait, matching the XCTes
325325
assert.equal(enabled(disabledSelected), false);
326326
});
327327

328+
test('the bridge tree reads keyboard focus from the is-editing trait, matching the XCTest tree', () => {
329+
const editor = (word?: unknown) => ({
330+
[automationType]: 57,
331+
[label]: 'Notes',
332+
[frame]: { X: 16, Y: 184, Width: 370, Height: 150 },
333+
...(word === undefined ? {} : { [traits]: word }),
334+
[children]: [],
335+
});
336+
const focused = (word?: unknown) =>
337+
decodeSnapshotBridgeTree(
338+
{ [application]: 'Application', [children]: [editor(word)] },
339+
{ truncated: false },
340+
limits,
341+
).nodes[1]?.focused;
342+
343+
// Real guest captures of a SwiftUI TextEditor on an iOS 27 simulator: the word gains exactly
344+
// bit 21 when the editor takes keyboard focus and loses it when another field takes it.
345+
const focusedEditorTraits = 140883519602688n;
346+
const unfocusedEditorTraits = 140883517505536n;
347+
assert.equal(focusedEditorTraits ^ unfocusedEditorTraits, 1n << 21n);
348+
assert.equal(focused(focusedEditorTraits.toString()), true);
349+
assert.equal(focused(unfocusedEditorTraits.toString()), undefined, 'unfocused omits focused');
350+
assert.equal(focused(), undefined, 'no traits word leaves focused unknown');
351+
});
352+
328353
test('the bridge tree rejects unknown fields, invalid frames, and bounded overflows', () => {
329354
assert.throws(
330355
() => decodeSnapshotBridgeTree({ [children]: [], unknown: true }, { truncated: false }, limits),

‎packages/platform-apple/src/snapshot-source/tree.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ const NOT_ENABLED_TRAIT = 1n << 8n;
132132
*/
133133
const SELECTED_TRAIT = 1n << 3n;
134134

135+
/**
136+
* The private is-editing trait UIKit sets on the text input that holds keyboard focus. XCTest
137+
* derives `hasKeyboardFocus` from this word, and the runner path answers `focused: true` from that
138+
* key, so the bridge derives the fact from this bit. Like `selected`, it is published only when set.
139+
*/
140+
const KEYBOARD_FOCUS_TRAIT = 1n << 21n;
141+
135142
/**
136143
* A WebKit page — Safari's, or a `WKWebView`'s — lives in a WebContent process and reaches UIKit's
137144
* tree as an `AXRemoteElement` under the web view, with its children in that other process. The
@@ -247,6 +254,7 @@ function nodeFacts(
247254
// Publishes `selected: true` only when the selected bit is set and omits it otherwise — the same
248255
// shape the XCTest tree produces, so a `selected:` selector cannot tell the producers apart.
249256
const selected = traits === undefined || (traits & SELECTED_TRAIT) === 0n ? undefined : true;
257+
const focused = traits === undefined || (traits & KEYBOARD_FOCUS_TRAIT) === 0n ? undefined : true;
250258
const userInteractionEnabled = optionalBoolean(value[ATTRIBUTE.userInteractionEnabled]);
251259
// Trimmed like the runner's `placeholderText`: a whitespace placeholder is no placeholder.
252260
const placeholder = optionalString(value[ATTRIBUTE.placeholder])?.trim();
@@ -271,6 +279,7 @@ function nodeFacts(
271279
...(frame ? { rect: frame } : {}),
272280
...(enabled === undefined ? {} : { enabled }),
273281
...(selected === undefined ? {} : { selected }),
282+
...(focused === undefined ? {} : { focused }),
274283
...(userInteractionEnabled === undefined ? {} : { userInteractionEnabled }),
275284
depth,
276285
};
@@ -391,7 +400,7 @@ function optionalScalar(value: unknown): string | undefined {
391400

392401
/**
393402
* The guest sends the uint64 traits word as a decimal string so no bit is lost to a double. One
394-
* parse feeds every trait fact the tree publishes — `enabled` and `selected` — so a malformed word
403+
* parse feeds every trait fact the tree publishes — `enabled`, `selected`, and `focused` — so a malformed word
395404
* fails the same way no matter which fact is read.
396405
*/
397406
function traitsFromGuest(value: unknown): bigint | undefined {

0 commit comments

Comments
 (0)