diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index 434b9f9f0e..455d0dd4dc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -417,13 +417,27 @@ extension RunnerTests { } private func snapshotHasFocus(_ snapshot: XCUIElementSnapshot) -> Bool { - var focused = false + return focusBool(snapshot as! NSObject) + } + + /// 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 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") + } + + private func kvcBool(_ object: NSObject, 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 = object.value(forKey: key) as? Bool { + result = value } }) - return focused + return result } private func snapshotIsSelected(_ snapshot: XCUIElementSnapshot) -> Bool { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift index 677f30b39b..b768656c87 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift @@ -52,13 +52,7 @@ extension RunnerTests { } func elementHasFocus(_ element: XCUIElement) -> Bool { - var focused = false - _ = RunnerObjCExceptionCatcher.catchException({ - if let value = (element as NSObject).value(forKey: "hasFocus") as? Bool { - focused = value - } - }) - return focused + return focusBool(element as NSObject) } func activateElement( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift index 3cdcc89651..1715151a00 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TvRemoteTests.swift @@ -21,5 +21,48 @@ 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" + ) + 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") + } +} + +/// 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 +} + +private final class NativeFocusOnlyFixture: NSObject { + @objc let hasFocus: NSNumber = true } #endif