Skip to content

Commit 236016e

Browse files
knchstthymikee
andauthored
fix(ios): support remote-hosted alerts on physical devices (#1232)
* fix(ios): probe remote-hosted system modals (AccessorySetupKit picker) when the springboard mirror yields no hittable actions * fix(ios): fail closed on host state, guard dismissal re-query, unit-test probe routing Addresses review on #1232: - Gate the remote-host probe to a foreground host (RemoteHostedSystemModalPolicy.isEligibleHostState); background/unknown hosts fail closed instead of substituting an unrelated action tree. - Wrap the alert-resolution fallback query in safeElementsQuery so a dismissed remote host raising kAXErrorServerNotFound is absorbed. - Extract routing/gating into RemoteHostedSystemModalPolicy and add simulator-free unit tests under AGENT_DEVICE_RUNNER_UNIT_TESTS. * refactor(ios): centralize blocking system modal resolution * fix(ios): bound alert dismissal rechecks * feat(ios): enable alerts on physical devices * fix(ios): bound alert system modal resolution * test(ios): add AccessorySetupKit picker fixture * fix(ios): validate remote-hosted system modal interactions * chore: keep pnpm checks non-interactive * fix(ios): share alert command deadline --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 392dc1c commit 236016e

29 files changed

Lines changed: 751 additions & 77 deletions

.github/workflows/ios.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ jobs:
6767
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \
6868
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testMissingBundleCommandInvalidatesCompleteCachedTargetState \
6969
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \
70+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertResolutionCannotBypassRequestedDeadline \
7071
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
7172
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
7273
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,94 @@
11
import XCTest
22

33
extension RunnerTests {
4+
enum RunnerAlertSource {
5+
case blockingSystemModal
6+
case appAlert
7+
case dismissPopup
8+
}
9+
410
struct RunnerAlert {
511
let root: XCUIElement
612
let ownerApp: XCUIApplication
713
let buttons: [XCUIElement]
14+
let source: RunnerAlertSource
15+
}
16+
17+
static let defaultAlertCommandTimeout: TimeInterval = 10
18+
19+
static func alertCommandTimeout(timeoutMs: Double?) -> TimeInterval {
20+
guard let timeoutMs, timeoutMs.isFinite else { return defaultAlertCommandTimeout }
21+
return max(0.001, timeoutMs / 1000)
822
}
923

10-
func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
24+
func resolveAlert(app activeApp: XCUIApplication, deadline: Date) -> RunnerAlert? {
25+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
26+
if let override = alertResolutionOverrideForTesting {
27+
return override(deadline)
28+
}
29+
#endif
1130
#if !os(macOS)
12-
if let systemModal = firstBlockingSystemModal(in: springboard) {
13-
return runnerAlert(root: systemModal, ownerApp: springboard)
31+
switch resolveBlockingSystemModal(deadline: deadline) {
32+
case .resolved(let modal):
33+
return runnerAlert(modal)
34+
case .unresolved:
35+
return nil
36+
case .absent:
37+
break
1438
}
1539
#endif
16-
if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
17-
return runnerAlert(root: alert, ownerApp: activeApp)
40+
// Guard the query: when a remote-hosted modal (e.g. the AccessorySetupKit
41+
// picker) was just dismissed, the dismissal re-check re-enters here with the
42+
// now-gone host as `activeApp`, and its `alerts` query raises
43+
// kAXErrorServerNotFound. safeElementsQuery absorbs it and reports no alert.
44+
if let alert = firstExistingElement(in: safeElementsQuery { activeApp.alerts.allElementsBoundByIndex }) {
45+
return runnerAlert(root: alert, ownerApp: activeApp, source: .appAlert)
1846
}
1947
if let popup = firstDismissPopupWindow(in: activeApp) {
20-
return runnerAlert(root: popup, ownerApp: activeApp)
48+
return runnerAlert(root: popup, ownerApp: activeApp, source: .dismissPopup)
2149
}
2250
return nil
2351
}
2452

25-
func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
53+
func handleAlert(_ alert: RunnerAlert, action: String, deadline: Date) -> Response {
2654
if action == "accept" || action == "dismiss" {
2755
guard let button = chooseAlertButton(alert.buttons, action: action) else {
2856
return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
2957
}
58+
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
59+
let actionButtonLabel = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
60+
let actionButtonFrame = button.frame
3061
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
3162
if let response = unsupportedResponse(for: outcome) {
3263
return response
3364
}
3465
sleepFor(0.2)
35-
if alertStillVisible(alert, actionButtonLabel: button.label) {
36-
let frame = button.frame
37-
if !frame.isNull && !frame.isEmpty {
38-
let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY)
66+
if alertStillVisible(
67+
in: alert.ownerApp,
68+
source: alert.source,
69+
previousTitle: previousTitle,
70+
actionButtonLabel: actionButtonLabel,
71+
deadline: deadline
72+
) {
73+
if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty {
74+
let coordinateOutcome = tapAt(
75+
app: alert.ownerApp,
76+
x: actionButtonFrame.midX,
77+
y: actionButtonFrame.midY
78+
)
3979
if let response = unsupportedResponse(for: coordinateOutcome) {
4080
return response
4181
}
4282
sleepFor(0.2)
4383
}
4484
}
45-
if alertStillVisible(alert, actionButtonLabel: button.label) {
85+
if alertStillVisible(
86+
in: alert.ownerApp,
87+
source: alert.source,
88+
previousTitle: previousTitle,
89+
actionButtonLabel: actionButtonLabel,
90+
deadline: deadline
91+
) {
4692
return Response(
4793
ok: false,
4894
error: ErrorPayload(
@@ -64,26 +110,79 @@ extension RunnerTests {
64110
)
65111
}
66112

67-
private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? {
113+
private func runnerAlert(_ modal: ResolvedBlockingSystemModal) -> RunnerAlert? {
114+
let buttons = modal.actions.filter { isEnabledElement($0) }
115+
guard !buttons.isEmpty else {
116+
return nil
117+
}
118+
return RunnerAlert(
119+
root: modal.root,
120+
ownerApp: modal.ownerApp,
121+
buttons: buttons,
122+
source: .blockingSystemModal
123+
)
124+
}
125+
126+
private func runnerAlert(
127+
root: XCUIElement,
128+
ownerApp: XCUIApplication,
129+
source: RunnerAlertSource
130+
) -> RunnerAlert? {
68131
let buttons = actionableElements(in: root).filter { isEnabledElement($0) }
69132
guard !buttons.isEmpty else {
70133
return nil
71134
}
72-
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
135+
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source)
73136
}
74137

75-
private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool {
76-
guard let current = resolveAlert(app: alert.ownerApp) else {
138+
private func alertStillVisible(
139+
in ownerApp: XCUIApplication,
140+
source: RunnerAlertSource,
141+
previousTitle: String,
142+
actionButtonLabel: String,
143+
deadline: Date
144+
) -> Bool {
145+
guard Date() < deadline,
146+
let current = resolveAlert(source: source, app: ownerApp, deadline: deadline)
147+
else {
77148
return false
78149
}
79-
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
80150
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
81151
if previousTitle == currentTitle {
82152
return true
83153
}
84-
let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines)
85154
return current.buttons.contains { button in
86-
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel
155+
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == actionButtonLabel
156+
}
157+
}
158+
159+
private func resolveAlert(
160+
source: RunnerAlertSource,
161+
app: XCUIApplication,
162+
deadline: Date
163+
) -> RunnerAlert? {
164+
switch source {
165+
case .blockingSystemModal:
166+
#if os(macOS)
167+
return nil
168+
#else
169+
guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) else {
170+
return nil
171+
}
172+
return runnerAlert(modal)
173+
#endif
174+
case .appAlert:
175+
guard let alert = firstExistingElement(
176+
in: safeElementsQuery { app.alerts.allElementsBoundByIndex }
177+
) else {
178+
return nil
179+
}
180+
return runnerAlert(root: alert, ownerApp: app, source: .appAlert)
181+
case .dismissPopup:
182+
guard let popup = firstDismissPopupWindow(in: app) else {
183+
return nil
184+
}
185+
return runnerAlert(root: popup, ownerApp: app, source: .dismissPopup)
87186
}
88187
}
89188

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import XCTest
2+
3+
extension RunnerTests {
4+
struct ResolvedBlockingSystemModal {
5+
let root: XCUIElement
6+
let ownerApp: XCUIApplication
7+
let actions: [XCUIElement]
8+
}
9+
10+
enum BlockingSystemModalResolution {
11+
case absent
12+
case unresolved
13+
case resolved(ResolvedBlockingSystemModal)
14+
}
15+
16+
func resolveBlockingSystemModal(
17+
deadline: Date
18+
) -> BlockingSystemModalResolution {
19+
guard let springboardModal = firstBlockingSystemModal(
20+
in: springboard,
21+
deadline: deadline
22+
) else {
23+
return .absent
24+
}
25+
26+
let springboardActions = actionableElements(in: springboardModal)
27+
if !RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(
28+
springboardActionCount: springboardActions.count
29+
) {
30+
return .resolved(
31+
ResolvedBlockingSystemModal(
32+
root: springboardModal,
33+
ownerApp: springboard,
34+
actions: springboardActions
35+
)
36+
)
37+
}
38+
39+
guard Date() < deadline, let remoteModal = remoteHostedSystemModal(deadline: deadline) else {
40+
return .unresolved
41+
}
42+
return .resolved(remoteModal)
43+
}
44+
45+
// AccessorySetupUI mirrors into SpringBoard, but its mirrored elements are not
46+
// reliably hittable. Query the host directly; activating it breaks the picker.
47+
private static let remoteSystemModalHostBundleIds = [
48+
"com.apple.AccessorySetupUI"
49+
]
50+
51+
private func remoteHostedSystemModal(deadline: Date) -> ResolvedBlockingSystemModal? {
52+
for bundleId in Self.remoteSystemModalHostBundleIds {
53+
guard Date() < deadline else { return nil }
54+
let host = XCUIApplication(bundleIdentifier: bundleId)
55+
let state = safely("REMOTE_MODAL_STATE", XCUIApplication.State.unknown) { host.state }
56+
guard RemoteHostedSystemModalPolicy.isEligibleHostState(state) else { continue }
57+
guard Date() < deadline else { return nil }
58+
59+
// A dismissed remote host can raise kAXErrorServerNotFound. The safe queries
60+
// inside actionableElements turn that disappearance into an empty result.
61+
let actions = actionableElements(in: host)
62+
guard !actions.isEmpty else { continue }
63+
return ResolvedBlockingSystemModal(root: host, ownerApp: host, actions: actions)
64+
}
65+
return nil
66+
}
67+
}
68+
69+
enum RemoteHostedSystemModalPolicy {
70+
static func shouldProbeRemoteHost(springboardActionCount: Int) -> Bool {
71+
springboardActionCount == 0
72+
}
73+
74+
static func isEligibleHostState(_ state: XCUIApplication.State) -> Bool {
75+
state == .runningForeground
76+
}
77+
}
78+
79+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
80+
extension RunnerTests {
81+
func testRemoteHostProbeRunsOnlyWhenSpringboardModalHasNoActions() {
82+
XCTAssertTrue(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 0))
83+
XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 1))
84+
XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 3))
85+
}
86+
87+
func testRemoteHostStateGateFailsClosedToForeground() {
88+
XCTAssertTrue(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningForeground))
89+
XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningBackground))
90+
XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.notRunning))
91+
XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.unknown))
92+
}
93+
}
94+
#endif

0 commit comments

Comments
 (0)