From c5af95e93257ed798c2fdbdd1113e2d6680407ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 24 Sep 2026 18:51:49 +0200 Subject: [PATCH 1/7] fix(ios): a text field under the software keyboard reports focused in the snapshot Both snapshot producers read hasFocus, the focus engine's attribute (tvOS, keyboard navigation), so the field a software keyboard was typing into never reported focused. Keyboard focus is hasKeyboardFocus, which the text-entry readiness check already consults; the XCTest producer and the AX bridge now report either as focused. --- .../RunnerAXSnapshotBridge.m | 7 ++++++- .../RunnerTests+SnapshotAcquisition.swift | 15 +++++++++++---- .../RunnerTests+AXRecoveryConformanceTests.swift | 1 + 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m index 243c280523..41e46cd9c9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m @@ -369,6 +369,7 @@ + (NSArray *)snapshotAttributes @"enabled", @"selected", @"hasFocus", + @"hasKeyboardFocus", @"children", ]; // The AX server expects real accessibility attribute identifiers, not snapshot keypath @@ -767,7 +768,11 @@ + (nullable NSMutableDictionary *)dictionaryForSnapshot:(id)snapshot result[@"frame"] = [self frameValueForSnapshot:snapshot]; result[@"enabled"] = [self boolNumberForKey:@"enabled" snapshot:snapshot defaultValue:YES]; result[@"selected"] = [self boolNumberForKey:@"selected" snapshot:snapshot defaultValue:NO]; - result[@"focused"] = [self boolNumberForKey:@"hasFocus" snapshot:snapshot defaultValue:NO]; + // Either focus is focus: `hasFocus` is the focus engine's, `hasKeyboardFocus` the text field's + // under a software keyboard (the XCTest producer reads the same pair). + result[@"focused"] = @( + [[self boolNumberForKey:@"hasKeyboardFocus" snapshot:snapshot defaultValue:NO] boolValue] + || [[self boolNumberForKey:@"hasFocus" snapshot:snapshot defaultValue:NO] boolValue]); NSMutableArray *children = [NSMutableArray array]; if (depth < maxDepth) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index 434b9f9f0e..341282e774 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -416,14 +416,21 @@ extension RunnerTests { return node } + // `hasFocus` is the focus engine's answer (tvOS, keyboard navigation); the field a software + // keyboard is typing into holds `hasKeyboardFocus` instead, which the text-entry preflight already + // consults. The snapshot reports either, so the field under the keyboard reads as focused. private func snapshotHasFocus(_ snapshot: XCUIElementSnapshot) -> Bool { - var focused = false + return snapshotBool(snapshot, forKey: "hasKeyboardFocus") || snapshotBool(snapshot, forKey: "hasFocus") + } + + private func snapshotBool(_ snapshot: XCUIElementSnapshot, forKey key: String) -> Bool { + var result = false _ = RunnerObjCExceptionCatcher.catchException({ - if let value = (snapshot as! NSObject).value(forKey: "hasFocus") as? Bool { - focused = value + if let value = (snapshot as! NSObject).value(forKey: key) as? Bool { + result = value } }) - return focused + return result } private func snapshotIsSelected(_ snapshot: XCUIElementSnapshot) -> Bool { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift index cde158367b..fa49faa6a3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift @@ -154,6 +154,7 @@ private final class AXFixtureSnapshot: NSObject { @objc let enabled: NSNumber = true @objc let selected: NSNumber = false @objc let hasFocus: NSNumber = false + @objc let hasKeyboardFocus: NSNumber = false @objc let children: [AXFixtureSnapshot] @objc let accessibilityElement: AXFixtureElement? From 6ae3edada71d0a382219db602bd168253e5ae812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 24 Sep 2026 19:21:32 +0200 Subject: [PATCH 2/7] fix(ios): the element focus read and the bridge fixture cover keyboard focus too The query-sweep producer and the collapsed-tab nodes read focus through elementHasFocus, which still asked for hasFocus alone; it now reads the same pair as the snapshot producers. The AX conformance fixture can hold keyboard focus on one node, and a test proves the bridge asks for the attribute and reports that node focused. --- .../RunnerTests+TvRemote.swift | 14 ++++-- ...nnerTests+AXRecoveryConformanceTests.swift | 48 +++++++++++++++---- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift index 677f30b39b..d594e3abe2 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift @@ -51,14 +51,20 @@ extension RunnerTests { return TvRemoteButton(rawValue: raw.lowercased()) } + // Either focus is focus, the same pair the snapshot producers read: `hasFocus` is the focus + // engine's answer, `hasKeyboardFocus` the text field's under a software keyboard. func elementHasFocus(_ element: XCUIElement) -> Bool { - var focused = false + return elementBool(element, forKey: "hasKeyboardFocus") || elementBool(element, forKey: "hasFocus") + } + + private func elementBool(_ element: XCUIElement, forKey key: String) -> Bool { + var result = false _ = RunnerObjCExceptionCatcher.catchException({ - if let value = (element as NSObject).value(forKey: "hasFocus") as? Bool { - focused = value + if let value = (element as NSObject).value(forKey: key) as? Bool { + result = value } }) - return focused + return result } func activateElement( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift index fa49faa6a3..f12e6c6abd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift @@ -154,28 +154,35 @@ private final class AXFixtureSnapshot: NSObject { @objc let enabled: NSNumber = true @objc let selected: NSNumber = false @objc let hasFocus: NSNumber = false - @objc let hasKeyboardFocus: NSNumber = false + @objc let hasKeyboardFocus: NSNumber @objc let children: [AXFixtureSnapshot] @objc let accessibilityElement: AXFixtureElement? - init(node: AXFixtureNode, children: [AXFixtureSnapshot], element: AXFixtureElement?) { + init(node: AXFixtureNode, children: [AXFixtureSnapshot], element: AXFixtureElement?, keyboardFocus: Bool = false) { identifier = node.identity label = node.identity + hasKeyboardFocus = NSNumber(value: keyboardFocus) self.children = children accessibilityElement = element } /// `levels` node levels rooted at `node`; the deepest returned level loses its live element - /// when the fixture says the frontier vanished. - static func fragment(_ node: AXFixtureNode, levels: Int, vanishAtFrontier: Bool) -> AXFixtureSnapshot { + /// when the fixture says the frontier vanished. The node named by `keyboardFocusIdentity` holds + /// the software keyboard's focus, the way a text field being typed into does. + static func fragment( + _ node: AXFixtureNode, levels: Int, vanishAtFrontier: Bool, keyboardFocusIdentity: String? = nil + ) -> AXFixtureSnapshot { let boundary = levels <= 1 let children = boundary ? [] - : node.children.map { fragment($0, levels: levels - 1, vanishAtFrontier: vanishAtFrontier) } + : node.children.map { + fragment($0, levels: levels - 1, vanishAtFrontier: vanishAtFrontier, keyboardFocusIdentity: keyboardFocusIdentity) + } return AXFixtureSnapshot( node: node, children: children, - element: boundary && vanishAtFrontier ? nil : AXFixtureElement(node: node)) + element: boundary && vanishAtFrontier ? nil : AXFixtureElement(node: node), + keyboardFocus: node.identity == keyboardFocusIdentity) } } @@ -184,12 +191,14 @@ private final class AXFixtureSnapshot: NSObject { private final class AXFixtureClient: NSObject { private let rejectLevelsAbove: Int? private let vanishAtFrontier: Bool + private let keyboardFocusIdentity: String? private(set) var requests = 0 private(set) var rejected = 0 - init(rejectLevelsAbove: Int?, vanishAtFrontier: Bool) { + init(rejectLevelsAbove: Int?, vanishAtFrontier: Bool, keyboardFocusIdentity: String? = nil) { self.rejectLevelsAbove = rejectLevelsAbove self.vanishAtFrontier = vanishAtFrontier + self.keyboardFocusIdentity = keyboardFocusIdentity } @objc(requestSnapshotForElement:attributes:parameters:error:) @@ -206,7 +215,8 @@ private final class AXFixtureClient: NSObject { return nil } guard let element = element as? AXFixtureElement else { return nil } - return AXFixtureSnapshot.fragment(element.node, levels: levels, vanishAtFrontier: vanishAtFrontier) + return AXFixtureSnapshot.fragment( + element.node, levels: levels, vanishAtFrontier: vanishAtFrontier, keyboardFocusIdentity: keyboardFocusIdentity) } } @@ -365,6 +375,28 @@ extension RunnerTests { return observation } + /// The bridge asks the AX server for `hasKeyboardFocus` beside `hasFocus` and reports either as + /// `focused`: the field a software keyboard is typing into holds keyboard focus only, and a + /// capture that read `hasFocus` alone left it unfocused. + func testPrivateAXBridgeReportsKeyboardFocusAsFocused() throws { + let rootNode = AXFixtureNode.build(AXRecoveryFixture.Tree(chain: 2, fan: nil)) + let client = AXFixtureClient(rejectLevelsAbove: nil, vanishAtFrontier: false, keyboardFocusIdentity: "1") + let response = RunnerAXSnapshotBridge.snapshotTree( + withClient: client, + target: AXFixtureElement(node: rootNode), + maxDepth: 2, + maxNodes: 10, + deepExtensionCallLimit: 0, + customActionLimit: 0, + deadline: .distantFuture) + let root = try XCTUnwrap(response["root"] as? [String: Any]) + let field = try XCTUnwrap((root["children"] as? [[String: Any]])?.first) + XCTAssertEqual(root["label"] as? String, "0") + XCTAssertEqual(root["focused"] as? Bool, false, "no focus of either kind is not focused") + XCTAssertEqual(field["label"] as? String, "1") + XCTAssertEqual(field["focused"] as? Bool, true, "keyboard focus alone is focused") + } + /// Every recovery case of the shared fixture, replayed through the real ladder, bridge /// capture, frontier extension, and completeness verdict, down to the delivered tree. func testPrivateAXRecoveryMatchesSharedFixture() throws { From 1dc738602b8adeb35843072e7d7c180796fded68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 24 Sep 2026 19:32:37 +0200 Subject: [PATCH 3/7] test(ios): the bridge fixture records the attributes the bridge requests, and the keyboard-focus test asserts it asked for keyboard focus --- .../RunnerTests+AXRecoveryConformanceTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift index f12e6c6abd..4d82cffe86 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift @@ -194,6 +194,8 @@ private final class AXFixtureClient: NSObject { private let keyboardFocusIdentity: String? private(set) var requests = 0 private(set) var rejected = 0 + /// Every attribute the bridge asked for, across requests: the request contract under test. + private(set) var requestedAttributes: [String] = [] init(rejectLevelsAbove: Int?, vanishAtFrontier: Bool, keyboardFocusIdentity: String? = nil) { self.rejectLevelsAbove = rejectLevelsAbove @@ -206,6 +208,7 @@ private final class AXFixtureClient: NSObject { forElement element: Any, attributes: Any, parameters: [String: Any], error: NSErrorPointer ) -> Any? { requests += 1 + requestedAttributes.append(contentsOf: (attributes as? [Any] ?? []).compactMap { $0 as? String }) let levels = (parameters["maxDepth"] as? NSNumber)?.intValue ?? 0 if let limit = rejectLevelsAbove, levels > limit { rejected += 1 @@ -395,6 +398,15 @@ extension RunnerTests { XCTAssertEqual(root["focused"] as? Bool, false, "no focus of either kind is not focused") XCTAssertEqual(field["label"] as? String, "1") XCTAssertEqual(field["focused"] as? Bool, true, "keyboard focus alone is focused") + // The AX server answers only what it was asked for: the request must name keyboard focus, as + // the snapshot keypath or as the AX attribute XCElementSnapshot maps it to, or a real capture + // would come back without it and read false above. + XCTAssertTrue( + client.requestedAttributes.contains { $0.range(of: "keyboardfocus", options: .caseInsensitive) != nil }, + "requested attributes name keyboard focus: \(client.requestedAttributes)") + XCTAssertTrue( + client.requestedAttributes.contains { $0.range(of: "hasfocus", options: .caseInsensitive) != nil }, + "requested attributes still name the focus engine's focus: \(client.requestedAttributes)") } /// Every recovery case of the shared fixture, replayed through the real ladder, bridge From d6b8bd9c218da49a77a6ea3521d1d273f2f3f099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 24 Sep 2026 21:01:13 +0200 Subject: [PATCH 4/7] test(ios): the bridge test asks for what the AX server can give XCTAutomationSupport declares HasNativeFocus and FocusedApplications and no keyboard-focus attribute, so the mapper drops the hasKeyboardFocus keypath and the private-AX fallback reports the focus engine's focus alone; the XCTest producers carry keyboard focus. The test now asserts the request names the native focus and that a snapshot arriving with keyboard focus reads as focused, and the bridge comment says why the keypath stays. --- .../RunnerAXSnapshotBridge.m | 3 +++ ...nnerTests+AXRecoveryConformanceTests.swift | 19 ++++++++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m index 41e46cd9c9..41ce01b16c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m @@ -369,6 +369,9 @@ + (NSArray *)snapshotAttributes @"enabled", @"selected", @"hasFocus", + // The AX server has no keyboard-focus attribute (XCTAutomationSupport declares HasNativeFocus + // and FocusedApplications only), so the mapper drops this keypath today and this backend reports + // the focus engine's focus alone; the keypath stays so a mapper that learns it needs no change. @"hasKeyboardFocus", @"children", ]; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift index 4d82cffe86..08c8cb10f3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift @@ -378,9 +378,10 @@ extension RunnerTests { return observation } - /// The bridge asks the AX server for `hasKeyboardFocus` beside `hasFocus` and reports either as - /// `focused`: the field a software keyboard is typing into holds keyboard focus only, and a - /// capture that read `hasFocus` alone left it unfocused. + /// The bridge reports keyboard focus as `focused` when the snapshot carries it, beside the focus + /// engine's focus it asks the AX server for. The server exposes no keyboard-focus attribute, so + /// the request names the native focus only; a snapshot that arrives with keyboard focus set (the + /// XCTest producers' case) still reads as focused through the shared OR. func testPrivateAXBridgeReportsKeyboardFocusAsFocused() throws { let rootNode = AXFixtureNode.build(AXRecoveryFixture.Tree(chain: 2, fan: nil)) let client = AXFixtureClient(rejectLevelsAbove: nil, vanishAtFrontier: false, keyboardFocusIdentity: "1") @@ -398,15 +399,11 @@ extension RunnerTests { XCTAssertEqual(root["focused"] as? Bool, false, "no focus of either kind is not focused") XCTAssertEqual(field["label"] as? String, "1") XCTAssertEqual(field["focused"] as? Bool, true, "keyboard focus alone is focused") - // The AX server answers only what it was asked for: the request must name keyboard focus, as - // the snapshot keypath or as the AX attribute XCElementSnapshot maps it to, or a real capture - // would come back without it and read false above. + // The AX server answers only what it was asked for, so the request must still name the focus + // engine's focus: the raw keypath when the mapper is absent, `HasNativeFocus` when it maps. XCTAssertTrue( - client.requestedAttributes.contains { $0.range(of: "keyboardfocus", options: .caseInsensitive) != nil }, - "requested attributes name keyboard focus: \(client.requestedAttributes)") - XCTAssertTrue( - client.requestedAttributes.contains { $0.range(of: "hasfocus", options: .caseInsensitive) != nil }, - "requested attributes still name the focus engine's focus: \(client.requestedAttributes)") + client.requestedAttributes.contains { $0.range(of: "focus", options: .caseInsensitive) != nil }, + "requested attributes name the focus engine's focus: \(client.requestedAttributes)") } /// Every recovery case of the shared fixture, replayed through the real ladder, bridge From 2ff4dcfbe0c71c256e7148be6ad2d29695a46266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Fri, 25 Sep 2026 10:07:25 +0200 Subject: [PATCH 5/7] fix(ios): one focus reader behind both snapshot producers, tested where the fix is The private-AX bridge cannot see keyboard focus (the AX server declares no such attribute), so its OR and the extra keypath were code for a state that cannot occur; they are gone, and the bridge is what it was on main. The two reads that carry the fix, snapshotHasFocus and elementHasFocus, now share one focusBool reader (hasKeyboardFocus, else hasFocus, through KVC), and a host-lane test drives that reader from fixtures carrying only keyboard focus, only native focus, neither, and no key at all. --- .../RunnerAXSnapshotBridge.m | 10 +--- .../RunnerTests+SnapshotAcquisition.swift | 17 ++++-- .../RunnerTests+TvRemote.swift | 14 +---- ...nnerTests+AXRecoveryConformanceTests.swift | 56 +++---------------- .../UnitTests/RunnerTests+TvRemoteTests.swift | 35 ++++++++++++ 5 files changed, 55 insertions(+), 77 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m index 41ce01b16c..243c280523 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m @@ -369,10 +369,6 @@ + (NSArray *)snapshotAttributes @"enabled", @"selected", @"hasFocus", - // The AX server has no keyboard-focus attribute (XCTAutomationSupport declares HasNativeFocus - // and FocusedApplications only), so the mapper drops this keypath today and this backend reports - // the focus engine's focus alone; the keypath stays so a mapper that learns it needs no change. - @"hasKeyboardFocus", @"children", ]; // The AX server expects real accessibility attribute identifiers, not snapshot keypath @@ -771,11 +767,7 @@ + (nullable NSMutableDictionary *)dictionaryForSnapshot:(id)snapshot result[@"frame"] = [self frameValueForSnapshot:snapshot]; result[@"enabled"] = [self boolNumberForKey:@"enabled" snapshot:snapshot defaultValue:YES]; result[@"selected"] = [self boolNumberForKey:@"selected" snapshot:snapshot defaultValue:NO]; - // Either focus is focus: `hasFocus` is the focus engine's, `hasKeyboardFocus` the text field's - // under a software keyboard (the XCTest producer reads the same pair). - result[@"focused"] = @( - [[self boolNumberForKey:@"hasKeyboardFocus" snapshot:snapshot defaultValue:NO] boolValue] - || [[self boolNumberForKey:@"hasFocus" snapshot:snapshot defaultValue:NO] boolValue]); + result[@"focused"] = [self boolNumberForKey:@"hasFocus" snapshot:snapshot defaultValue:NO]; NSMutableArray *children = [NSMutableArray array]; if (depth < maxDepth) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index 341282e774..fe9d2eed72 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -416,17 +416,22 @@ extension RunnerTests { return node } - // `hasFocus` is the focus engine's answer (tvOS, keyboard navigation); the field a software - // keyboard is typing into holds `hasKeyboardFocus` instead, which the text-entry preflight already - // consults. The snapshot reports either, so the field under the keyboard reads as focused. private func snapshotHasFocus(_ snapshot: XCUIElementSnapshot) -> Bool { - return snapshotBool(snapshot, forKey: "hasKeyboardFocus") || snapshotBool(snapshot, forKey: "hasFocus") + return focusBool(snapshot as! NSObject) } - private func snapshotBool(_ snapshot: XCUIElementSnapshot, forKey key: String) -> Bool { + /// Either focus is focus. `hasFocus` is the focus engine's answer (tvOS, keyboard navigation); + /// the field a software keyboard is typing into holds `hasKeyboardFocus` instead, which the + /// text-entry readiness check already consults. Both an element and its snapshot answer the two + /// keys through KVC, so one reader serves every producer; a key the object lacks reads as false. + func focusBool(_ object: NSObject) -> Bool { + return kvcBool(object, forKey: "hasKeyboardFocus") || kvcBool(object, forKey: "hasFocus") + } + + private func kvcBool(_ object: NSObject, forKey key: String) -> Bool { var result = false _ = RunnerObjCExceptionCatcher.catchException({ - if let value = (snapshot as! NSObject).value(forKey: key) as? Bool { + if let value = object.value(forKey: key) as? Bool { result = value } }) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift index d594e3abe2..b768656c87 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift @@ -51,20 +51,8 @@ extension RunnerTests { return TvRemoteButton(rawValue: raw.lowercased()) } - // Either focus is focus, the same pair the snapshot producers read: `hasFocus` is the focus - // engine's answer, `hasKeyboardFocus` the text field's under a software keyboard. func elementHasFocus(_ element: XCUIElement) -> Bool { - return elementBool(element, forKey: "hasKeyboardFocus") || elementBool(element, forKey: "hasFocus") - } - - private func elementBool(_ element: XCUIElement, forKey key: String) -> Bool { - var result = false - _ = RunnerObjCExceptionCatcher.catchException({ - if let value = (element as NSObject).value(forKey: key) as? Bool { - result = value - } - }) - return result + return focusBool(element as NSObject) } func activateElement( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift index 08c8cb10f3..cde158367b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AXRecoveryConformanceTests.swift @@ -154,35 +154,27 @@ private final class AXFixtureSnapshot: NSObject { @objc let enabled: NSNumber = true @objc let selected: NSNumber = false @objc let hasFocus: NSNumber = false - @objc let hasKeyboardFocus: NSNumber @objc let children: [AXFixtureSnapshot] @objc let accessibilityElement: AXFixtureElement? - init(node: AXFixtureNode, children: [AXFixtureSnapshot], element: AXFixtureElement?, keyboardFocus: Bool = false) { + init(node: AXFixtureNode, children: [AXFixtureSnapshot], element: AXFixtureElement?) { identifier = node.identity label = node.identity - hasKeyboardFocus = NSNumber(value: keyboardFocus) self.children = children accessibilityElement = element } /// `levels` node levels rooted at `node`; the deepest returned level loses its live element - /// when the fixture says the frontier vanished. The node named by `keyboardFocusIdentity` holds - /// the software keyboard's focus, the way a text field being typed into does. - static func fragment( - _ node: AXFixtureNode, levels: Int, vanishAtFrontier: Bool, keyboardFocusIdentity: String? = nil - ) -> AXFixtureSnapshot { + /// when the fixture says the frontier vanished. + static func fragment(_ node: AXFixtureNode, levels: Int, vanishAtFrontier: Bool) -> AXFixtureSnapshot { let boundary = levels <= 1 let children = boundary ? [] - : node.children.map { - fragment($0, levels: levels - 1, vanishAtFrontier: vanishAtFrontier, keyboardFocusIdentity: keyboardFocusIdentity) - } + : node.children.map { fragment($0, levels: levels - 1, vanishAtFrontier: vanishAtFrontier) } return AXFixtureSnapshot( node: node, children: children, - element: boundary && vanishAtFrontier ? nil : AXFixtureElement(node: node), - keyboardFocus: node.identity == keyboardFocusIdentity) + element: boundary && vanishAtFrontier ? nil : AXFixtureElement(node: node)) } } @@ -191,16 +183,12 @@ private final class AXFixtureSnapshot: NSObject { private final class AXFixtureClient: NSObject { private let rejectLevelsAbove: Int? private let vanishAtFrontier: Bool - private let keyboardFocusIdentity: String? private(set) var requests = 0 private(set) var rejected = 0 - /// Every attribute the bridge asked for, across requests: the request contract under test. - private(set) var requestedAttributes: [String] = [] - init(rejectLevelsAbove: Int?, vanishAtFrontier: Bool, keyboardFocusIdentity: String? = nil) { + init(rejectLevelsAbove: Int?, vanishAtFrontier: Bool) { self.rejectLevelsAbove = rejectLevelsAbove self.vanishAtFrontier = vanishAtFrontier - self.keyboardFocusIdentity = keyboardFocusIdentity } @objc(requestSnapshotForElement:attributes:parameters:error:) @@ -208,7 +196,6 @@ private final class AXFixtureClient: NSObject { forElement element: Any, attributes: Any, parameters: [String: Any], error: NSErrorPointer ) -> Any? { requests += 1 - requestedAttributes.append(contentsOf: (attributes as? [Any] ?? []).compactMap { $0 as? String }) let levels = (parameters["maxDepth"] as? NSNumber)?.intValue ?? 0 if let limit = rejectLevelsAbove, levels > limit { rejected += 1 @@ -218,8 +205,7 @@ private final class AXFixtureClient: NSObject { return nil } guard let element = element as? AXFixtureElement else { return nil } - return AXFixtureSnapshot.fragment( - element.node, levels: levels, vanishAtFrontier: vanishAtFrontier, keyboardFocusIdentity: keyboardFocusIdentity) + return AXFixtureSnapshot.fragment(element.node, levels: levels, vanishAtFrontier: vanishAtFrontier) } } @@ -378,34 +364,6 @@ extension RunnerTests { return observation } - /// The bridge reports keyboard focus as `focused` when the snapshot carries it, beside the focus - /// engine's focus it asks the AX server for. The server exposes no keyboard-focus attribute, so - /// the request names the native focus only; a snapshot that arrives with keyboard focus set (the - /// XCTest producers' case) still reads as focused through the shared OR. - func testPrivateAXBridgeReportsKeyboardFocusAsFocused() throws { - let rootNode = AXFixtureNode.build(AXRecoveryFixture.Tree(chain: 2, fan: nil)) - let client = AXFixtureClient(rejectLevelsAbove: nil, vanishAtFrontier: false, keyboardFocusIdentity: "1") - let response = RunnerAXSnapshotBridge.snapshotTree( - withClient: client, - target: AXFixtureElement(node: rootNode), - maxDepth: 2, - maxNodes: 10, - deepExtensionCallLimit: 0, - customActionLimit: 0, - deadline: .distantFuture) - let root = try XCTUnwrap(response["root"] as? [String: Any]) - let field = try XCTUnwrap((root["children"] as? [[String: Any]])?.first) - XCTAssertEqual(root["label"] as? String, "0") - XCTAssertEqual(root["focused"] as? Bool, false, "no focus of either kind is not focused") - XCTAssertEqual(field["label"] as? String, "1") - XCTAssertEqual(field["focused"] as? Bool, true, "keyboard focus alone is focused") - // The AX server answers only what it was asked for, so the request must still name the focus - // engine's focus: the raw keypath when the mapper is absent, `HasNativeFocus` when it maps. - XCTAssertTrue( - client.requestedAttributes.contains { $0.range(of: "focus", options: .caseInsensitive) != nil }, - "requested attributes name the focus engine's focus: \(client.requestedAttributes)") - } - /// Every recovery case of the shared fixture, replayed through the real ladder, bridge /// capture, frontier extension, and completeness verdict, down to the delivered tree. func testPrivateAXRecoveryMatchesSharedFixture() throws { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift index 3cdcc89651..67957365e7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift @@ -21,5 +21,40 @@ extension RunnerTests { XCTAssertNil(tvRemoteButton(from: raw)) } } + + /// `focusBool` is the one reader behind `snapshotHasFocus` and `elementHasFocus`. A live element + /// or snapshot answers both keys through KVC; the fixtures answer the same way, so the read that + /// production performs is the read under test. + func testFocusBoolReadsKeyboardFocusBesideTheFocusEnginesFocus() { + XCTAssertTrue( + focusBool(FocusFixture(hasFocus: false, hasKeyboardFocus: true)), + "the field a software keyboard is typing into holds keyboard focus alone" + ) + XCTAssertTrue( + focusBool(FocusFixture(hasFocus: true, hasKeyboardFocus: false)), + "the focus engine's focus (tvOS, keyboard navigation) still counts" + ) + XCTAssertFalse(focusBool(FocusFixture(hasFocus: false, hasKeyboardFocus: false))) + XCTAssertTrue( + focusBool(KeyboardFocusOnlyFixture()), + "an object that exposes no hasFocus key at all still reports its keyboard focus" + ) + XCTAssertFalse(focusBool(NSObject()), "an object with neither key reads as unfocused, not as an exception") + } +} + +/// KVC-readable the way XCTest exposes an element's or a snapshot's focus attributes. +private final class FocusFixture: NSObject { + @objc let hasFocus: NSNumber + @objc let hasKeyboardFocus: NSNumber + + init(hasFocus: Bool, hasKeyboardFocus: Bool) { + self.hasFocus = NSNumber(value: hasFocus) + self.hasKeyboardFocus = NSNumber(value: hasKeyboardFocus) + } +} + +private final class KeyboardFocusOnlyFixture: NSObject { + @objc let hasKeyboardFocus: NSNumber = true } #endif From 279ccef37695210c942311fe2d9213f4d034a837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Fri, 25 Sep 2026 10:13:14 +0200 Subject: [PATCH 6/7] test(ios): the focus reader test covers an object exposing only the focus engine's key --- .../UnitTests/RunnerTests+TvRemoteTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift index 67957365e7..1715151a00 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift @@ -39,6 +39,10 @@ extension RunnerTests { focusBool(KeyboardFocusOnlyFixture()), "an object that exposes no hasFocus key at all still reports its keyboard focus" ) + XCTAssertTrue( + focusBool(NativeFocusOnlyFixture()), + "an object that exposes no hasKeyboardFocus key at all still reports the focus engine's focus" + ) XCTAssertFalse(focusBool(NSObject()), "an object with neither key reads as unfocused, not as an exception") } } @@ -57,4 +61,8 @@ private final class FocusFixture: NSObject { private final class KeyboardFocusOnlyFixture: NSObject { @objc let hasKeyboardFocus: NSNumber = true } + +private final class NativeFocusOnlyFixture: NSObject { + @objc let hasFocus: NSNumber = true +} #endif From c7dece5e4033638e268ca514ba7d803be03882b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Fri, 25 Sep 2026 10:28:00 +0200 Subject: [PATCH 7/7] docs(ios-runner): the focus reader comment names the producers it serves and the bridge it does not --- .../RunnerTests+SnapshotAcquisition.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index fe9d2eed72..455d0dd4dc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -423,7 +423,9 @@ extension RunnerTests { /// Either focus is focus. `hasFocus` is the focus engine's answer (tvOS, keyboard navigation); /// the field a software keyboard is typing into holds `hasKeyboardFocus` instead, which the /// text-entry readiness check already consults. Both an element and its snapshot answer the two - /// keys through KVC, so one reader serves every producer; a key the object lacks reads as false. + /// keys through KVC, so one reader serves the XCTest producers (recursive tree, query sweep, + /// collapsed tabs); a key the object lacks reads as false. The private-AX bridge is not among + /// them: the AX server declares no keyboard-focus attribute, so it reads `hasFocus` alone. func focusBool(_ object: NSObject) -> Bool { return kvcBool(object, forKey: "hasKeyboardFocus") || kvcBool(object, forKey: "hasFocus") }