From b3e4bc89d1e7919cce8790202d7b2d4a71c61422 Mon Sep 17 00:00:00 2001 From: Kenichi Saito Date: Mon, 13 Jul 2026 12:25:15 +0900 Subject: [PATCH 01/10] fix(ios): probe remote-hosted system modals (AccessorySetupKit picker) when the springboard mirror yields no hittable actions --- .../RunnerTests+Alert.swift | 19 +++++-- .../RunnerTests+SystemModal.swift | 49 ++++++++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index 7b4fdd38f..188a924be 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -10,7 +10,16 @@ extension RunnerTests { func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? { #if !os(macOS) if let systemModal = firstBlockingSystemModal(in: springboard) { - return runnerAlert(root: systemModal, ownerApp: springboard) + if let alert = runnerAlert(root: systemModal, ownerApp: springboard) { + return alert + } + // Springboard modal with no hittable actions: remote-hosted system UI + // (e.g. the AccessorySetupKit picker). Resolve it in the hosting process's + // own scope, where the elements are hittable and taps land. + if let remote = remoteHostedSystemModal() { + return runnerAlert(root: remote.host, ownerApp: remote.host, actions: remote.actions) + } + return nil } #endif if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) { @@ -64,8 +73,12 @@ extension RunnerTests { ) } - private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? { - let buttons = actionableElements(in: root).filter { isEnabledElement($0) } + private func runnerAlert( + root: XCUIElement, + ownerApp: XCUIApplication, + actions: [XCUIElement]? = nil + ) -> RunnerAlert? { + let buttons = (actions ?? actionableElements(in: root)).filter { isEnabledElement($0) } guard !buttons.isEmpty else { return nil } diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index 45db60cbe..537f5ee72 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -7,10 +7,18 @@ extension RunnerTests { #if os(macOS) return nil #else - guard let modal = firstBlockingSystemModal(in: springboard, deadline: deadline) else { + guard let springboardModal = firstBlockingSystemModal( + in: springboard, + deadline: deadline + ) else { return nil } - let actions = actionableElements(in: modal) + var modal = springboardModal + var actions = actionableElements(in: modal) + if actions.isEmpty, Date() < deadline, let remote = remoteHostedSystemModal() { + modal = remote.host + actions = remote.actions + } guard !actions.isEmpty else { return nil } @@ -100,6 +108,43 @@ extension RunnerTests { return nil } + // MARK: - Remote-Hosted System Modal Probe + + /// System UI hosted out-of-process — the AccessorySetupKit picker, hosted by + /// AccessorySetupUI.app on iOS 18+ — mirrors into `springboard.alerts`, but the + /// mirrored content does not reliably pass `isHittable` from the springboard scope, + /// so `actionableElements(in:)` can come back empty while the picker is on screen. + /// Querying the hosting process directly sees the real elements, and synthesized + /// taps on them work. Query only: activating the host tears the picker sheet into + /// a black full-screen state. + private static let remoteSystemModalHostBundleIds = [ + "com.apple.AccessorySetupUI" + ] + + struct RemoteHostedSystemModal { + let host: XCUIApplication + let actions: [XCUIElement] + } + + func remoteHostedSystemModal() -> RemoteHostedSystemModal? { + // CONSERVATIVE: Callers probe remote hosts only after a springboard modal was + // detected but yielded no hittable actions, so the common no-modal snapshot path + // never pays the cross-process query (~1s against a live host, exception-absorbed + // against a dead one). Revisit if a remote-hosted modal ever stops mirroring into + // springboard entirely. + for bundleId in Self.remoteSystemModalHostBundleIds { + let host = XCUIApplication(bundleIdentifier: bundleId) + let state = safely("REMOTE_MODAL_STATE", XCUIApplication.State.notRunning) { host.state } + guard state != .notRunning else { continue } + // A host that just dismissed its UI raises kAXErrorServerNotFound on query; + // safely(...) inside actionableElements absorbs it and yields no actions. + let actions = actionableElements(in: host) + guard !actions.isEmpty else { continue } + return RemoteHostedSystemModal(host: host, actions: actions) + } + return nil + } + func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] { safely("MODAL_QUERY", [], fetch) } From fc6497b4717ff392ebd3cb6346bcf3e5dea7f876 Mon Sep 17 00:00:00 2001 From: Kenichi Saito Date: Mon, 13 Jul 2026 14:48:31 +0900 Subject: [PATCH 02/10] 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. --- .../RunnerTests+Alert.swift | 6 ++- .../RunnerTests+SystemModal.swift | 49 +++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index 188a924be..f493324e2 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -22,7 +22,11 @@ extension RunnerTests { return nil } #endif - if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) { + // Guard the query: when a remote-hosted modal (e.g. the AccessorySetupKit + // picker) was just dismissed, the dismissal re-check re-enters here with the + // now-gone host as `activeApp`, and its `alerts` query raises + // kAXErrorServerNotFound. safeElementsQuery absorbs it and reports no alert. + if let alert = firstExistingElement(in: safeElementsQuery { activeApp.alerts.allElementsBoundByIndex }) { return runnerAlert(root: alert, ownerApp: activeApp) } if let popup = firstDismissPopupWindow(in: activeApp) { diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index 537f5ee72..0e8b0faa3 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -15,7 +15,9 @@ extension RunnerTests { } var modal = springboardModal var actions = actionableElements(in: modal) - if actions.isEmpty, Date() < deadline, let remote = remoteHostedSystemModal() { + if Date() < deadline, + RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: actions.count), + let remote = remoteHostedSystemModal() { modal = remote.host actions = remote.actions } @@ -134,8 +136,12 @@ extension RunnerTests { // springboard entirely. for bundleId in Self.remoteSystemModalHostBundleIds { let host = XCUIApplication(bundleIdentifier: bundleId) - let state = safely("REMOTE_MODAL_STATE", XCUIApplication.State.notRunning) { host.state } - guard state != .notRunning else { continue } + // Fall back to `.unknown` (an ineligible state) so a query that raises rather + // than returns is treated as "do not substitute", not as a runnable host. + let state = safely("REMOTE_MODAL_STATE", XCUIApplication.State.unknown) { host.state } + // Fail closed to a foreground host: a background/unknown host must not + // substitute its action tree for an unrelated unusable springboard modal. + guard RemoteHostedSystemModalPolicy.isEligibleHostState(state) else { continue } // A host that just dismissed its UI raises kAXErrorServerNotFound on query; // safely(...) inside actionableElements absorbs it and yields no actions. let actions = actionableElements(in: host) @@ -312,3 +318,40 @@ extension RunnerTests { } } } + +// Pure routing/gating rules for the remote-hosted system-modal probe, split out +// (like `SynthesizedFallbackPolicy`) so the fail-closed decisions can be proven +// against a simulator-free unit test under `AGENT_DEVICE_RUNNER_UNIT_TESTS`. +enum RemoteHostedSystemModalPolicy { + /// The remote-host probe runs only when a springboard modal was detected but + /// yielded no hittable actions — the mirror is present but unusable. When the + /// springboard modal already exposes actions, its own tree stays authoritative + /// and the cross-process probe is skipped. + static func shouldProbeRemoteHost(springboardActionCount: Int) -> Bool { + springboardActionCount == 0 + } + + /// Only a foreground host may substitute its action tree for an unusable + /// springboard modal. A background/suspended/not-running/unknown host fails + /// closed, so a stale or unrelated host process can never replace the modal tree. + static func isEligibleHostState(_ state: XCUIApplication.State) -> Bool { + state == .runningForeground + } +} + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + func testRemoteHostProbeRunsOnlyWhenSpringboardModalHasNoActions() { + XCTAssertTrue(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 0)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 1)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 3)) + } + + func testRemoteHostStateGateFailsClosedToForeground() { + XCTAssertTrue(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningForeground)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningBackground)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.notRunning)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.unknown)) + } +} +#endif From 321c75089436c0b830001516dc43912b6478cb7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 18:04:48 +0200 Subject: [PATCH 03/10] refactor(ios): centralize blocking system modal resolution --- .../RunnerTests+Alert.swift | 36 +++---- ...rTests+BlockingSystemModalResolution.swift | 94 ++++++++++++++++++ .../RunnerTests+SystemModal.swift | 96 +------------------ 3 files changed, 116 insertions(+), 110 deletions(-) create mode 100644 apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index f493324e2..e1f3fedb9 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -9,17 +9,13 @@ extension RunnerTests { func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? { #if !os(macOS) - if let systemModal = firstBlockingSystemModal(in: springboard) { - if let alert = runnerAlert(root: systemModal, ownerApp: springboard) { - return alert - } - // Springboard modal with no hittable actions: remote-hosted system UI - // (e.g. the AccessorySetupKit picker). Resolve it in the hosting process's - // own scope, where the elements are hittable and taps land. - if let remote = remoteHostedSystemModal() { - return runnerAlert(root: remote.host, ownerApp: remote.host, actions: remote.actions) - } + switch resolveBlockingSystemModal() { + case .resolved(let modal): + return runnerAlert(modal) + case .unresolved: return nil + case .absent: + break } #endif // Guard the query: when a remote-hosted modal (e.g. the AccessorySetupKit @@ -77,16 +73,22 @@ extension RunnerTests { ) } - private func runnerAlert( - root: XCUIElement, - ownerApp: XCUIApplication, - actions: [XCUIElement]? = nil - ) -> RunnerAlert? { - let buttons = (actions ?? actionableElements(in: root)).filter { isEnabledElement($0) } + private func runnerAlert(_ modal: ResolvedBlockingSystemModal) -> RunnerAlert? { + let buttons = modal.actions.filter { isEnabledElement($0) } guard !buttons.isEmpty else { return nil } - return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons) + return RunnerAlert(root: modal.root, ownerApp: modal.ownerApp, buttons: buttons) + } + + private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? { + runnerAlert( + ResolvedBlockingSystemModal( + root: root, + ownerApp: ownerApp, + actions: actionableElements(in: root) + ) + ) } private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool { diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift new file mode 100644 index 000000000..d1896b2ce --- /dev/null +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift @@ -0,0 +1,94 @@ +import XCTest + +extension RunnerTests { + struct ResolvedBlockingSystemModal { + let root: XCUIElement + let ownerApp: XCUIApplication + let actions: [XCUIElement] + } + + enum BlockingSystemModalResolution { + case absent + case unresolved + case resolved(ResolvedBlockingSystemModal) + } + + func resolveBlockingSystemModal( + deadline: Date = .distantFuture + ) -> BlockingSystemModalResolution { + guard let springboardModal = firstBlockingSystemModal( + in: springboard, + deadline: deadline + ) else { + return .absent + } + + let springboardActions = actionableElements(in: springboardModal) + if !RemoteHostedSystemModalPolicy.shouldProbeRemoteHost( + springboardActionCount: springboardActions.count + ) { + return .resolved( + ResolvedBlockingSystemModal( + root: springboardModal, + ownerApp: springboard, + actions: springboardActions + ) + ) + } + + guard Date() < deadline, let remoteModal = remoteHostedSystemModal(deadline: deadline) else { + return .unresolved + } + return .resolved(remoteModal) + } + + // AccessorySetupUI mirrors into SpringBoard, but its mirrored elements are not + // reliably hittable. Query the host directly; activating it breaks the picker. + private static let remoteSystemModalHostBundleIds = [ + "com.apple.AccessorySetupUI" + ] + + private func remoteHostedSystemModal(deadline: Date) -> ResolvedBlockingSystemModal? { + for bundleId in Self.remoteSystemModalHostBundleIds { + guard Date() < deadline else { return nil } + let host = XCUIApplication(bundleIdentifier: bundleId) + let state = safely("REMOTE_MODAL_STATE", XCUIApplication.State.unknown) { host.state } + guard RemoteHostedSystemModalPolicy.isEligibleHostState(state) else { continue } + guard Date() < deadline else { return nil } + + // A dismissed remote host can raise kAXErrorServerNotFound. The safe queries + // inside actionableElements turn that disappearance into an empty result. + let actions = actionableElements(in: host) + guard !actions.isEmpty else { continue } + return ResolvedBlockingSystemModal(root: host, ownerApp: host, actions: actions) + } + return nil + } +} + +enum RemoteHostedSystemModalPolicy { + static func shouldProbeRemoteHost(springboardActionCount: Int) -> Bool { + springboardActionCount == 0 + } + + static func isEligibleHostState(_ state: XCUIApplication.State) -> Bool { + state == .runningForeground + } +} + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + func testRemoteHostProbeRunsOnlyWhenSpringboardModalHasNoActions() { + XCTAssertTrue(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 0)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 1)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 3)) + } + + func testRemoteHostStateGateFailsClosedToForeground() { + XCTAssertTrue(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningForeground)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningBackground)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.notRunning)) + XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.unknown)) + } +} +#endif diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index 0e8b0faa3..4dfdcf542 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -7,23 +7,11 @@ extension RunnerTests { #if os(macOS) return nil #else - guard let springboardModal = firstBlockingSystemModal( - in: springboard, - deadline: deadline - ) else { - return nil - } - var modal = springboardModal - var actions = actionableElements(in: modal) - if Date() < deadline, - RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: actions.count), - let remote = remoteHostedSystemModal() { - modal = remote.host - actions = remote.actions - } - guard !actions.isEmpty else { + guard case .resolved(let resolvedModal) = resolveBlockingSystemModal(deadline: deadline) else { return nil } + let modal = resolvedModal.root + let actions = resolvedModal.actions let title = preferredSystemModalTitle(modal) guard let modalNode = safeMakeSnapshotNode( @@ -110,47 +98,6 @@ extension RunnerTests { return nil } - // MARK: - Remote-Hosted System Modal Probe - - /// System UI hosted out-of-process — the AccessorySetupKit picker, hosted by - /// AccessorySetupUI.app on iOS 18+ — mirrors into `springboard.alerts`, but the - /// mirrored content does not reliably pass `isHittable` from the springboard scope, - /// so `actionableElements(in:)` can come back empty while the picker is on screen. - /// Querying the hosting process directly sees the real elements, and synthesized - /// taps on them work. Query only: activating the host tears the picker sheet into - /// a black full-screen state. - private static let remoteSystemModalHostBundleIds = [ - "com.apple.AccessorySetupUI" - ] - - struct RemoteHostedSystemModal { - let host: XCUIApplication - let actions: [XCUIElement] - } - - func remoteHostedSystemModal() -> RemoteHostedSystemModal? { - // CONSERVATIVE: Callers probe remote hosts only after a springboard modal was - // detected but yielded no hittable actions, so the common no-modal snapshot path - // never pays the cross-process query (~1s against a live host, exception-absorbed - // against a dead one). Revisit if a remote-hosted modal ever stops mirroring into - // springboard entirely. - for bundleId in Self.remoteSystemModalHostBundleIds { - let host = XCUIApplication(bundleIdentifier: bundleId) - // Fall back to `.unknown` (an ineligible state) so a query that raises rather - // than returns is treated as "do not substitute", not as a runnable host. - let state = safely("REMOTE_MODAL_STATE", XCUIApplication.State.unknown) { host.state } - // Fail closed to a foreground host: a background/unknown host must not - // substitute its action tree for an unrelated unusable springboard modal. - guard RemoteHostedSystemModalPolicy.isEligibleHostState(state) else { continue } - // A host that just dismissed its UI raises kAXErrorServerNotFound on query; - // safely(...) inside actionableElements absorbs it and yields no actions. - let actions = actionableElements(in: host) - guard !actions.isEmpty else { continue } - return RemoteHostedSystemModal(host: host, actions: actions) - } - return nil - } - func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] { safely("MODAL_QUERY", [], fetch) } @@ -318,40 +265,3 @@ extension RunnerTests { } } } - -// Pure routing/gating rules for the remote-hosted system-modal probe, split out -// (like `SynthesizedFallbackPolicy`) so the fail-closed decisions can be proven -// against a simulator-free unit test under `AGENT_DEVICE_RUNNER_UNIT_TESTS`. -enum RemoteHostedSystemModalPolicy { - /// The remote-host probe runs only when a springboard modal was detected but - /// yielded no hittable actions — the mirror is present but unusable. When the - /// springboard modal already exposes actions, its own tree stays authoritative - /// and the cross-process probe is skipped. - static func shouldProbeRemoteHost(springboardActionCount: Int) -> Bool { - springboardActionCount == 0 - } - - /// Only a foreground host may substitute its action tree for an unusable - /// springboard modal. A background/suspended/not-running/unknown host fails - /// closed, so a stale or unrelated host process can never replace the modal tree. - static func isEligibleHostState(_ state: XCUIApplication.State) -> Bool { - state == .runningForeground - } -} - -#if AGENT_DEVICE_RUNNER_UNIT_TESTS -extension RunnerTests { - func testRemoteHostProbeRunsOnlyWhenSpringboardModalHasNoActions() { - XCTAssertTrue(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 0)) - XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 1)) - XCTAssertFalse(RemoteHostedSystemModalPolicy.shouldProbeRemoteHost(springboardActionCount: 3)) - } - - func testRemoteHostStateGateFailsClosedToForeground() { - XCTAssertTrue(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningForeground)) - XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.runningBackground)) - XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.notRunning)) - XCTAssertFalse(RemoteHostedSystemModalPolicy.isEligibleHostState(.unknown)) - } -} -#endif From 4c01d2aac3f2fe0d7c5f3418c79c90578d78474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 19:11:30 +0200 Subject: [PATCH 04/10] fix(ios): bound alert dismissal rechecks --- .../RunnerTests+Alert.swift | 101 ++++++++++++++---- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index e1f3fedb9..a6fd9a9ce 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -1,10 +1,17 @@ import XCTest extension RunnerTests { + enum RunnerAlertSource { + case blockingSystemModal + case appAlert + case dismissPopup + } + struct RunnerAlert { let root: XCUIElement let ownerApp: XCUIApplication let buttons: [XCUIElement] + let source: RunnerAlertSource } func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? { @@ -23,10 +30,10 @@ extension RunnerTests { // now-gone host as `activeApp`, and its `alerts` query raises // kAXErrorServerNotFound. safeElementsQuery absorbs it and reports no alert. if let alert = firstExistingElement(in: safeElementsQuery { activeApp.alerts.allElementsBoundByIndex }) { - return runnerAlert(root: alert, ownerApp: activeApp) + return runnerAlert(root: alert, ownerApp: activeApp, source: .appAlert) } if let popup = firstDismissPopupWindow(in: activeApp) { - return runnerAlert(root: popup, ownerApp: activeApp) + return runnerAlert(root: popup, ownerApp: activeApp, source: .dismissPopup) } return nil } @@ -36,22 +43,38 @@ extension RunnerTests { guard let button = chooseAlertButton(alert.buttons, action: action) else { return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found")) } + let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons) + let actionButtonLabel = button.label.trimmingCharacters(in: .whitespacesAndNewlines) + let actionButtonFrame = button.frame let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)") if let response = unsupportedResponse(for: outcome) { return response } sleepFor(0.2) - if alertStillVisible(alert, actionButtonLabel: button.label) { - let frame = button.frame - if !frame.isNull && !frame.isEmpty { - let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY) + if alertStillVisible( + in: alert.ownerApp, + source: alert.source, + previousTitle: previousTitle, + actionButtonLabel: actionButtonLabel + ) { + if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty { + let coordinateOutcome = tapAt( + app: alert.ownerApp, + x: actionButtonFrame.midX, + y: actionButtonFrame.midY + ) if let response = unsupportedResponse(for: coordinateOutcome) { return response } sleepFor(0.2) } } - if alertStillVisible(alert, actionButtonLabel: button.label) { + if alertStillVisible( + in: alert.ownerApp, + source: alert.source, + previousTitle: previousTitle, + actionButtonLabel: actionButtonLabel + ) { return Response( ok: false, error: ErrorPayload( @@ -78,31 +101,67 @@ extension RunnerTests { guard !buttons.isEmpty else { return nil } - return RunnerAlert(root: modal.root, ownerApp: modal.ownerApp, buttons: buttons) + return RunnerAlert( + root: modal.root, + ownerApp: modal.ownerApp, + buttons: buttons, + source: .blockingSystemModal + ) } - private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? { - runnerAlert( - ResolvedBlockingSystemModal( - root: root, - ownerApp: ownerApp, - actions: actionableElements(in: root) - ) - ) + private func runnerAlert( + root: XCUIElement, + ownerApp: XCUIApplication, + source: RunnerAlertSource + ) -> RunnerAlert? { + let buttons = actionableElements(in: root).filter { isEnabledElement($0) } + guard !buttons.isEmpty else { + return nil + } + return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source) } - private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool { - guard let current = resolveAlert(app: alert.ownerApp) else { + private func alertStillVisible( + in ownerApp: XCUIApplication, + source: RunnerAlertSource, + previousTitle: String, + actionButtonLabel: String + ) -> Bool { + guard let current = resolveAlert(source: source, app: ownerApp) else { return false } - let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons) let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons) if previousTitle == currentTitle { return true } - let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines) return current.buttons.contains { button in - button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel + button.label.trimmingCharacters(in: .whitespacesAndNewlines) == actionButtonLabel + } + } + + private func resolveAlert(source: RunnerAlertSource, app: XCUIApplication) -> RunnerAlert? { + switch source { + case .blockingSystemModal: +#if os(macOS) + return nil +#else + guard case .resolved(let modal) = resolveBlockingSystemModal() else { + return nil + } + return runnerAlert(modal) +#endif + case .appAlert: + guard let alert = firstExistingElement( + in: safeElementsQuery { app.alerts.allElementsBoundByIndex } + ) else { + return nil + } + return runnerAlert(root: alert, ownerApp: app, source: .appAlert) + case .dismissPopup: + guard let popup = firstDismissPopupWindow(in: app) else { + return nil + } + return runnerAlert(root: popup, ownerApp: app, source: .dismissPopup) } } From c5565009120097063a8f8d382884d16cf52f5c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 19:19:04 +0200 Subject: [PATCH 05/10] feat(ios): enable alerts on physical devices --- src/core/__tests__/capabilities.test.ts | 11 ++++++++++- .../capability-plugin-routing-parity.test.ts | 6 +++++- .../apple-os-capability-table-parity.test.ts | 6 +++++- src/platforms/apple/capabilities.ts | 3 ++- src/platforms/apple/plugin.ts | 18 ++++++++++++++---- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 9852bbc38..850844f9c 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -18,6 +18,14 @@ const iosDevice: DeviceInfo = { kind: 'device', }; +const iPadOsDevice: DeviceInfo = { + platform: 'apple', + appleOs: 'ipados', + id: 'ipad-dev-1', + name: 'iPad', + kind: 'device', +}; + const androidDevice: DeviceInfo = { platform: 'android', id: 'and-1', @@ -91,7 +99,8 @@ test('device capability matrix stays consistent across shared command groups', ( commands: ['alert'], checks: [ { device: iosSimulator, expected: true, label: 'on iOS sim' }, - { device: iosDevice, expected: false, label: 'on iOS device' }, + { device: iosDevice, expected: true, label: 'on iOS device' }, + { device: iPadOsDevice, expected: false, label: 'on iPadOS device' }, { device: androidDevice, expected: true, label: 'on Android' }, { device: macOsDevice, expected: true, label: 'on macOS' }, ], diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index c7026934a..35868418d 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -106,6 +106,9 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => isMacOs(device) || device.kind === 'simulator'; +const isIosOs = (device: DeviceInfo): boolean => + device.platform === 'apple' && + (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => device.platform === 'android' || (isIosFamily(device) && device.target !== 'tv'); const supportsTvRemote = (device: DeviceInfo): boolean => @@ -136,7 +139,8 @@ const SUPPORTS_REF: Record boolean> = { keyboard: supportsAndroidOrIosNonTv, rotate: supportsAndroidOrIosNonTv, 'tv-remote': supportsTvRemote, - alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), + alert: (device) => + device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', audio: supportsHostAudioProbe, diff --git a/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts b/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts index bd68354bf..cbaf42044 100644 --- a/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts +++ b/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts @@ -47,6 +47,9 @@ registerBuiltinPlatformPlugins(); const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => isMacOs(device) || device.kind === 'simulator'; +const isIosOs = (device: DeviceInfo): boolean => + device.platform === 'apple' && + (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => device.platform === 'android' || (isIosFamily(device) && device.target !== 'tv'); const supportsTvRemote = (device: DeviceInfo): boolean => @@ -68,7 +71,8 @@ const SUPPORTS_REF: Record boolean> = { keyboard: supportsAndroidOrIosNonTv, rotate: supportsAndroidOrIosNonTv, 'tv-remote': supportsTvRemote, - alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), + alert: (device) => + device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', // `audio` is NOT part of the AppleOS-table relocation — it stays the standalone diff --git a/src/platforms/apple/capabilities.ts b/src/platforms/apple/capabilities.ts index c1d79cfd5..d82ddd70b 100644 --- a/src/platforms/apple/capabilities.ts +++ b/src/platforms/apple/capabilities.ts @@ -35,9 +35,10 @@ export type AppleOsCapabilityProfile = { /** `rotate` — device orientation. tvOS (focus-only) and macOS lack it. */ readonly orientation: boolean; /** - * Whether `clipboard` / `alert` / `settings` are reachable on a PHYSICAL device of + * Whether `clipboard` / `settings` are reachable on a PHYSICAL device of * this OS. Only the macOS host exposes them without a simulator; the reading closure * still admits every Apple *simulator* via its own `kind === 'simulator'` check. + * Alert support has a separate command predicate because physical iOS is verified. */ readonly physicalDeviceSurfaces: boolean; }; diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 73cf7f26e..0dbcc5f08 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -6,7 +6,12 @@ import { shouldUseHostMacFastPath, type DeviceInventoryRequest, } from '../../contracts/device-inventory.ts'; -import { isMacOs, isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts'; +import { + isMacOs, + isTvOsDevice, + resolveDeviceAppleOs, + type DeviceInfo, +} from '../../kernel/device.ts'; import type { RunnerContext } from '../../core/interactor-types.ts'; // --------------------------------------------------------------------------- @@ -44,7 +49,7 @@ const supportsOrientation = (device: DeviceInfo): boolean => { return caps ? caps.orientation : device.platform === 'android'; }; -// The Apple arm shared by `clipboard`/`alert`/`settings` (was `macos || simulator`): +// The Apple arm shared by `clipboard`/`settings` (was `macos || simulator`): // reachable on the macOS host directly, on every other Apple OS only on the simulator. // Off Apple this preserves the trailing `device.kind === 'simulator'` term verbatim. const supportsHostOrSimulatorSurface = (device: DeviceInfo): boolean => { @@ -54,6 +59,12 @@ const supportsHostOrSimulatorSurface = (device: DeviceInfo): boolean => { : device.kind === 'simulator'; }; +// Alerts use the host/simulator surface plus physical iOS, whose XCTest path is +// device-verified. iPadOS/visionOS remain closed until independently verified. +const supportsAlertSurface = (device: DeviceInfo): boolean => + device.platform === 'android' || + (device.platform === 'apple' && resolveDeviceAppleOs(device) === 'ios') || + supportsHostOrSimulatorSurface(device); // `tv-remote` is Android-TV or tvOS only. Off Apple this preserves the Android-TV // branch so the relocated Apple closure stays equivalent to the full original // supports predicate under the parity guard; the closure is only consulted for Apple @@ -80,8 +91,7 @@ const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> [PUBLIC_COMMANDS.keyboard]: supportsKeyboard, [PUBLIC_COMMANDS.rotate]: supportsOrientation, [PUBLIC_COMMANDS.tvRemote]: supportsTvRemote, - [PUBLIC_COMMANDS.alert]: (device) => - device.platform === 'android' || supportsHostOrSimulatorSurface(device), + [PUBLIC_COMMANDS.alert]: supportsAlertSurface, [PUBLIC_COMMANDS.settings]: (device) => device.platform === 'android' || supportsHostOrSimulatorSurface(device), [PUBLIC_COMMANDS.audio]: isAudioProbeSupportedDevice, From d8d9646965ad7b00809c6f00cb6fc5894aac7fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 11:39:08 +0200 Subject: [PATCH 06/10] fix(ios): bound alert system modal resolution --- .github/workflows/ios.yml | 1 + .../RunnerTests+Alert.swift | 39 ++++++++--- ...rTests+BlockingSystemModalResolution.swift | 2 +- .../RunnerTests+CommandExecution.swift | 67 ++++++++++++++++++- .../RunnerTests+Models.swift | 1 + .../RunnerTests.swift | 1 + src/daemon/handlers/snapshot-alert.ts | 24 +++++-- .../apple/core/runner/runner-contract.ts | 2 + .../ios-alert-settings.test.ts | 29 +++++++- 9 files changed, 146 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index d6e160f5d..67186c8d3 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -67,6 +67,7 @@ jobs: -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testMissingBundleCommandInvalidatesCompleteCachedTargetState \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \ + -only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertResolutionCannotBypassRequestedDeadline \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \ -only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \ diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index a6fd9a9ce..5cdee9ae8 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -14,9 +14,21 @@ extension RunnerTests { let source: RunnerAlertSource } - func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? { + static let defaultAlertCommandTimeout: TimeInterval = 10 + + static func alertCommandTimeout(timeoutMs: Double?) -> TimeInterval { + guard let timeoutMs, timeoutMs.isFinite else { return defaultAlertCommandTimeout } + return max(0.001, timeoutMs / 1000) + } + + func resolveAlert(app activeApp: XCUIApplication, deadline: Date) -> RunnerAlert? { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS + if let override = alertResolutionOverrideForTesting { + return override(deadline) + } +#endif #if !os(macOS) - switch resolveBlockingSystemModal() { + switch resolveBlockingSystemModal(deadline: deadline) { case .resolved(let modal): return runnerAlert(modal) case .unresolved: @@ -38,7 +50,7 @@ extension RunnerTests { return nil } - func handleAlert(_ alert: RunnerAlert, action: String) -> Response { + func handleAlert(_ alert: RunnerAlert, action: String, deadline: Date) -> Response { if action == "accept" || action == "dismiss" { guard let button = chooseAlertButton(alert.buttons, action: action) else { return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found")) @@ -55,7 +67,8 @@ extension RunnerTests { in: alert.ownerApp, source: alert.source, previousTitle: previousTitle, - actionButtonLabel: actionButtonLabel + actionButtonLabel: actionButtonLabel, + deadline: deadline ) { if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty { let coordinateOutcome = tapAt( @@ -73,7 +86,8 @@ extension RunnerTests { in: alert.ownerApp, source: alert.source, previousTitle: previousTitle, - actionButtonLabel: actionButtonLabel + actionButtonLabel: actionButtonLabel, + deadline: deadline ) { return Response( ok: false, @@ -125,9 +139,12 @@ extension RunnerTests { in ownerApp: XCUIApplication, source: RunnerAlertSource, previousTitle: String, - actionButtonLabel: String + actionButtonLabel: String, + deadline: Date ) -> Bool { - guard let current = resolveAlert(source: source, app: ownerApp) else { + guard Date() < deadline, + let current = resolveAlert(source: source, app: ownerApp, deadline: deadline) + else { return false } let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons) @@ -139,13 +156,17 @@ extension RunnerTests { } } - private func resolveAlert(source: RunnerAlertSource, app: XCUIApplication) -> RunnerAlert? { + private func resolveAlert( + source: RunnerAlertSource, + app: XCUIApplication, + deadline: Date + ) -> RunnerAlert? { switch source { case .blockingSystemModal: #if os(macOS) return nil #else - guard case .resolved(let modal) = resolveBlockingSystemModal() else { + guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) else { return nil } return runnerAlert(modal) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift index d1896b2ce..b32e79424 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift @@ -14,7 +14,7 @@ extension RunnerTests { } func resolveBlockingSystemModal( - deadline: Date = .distantFuture + deadline: Date ) -> BlockingSystemModalResolution { guard let springboardModal = firstBlockingSystemModal( in: springboard, diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 9c77235e5..f06d4d63a 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -447,6 +447,57 @@ extension RunnerTests { XCTAssertTrue(response.error?.hint?.contains("runner session will be restarted") == true) } + func testAlertResolutionCannotBypassRequestedDeadline() throws { + final class ResultBox { + var error: Error? + var observedDeadline: Date? + } + let box = ResultBox() + let releaseResolution = DispatchSemaphore(value: 0) + let resolutionExited = expectation(description: "bounded alert resolution exited") + let commandFinished = expectation(description: "alert command respected its deadline") + let startedAt = Date() + let command = try runnerCommandFixture( + #"{"command":"alert","commandId":"alert-deadline","appBundleId":"com.apple.springboard","action":"get","timeoutMs":50}"# + ) + currentApp = springboard + currentBundleId = Self.springboardBundleId + alertResolutionOverrideForTesting = { deadline in + box.observedDeadline = deadline + _ = releaseResolution.wait(timeout: .now() + 1) + resolutionExited.fulfill() + return nil + } + defer { + releaseResolution.signal() + alertResolutionOverrideForTesting = nil + currentApp = nil + currentBundleId = nil + } + + DispatchQueue(label: "agent-device.runner.tests.alert-deadline").async { + do { + _ = try self.executeDispatched(command: command) + } catch { + box.error = error + } + commandFinished.fulfill() + } + + wait(for: [commandFinished], timeout: 1) + let error = box.error as NSError? + XCTAssertEqual(error?.domain, RunnerErrorDomain.general) + XCTAssertEqual(error?.code, RunnerErrorCode.mainThreadExecutionTimedOut) + XCTAssertNotNil(box.observedDeadline) + if let observedDeadline = box.observedDeadline { + XCTAssertGreaterThan(observedDeadline.timeIntervalSince(startedAt), 0) + XCTAssertLessThan(observedDeadline.timeIntervalSince(startedAt), 0.2) + } + + releaseResolution.signal() + wait(for: [resolutionExited], timeout: 1) + } + func testRunMainThreadWorkExecutesOffMainCallerOnMainThread() { final class ResultBox { var observedMainThread: Bool? @@ -691,6 +742,15 @@ extension RunnerTests { if command.command == .snapshot { return try executeSnapshotDispatched(command: command) } + if command.command == .alert { + return try runMainThreadWork( + command: command, + timeout: Self.alertCommandTimeout(timeoutMs: command.timeoutMs), + timeoutError: mainThreadExecutionTimeoutError + ) { + try self.executeOnMainSafely(command: command) + } + } return try runMainThreadWork( command: command, timeout: mainThreadExecutionTimeout, @@ -1628,10 +1688,13 @@ extension RunnerTests { ) case .alert: let action = (command.action ?? "get").lowercased() - guard let alert = resolveAlert(app: activeApp) else { + let deadline = Date().addingTimeInterval( + Self.alertCommandTimeout(timeoutMs: command.timeoutMs) + ) + guard let alert = resolveAlert(app: activeApp, deadline: deadline) else { return Response(ok: false, error: ErrorPayload(message: "alert not found")) } - return handleAlert(alert, action: action) + return handleAlert(alert, action: action, deadline: deadline) case .gesture: guard let plan = command.gesturePlan else { return Response( diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 912118085..cff7cb5e9 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -126,6 +126,7 @@ struct Command: Codable { let x2: Double? let y2: Double? let durationMs: Double? + let timeoutMs: Double? let direction: String? let amount: Double? let pixels: Double? diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 6d82a301b..955776244 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -99,6 +99,7 @@ final class RunnerTests: XCTestCase { // live SpringBoard alert. Production never compiles this property. Stored here (rather than in // the extension that reads it) because Swift extensions cannot hold stored properties. var systemModalProbeOverrideForTesting: ((Date) -> DataPayload?)? + var alertResolutionOverrideForTesting: ((Date) -> RunnerAlert?)? #endif // Observability for the record(_:) suppression below: how many AX-broken-screen snapshot // issues this session muted, so wedge investigations see the volume without grepping logs. diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 7416d4e2d..b271646fa 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -26,7 +26,7 @@ type HandleAlertCommandParams = { }; type NativeAlertAction = Exclude; -type NativeAlertRunner = (action: NativeAlertAction) => Promise; +type NativeAlertRunner = (action: NativeAlertAction, timeoutMs: number) => Promise; const ALERT_FALLBACK_HINT = 'If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref.'; @@ -68,10 +68,10 @@ export async function handleAlertCommand( logPath, traceLogPath: session?.trace?.outPath, }); - const runAlert: NativeAlertRunner = async (alertAction) => + const runAlert: NativeAlertRunner = async (alertAction, timeoutMs) => await runAppleRunnerCommand( device, - { command: 'alert', action: alertAction, appBundleId: session?.appBundleId }, + { command: 'alert', action: alertAction, appBundleId: session?.appBundleId, timeoutMs }, runnerOptions, ); return await handleNativeAlertCommand(params, action, runAlert); @@ -91,7 +91,7 @@ async function handleNativeAlertCommand( return await handleNativeAlertAction(params, resolvedAction, runAlert); } - return recordAlertResponse(params, await runAlert('get')); + return recordAlertResponse(params, await runAlert('get', DEFAULT_TIMEOUT_MS)); } function normalizeAlertAction(action: string | undefined): AlertAction { @@ -105,9 +105,12 @@ async function waitForNativeAlert( ): Promise { const timeout = parseTimeout(params.req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; const start = Date.now(); + let firstAttempt = true; while (Date.now() - start < timeout) { try { - return recordAlertResponse(params, await runAlert('get')); + const budgetMs = firstAttempt ? timeout : remainingBudgetMs(start, timeout); + firstAttempt = false; + return recordAlertResponse(params, await runAlert('get', budgetMs)); } catch { // keep waiting } @@ -123,9 +126,14 @@ async function handleNativeAlertAction( ): Promise { const start = Date.now(); let lastError: unknown; + let firstAttempt = true; while (Date.now() - start < ALERT_ACTION_RETRY_MS) { try { - return recordAlertResponse(params, await runAlert(action)); + const budgetMs = firstAttempt + ? ALERT_ACTION_RETRY_MS + : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); + firstAttempt = false; + return recordAlertResponse(params, await runAlert(action, budgetMs)); } catch (err) { lastError = err; const msg = String((err as { message?: unknown })?.message ?? '').toLowerCase(); @@ -136,6 +144,10 @@ async function handleNativeAlertAction( throw withAlertFallbackHint(lastError); } +function remainingBudgetMs(start: number, timeoutMs: number): number { + return Math.max(1, timeoutMs - (Date.now() - start)); +} + function recordAlertResponse(params: HandleAlertCommandParams, data: unknown): DaemonResponse { const responseData = data as Record; recordIfSession(params.sessionStore, params.session, params.req, responseData); diff --git a/src/platforms/apple/core/runner/runner-contract.ts b/src/platforms/apple/core/runner/runner-contract.ts index 098e66c38..b0508baaf 100644 --- a/src/platforms/apple/core/runner/runner-contract.ts +++ b/src/platforms/apple/core/runner/runner-contract.ts @@ -67,6 +67,8 @@ export type RunnerCommand = { x2?: number; y2?: number; durationMs?: number; + /** Remaining request budget for runner work that performs bounded XCTest queries. */ + timeoutMs?: number; direction?: ScrollDirection; amount?: number; pixels?: number; diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index 7818577b9..d57fa2a1d 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -19,14 +19,36 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr command: 'ios.runner.alert', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, platform: 'apple', - request: { command: 'alert', action: 'get', appBundleId: 'com.apple.Preferences' }, + request: { + command: 'alert', + action: 'get', + appBundleId: 'com.apple.Preferences', + timeoutMs: 10_000, + }, result: { title: 'Camera Access', message: 'Allow Settings to access Camera?' }, }, { command: 'ios.runner.alert', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, platform: 'apple', - request: { command: 'alert', action: 'accept', appBundleId: 'com.apple.Preferences' }, + request: { + command: 'alert', + action: 'get', + appBundleId: 'com.apple.Preferences', + timeoutMs: 37, + }, + result: { title: 'Camera Access', message: 'Allow Settings to access Camera?' }, + }, + { + command: 'ios.runner.alert', + deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + platform: 'apple', + request: { + command: 'alert', + action: 'accept', + appBundleId: 'com.apple.Preferences', + timeoutMs: 2_000, + }, result: { action: 'accept', accepted: true }, }, ]); @@ -147,6 +169,9 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr const alertGet = await client.command.alert({ action: 'get', ...selection }); assert.equal(alertGet.title, 'Camera Access'); + const alertWait = await client.command.alert({ action: 'wait', timeoutMs: 37, ...selection }); + assert.equal(alertWait.title, 'Camera Access'); + const alertAccept = await client.command.alert({ action: 'accept', ...selection }); assert.equal(alertAccept.accepted, true); } From 02690ac837145613cf433a0bd50780a7ee0369b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 11:57:14 +0200 Subject: [PATCH 07/10] test(ios): add AccessorySetupKit picker fixture --- examples/test-app/README.md | 17 ++++ examples/test-app/app.config.js | 10 ++ .../expo-module.config.json | 6 ++ .../ios/AccessorySetupLab.podspec | 16 ++++ .../ios/AccessorySetupLabModule.swift | 96 +++++++++++++++++++ examples/test-app/src/accessory-setup.ts | 19 ++++ .../test-app/src/screens/SettingsScreen.tsx | 43 +++++++++ 7 files changed, 207 insertions(+) create mode 100644 examples/test-app/modules/accessory-setup-lab/expo-module.config.json create mode 100644 examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLab.podspec create mode 100644 examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift create mode 100644 examples/test-app/src/accessory-setup.ts diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 57e787619..a17c0dd98 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -96,6 +96,23 @@ the same session when verification is complete: agent-device close --platform ios --udid "" --session test-app-physical ``` +#### AccessorySetupKit picker fixture + +The Settings tab includes an iOS-only **Open accessory picker** action backed by a local Expo +module. AccessorySetupKit requires the app's discovery descriptor to declare a Bluetooth service +UUID; configure the service advertised by the physical test accessory before building: + +```bash +AGENT_DEVICE_TEST_ACCESSORY_SERVICE_UUID="<16-or-128-bit-service-uuid>" \ +AGENT_DEVICE_TEST_ACCESSORY_BLUETOOTH_NAME="Mori" \ +pnpm test-app:ios -- --device "" +``` + +This is native configuration, so changing either value requires rebuilding and reinstalling the +development client. A Metro reload is not sufficient. The picker itself requires physical iOS 18+ +hardware; use the normal session hygiene above when validating its snapshot, wait, and selector +paths. + ### Android emulator or device Install dependencies and run the development build on the target Android diff --git a/examples/test-app/app.config.js b/examples/test-app/app.config.js index b6c2e4111..46053e91e 100644 --- a/examples/test-app/app.config.js +++ b/examples/test-app/app.config.js @@ -1,5 +1,14 @@ const buildRunCacheDir = process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache'; +const accessoryBluetoothName = + process.env.AGENT_DEVICE_TEST_ACCESSORY_BLUETOOTH_NAME?.trim() || 'Mori'; +const accessoryServiceUuid = process.env.AGENT_DEVICE_TEST_ACCESSORY_SERVICE_UUID?.trim(); + +const accessoryInfoPlist = { + NSAccessorySetupBluetoothNames: [accessoryBluetoothName], + NSAccessorySetupSupports: ['Bluetooth'], + ...(accessoryServiceUuid ? { NSAccessorySetupBluetoothServices: [accessoryServiceUuid] } : {}), +}; module.exports = { expo: { @@ -19,6 +28,7 @@ module.exports = { ios: { supportsTablet: true, bundleIdentifier: 'com.callstack.agentdevicelab', + infoPlist: accessoryInfoPlist, }, android: { package: 'com.callstack.agentdevicelab', diff --git a/examples/test-app/modules/accessory-setup-lab/expo-module.config.json b/examples/test-app/modules/accessory-setup-lab/expo-module.config.json new file mode 100644 index 000000000..de731c59c --- /dev/null +++ b/examples/test-app/modules/accessory-setup-lab/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "apple": { + "modules": ["AccessorySetupLabModule"] + } +} diff --git a/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLab.podspec b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLab.podspec new file mode 100644 index 000000000..67491649d --- /dev/null +++ b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLab.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'AccessorySetupLab' + s.version = '1.0.0' + s.summary = 'Physical-device AccessorySetupKit fixture for Agent Device Tester' + s.description = s.summary + s.license = { :type => 'MIT' } + s.author = { 'Callstack' => 'opensource@callstack.com' } + s.homepage = 'https://github.com/callstack/agent-device' + s.platforms = { :ios => '15.1' } + s.source = { :git => 'https://github.com/callstack/agent-device.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.frameworks = 'AccessorySetupKit', 'CoreBluetooth', 'UIKit' + s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' +end diff --git a/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift new file mode 100644 index 000000000..cb8562535 --- /dev/null +++ b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift @@ -0,0 +1,96 @@ +import AccessorySetupKit +import CoreBluetooth +import ExpoModulesCore +import UIKit + +public final class AccessorySetupLabModule: Module { + public func definition() -> ModuleDefinition { + Name("AccessorySetupLab") + + AsyncFunction("showPickerAsync") { (promise: Promise) in + guard #available(iOS 18.0, *) else { + promise.reject( + Exception( + name: "UnsupportedOperation", + description: "AccessorySetupKit requires iOS 18 or later." + ) + ) + return + } + + AccessorySetupController.shared.showPicker(promise: promise) + }.runOnQueue(.main) + } +} + +@available(iOS 18.0, *) +private final class AccessorySetupController { + static let shared = AccessorySetupController() + + private let session = ASAccessorySession() + private var isActivated = false + private var isPickerPresented = false + + func showPicker(promise: Promise) { + guard !isPickerPresented else { + promise.reject( + Exception( + name: "PickerAlreadyPresented", + description: "The accessory picker is already presented." + ) + ) + return + } + + guard + let serviceUuid = firstInfoPlistString(forKey: "NSAccessorySetupBluetoothServices") + else { + promise.reject( + Exception( + name: "MissingAccessoryService", + description: + "Set AGENT_DEVICE_TEST_ACCESSORY_SERVICE_UUID before rebuilding the iOS development client." + ) + ) + return + } + + if !isActivated { + session.activate(on: .main) { _ in } + isActivated = true + } + + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = CBUUID(string: serviceUuid) + descriptor.bluetoothNameSubstring = firstInfoPlistString( + forKey: "NSAccessorySetupBluetoothNames" + ) + + let productImage = UIImage( + systemName: "dot.radiowaves.left.and.right", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .regular) + ) ?? UIImage() + let displayItem = ASPickerDisplayItem( + name: descriptor.bluetoothNameSubstring ?? "Test accessory", + productImage: productImage, + descriptor: descriptor + ) + + isPickerPresented = true + session.showPicker(for: [displayItem]) { [weak self] error in + self?.isPickerPresented = false + if let error { + promise.reject( + Exception(name: "AccessoryPickerFailed", description: error.localizedDescription) + ) + } else { + promise.resolve(nil) + } + } + } + + private func firstInfoPlistString(forKey key: String) -> String? { + let values = Bundle.main.object(forInfoDictionaryKey: key) as? [String] + return values?.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + } +} diff --git a/examples/test-app/src/accessory-setup.ts b/examples/test-app/src/accessory-setup.ts new file mode 100644 index 000000000..599bd2826 --- /dev/null +++ b/examples/test-app/src/accessory-setup.ts @@ -0,0 +1,19 @@ +import { requireOptionalNativeModule } from 'expo-modules-core'; +import { Platform } from 'react-native'; + +type AccessorySetupLabModule = { + showPickerAsync(): Promise; +}; + +export async function showAccessorySetupPicker(): Promise { + if (Platform.OS !== 'ios') { + throw new Error('AccessorySetupKit is available only on iOS.'); + } + + const module = requireOptionalNativeModule('AccessorySetupLab'); + if (!module) { + throw new Error('Rebuild the iOS development client to include AccessorySetupLab.'); + } + + await module.showPickerAsync(); +} diff --git a/examples/test-app/src/screens/SettingsScreen.tsx b/examples/test-app/src/screens/SettingsScreen.tsx index 5313fb8b1..de0ca9d41 100644 --- a/examples/test-app/src/screens/SettingsScreen.tsx +++ b/examples/test-app/src/screens/SettingsScreen.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Alert, ActivityIndicator, @@ -9,6 +10,7 @@ import { } from 'react-native'; import { ActionButton, InlineBadge, ScreenTitle, SectionCard, ToggleRow } from '../components'; +import { showAccessorySetupPicker } from '../accessory-setup'; import { useAppColors, type AppColors } from '../theme'; export interface SettingsScreenProps { @@ -28,6 +30,24 @@ export interface SettingsScreenProps { export function SettingsScreen(props: SettingsScreenProps) { const colors = useAppColors(); const styles = createStyles(colors); + const [accessoryPickerStatus, setAccessoryPickerStatus] = useState< + 'idle' | 'opening' | 'dismissed' | 'error' + >('idle'); + const [accessoryPickerMessage, setAccessoryPickerMessage] = useState(''); + + async function openAccessoryPicker() { + setAccessoryPickerStatus('opening'); + setAccessoryPickerMessage('Accessory picker requested.'); + + try { + await showAccessorySetupPicker(); + setAccessoryPickerStatus('dismissed'); + setAccessoryPickerMessage('Accessory picker dismissed.'); + } catch (error) { + setAccessoryPickerStatus('error'); + setAccessoryPickerMessage(error instanceof Error ? error.message : String(error)); + } + } function showResetAlert() { Alert.alert( @@ -146,6 +166,29 @@ export function SettingsScreen(props: SettingsScreenProps) { /> + + + + Rebuild the iOS development client after configuring the accessory Bluetooth service UUID. + + + {accessoryPickerStatus !== 'idle' ? ( + + {accessoryPickerMessage} + + ) : null} + ); } From de34fc1fe8f5fe685f096e8c14a9bfc9b05ccae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 13:43:38 +0200 Subject: [PATCH 08/10] fix(ios): validate remote-hosted system modal interactions --- .../RunnerTests+SystemModal.swift | 57 ++++----- examples/test-app/README.md | 19 ++- examples/test-app/accessory-setup.config.json | 3 + examples/test-app/app.config.js | 10 +- examples/test-app/app/(tabs)/settings.tsx | 4 + examples/test-app/app/_layout.tsx | 1 + examples/test-app/app/accessory-setup.tsx | 14 +++ .../ios/AccessorySetupLabModule.swift | 46 ++++--- .../src/screens/AccessorySetupScreen.tsx | 114 ++++++++++++++++++ .../test-app/src/screens/SettingsScreen.tsx | 55 ++------- .../__tests__/snapshot-handler.test.ts | 5 + src/daemon/handlers/snapshot-alert.ts | 5 +- .../ios-alert-settings.test.ts | 2 +- 13 files changed, 229 insertions(+), 106 deletions(-) create mode 100644 examples/test-app/accessory-setup.config.json create mode 100644 examples/test-app/app/accessory-setup.tsx create mode 100644 examples/test-app/src/screens/AccessorySetupScreen.tsx diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index 4dfdcf542..77597ba0a 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -27,20 +27,6 @@ extension RunnerTests { } var nodes: [SnapshotNode] = [modalNode] - for content in informativeElements(in: modal, excluding: actions) { - guard let contentNode = safeMakeSnapshotNode( - element: content, - index: nodes.count, - type: elementTypeName(content.elementType), - depth: 1, - parentIndex: 0, - hittableOverride: false - ) else { - continue - } - nodes.append(contentNode) - } - for action in actions { guard let actionNode = safeMakeSnapshotNode( element: action, @@ -55,6 +41,25 @@ extension RunnerTests { nodes.append(actionNode) } + // Action controls are the interaction contract. Informative text is useful context, but + // must not make a usable modal miss the bounded system-modal probe deadline. + let contentDeadline = deadline.addingTimeInterval(-0.5) + for content in informativeElements(in: modal, deadline: contentDeadline) { + guard Date() < contentDeadline, + let contentNode = safeMakeSnapshotNode( + element: content, + index: nodes.count, + type: elementTypeName(content.elementType), + depth: 1, + parentIndex: 0, + hittableOverride: false + ) + else { + break + } + nodes.append(contentNode) + } + return DataPayload(nodes: nodes, truncated: false) #endif } @@ -144,20 +149,18 @@ extension RunnerTests { } } - private func informativeElements(in element: XCUIElement, excluding actions: [XCUIElement]) -> [XCUIElement] { - let actionKeys = Set(actions.map(systemModalElementKey)) + private func informativeElements(in element: XCUIElement, deadline: Date) -> [XCUIElement] { var seen = Set() var contents: [XCUIElement] = [] - let descendants = readableSystemModalTypes.flatMap { - modalDescendants(in: element, matching: $0, limit: 2) - } - for candidate in descendants { - guard let key = safeInformativeElementKey(candidate, actionKeys: actionKeys) else { - continue + for type in readableSystemModalTypes { + guard Date() < deadline else { break } + for candidate in modalDescendants(in: element, matching: type, limit: 2) { + guard Date() < deadline else { return contents } + guard let key = safeInformativeElementKey(candidate) else { continue } + if seen.contains(key) { continue } + seen.insert(key) + contents.append(candidate) } - if seen.contains(key) { continue } - seen.insert(key) - contents.append(candidate) } return contents } @@ -180,11 +183,9 @@ extension RunnerTests { return Array(elements.prefix(limit)) } - private func safeInformativeElementKey(_ candidate: XCUIElement, actionKeys: Set) -> String? { + private func safeInformativeElementKey(_ candidate: XCUIElement) -> String? { safely("MODAL_CONTENT") { () -> String? in let key = systemModalElementKey(candidate) - if actionKeys.contains(key) { return nil } - if actionableTypes.contains(candidate.elementType) { return nil } if !candidate.exists { return nil } let frame = candidate.frame if frame.isNull || frame.isEmpty { return nil } diff --git a/examples/test-app/README.md b/examples/test-app/README.md index a17c0dd98..e52684a0e 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -98,20 +98,17 @@ agent-device close --platform ios --udid "" --session test-app-ph #### AccessorySetupKit picker fixture -The Settings tab includes an iOS-only **Open accessory picker** action backed by a local Expo -module. AccessorySetupKit requires the app's discovery descriptor to declare a Bluetooth service -UUID; configure the service advertised by the physical test accessory before building: +The Settings tab links to a dedicated **Accessory setup lab** backed by a local Expo module. The +development client uses this fixed test service UUID, so no build-time environment variables are +required: -```bash -AGENT_DEVICE_TEST_ACCESSORY_SERVICE_UUID="<16-or-128-bit-service-uuid>" \ -AGENT_DEVICE_TEST_ACCESSORY_BLUETOOTH_NAME="Mori" \ -pnpm test-app:ios -- --device "" +```text +FFF0 ``` -This is native configuration, so changing either value requires rebuilding and reinstalling the -development client. A Metro reload is not sufficient. The picker itself requires physical iOS 18+ -hardware; use the normal session hygiene above when validating its snapshot, wait, and selector -paths. +Advertise that service from the test accessory, build with the normal physical-device command above, +then open **Settings → Open accessory setup lab**. The picker requires physical iOS 18+ hardware; use +the normal session hygiene above when validating its snapshot, wait, and selector paths. ### Android emulator or device diff --git a/examples/test-app/accessory-setup.config.json b/examples/test-app/accessory-setup.config.json new file mode 100644 index 000000000..706124d6e --- /dev/null +++ b/examples/test-app/accessory-setup.config.json @@ -0,0 +1,3 @@ +{ + "serviceUuid": "FFF0" +} diff --git a/examples/test-app/app.config.js b/examples/test-app/app.config.js index 46053e91e..f94db38d0 100644 --- a/examples/test-app/app.config.js +++ b/examples/test-app/app.config.js @@ -1,13 +1,11 @@ +const accessorySetupConfig = require('./accessory-setup.config.json'); + const buildRunCacheDir = process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache'; -const accessoryBluetoothName = - process.env.AGENT_DEVICE_TEST_ACCESSORY_BLUETOOTH_NAME?.trim() || 'Mori'; -const accessoryServiceUuid = process.env.AGENT_DEVICE_TEST_ACCESSORY_SERVICE_UUID?.trim(); const accessoryInfoPlist = { - NSAccessorySetupBluetoothNames: [accessoryBluetoothName], - NSAccessorySetupSupports: ['Bluetooth'], - ...(accessoryServiceUuid ? { NSAccessorySetupBluetoothServices: [accessoryServiceUuid] } : {}), + NSAccessorySetupBluetoothServices: [accessorySetupConfig.serviceUuid], + NSAccessorySetupKitSupports: ['Bluetooth'], }; module.exports = { diff --git a/examples/test-app/app/(tabs)/settings.tsx b/examples/test-app/app/(tabs)/settings.tsx index e5efac979..b87fa2528 100644 --- a/examples/test-app/app/(tabs)/settings.tsx +++ b/examples/test-app/app/(tabs)/settings.tsx @@ -1,8 +1,11 @@ +import { useRouter } from 'expo-router'; + import { AppFrame } from '../../src/components'; import { useLabState } from '../../src/lab-state'; import { SettingsScreen } from '../../src/screens/SettingsScreen'; export default function SettingsRoute() { + const router = useRouter(); const state = useLabState(); return ( @@ -12,6 +15,7 @@ export default function SettingsRoute() { diagnosticsLoading={state.diagnosticsLoading} diagnosticsState={state.diagnosticsState} notificationsEnabled={state.notificationsEnabled} + onOpenAccessorySetup={() => router.push('/accessory-setup')} onConfirmReset={state.resetLabState} onLoadDiagnostics={state.loadDiagnostics} onRetryDiagnostics={state.retryDiagnostics} diff --git a/examples/test-app/app/_layout.tsx b/examples/test-app/app/_layout.tsx index 11fd19106..3dd77b739 100644 --- a/examples/test-app/app/_layout.tsx +++ b/examples/test-app/app/_layout.tsx @@ -23,6 +23,7 @@ function RootLayoutContent() { }} > + {toastMessage ? : null} diff --git a/examples/test-app/app/accessory-setup.tsx b/examples/test-app/app/accessory-setup.tsx new file mode 100644 index 000000000..187a0666a --- /dev/null +++ b/examples/test-app/app/accessory-setup.tsx @@ -0,0 +1,14 @@ +import { useRouter } from 'expo-router'; + +import { AppFrame } from '../src/components'; +import { AccessorySetupScreen } from '../src/screens/AccessorySetupScreen'; + +export default function AccessorySetupRoute() { + const router = useRouter(); + + return ( + + router.back()} /> + + ); +} diff --git a/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift index cb8562535..f69457339 100644 --- a/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift +++ b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift @@ -49,17 +49,12 @@ private final class AccessorySetupController { Exception( name: "MissingAccessoryService", description: - "Set AGENT_DEVICE_TEST_ACCESSORY_SERVICE_UUID before rebuilding the iOS development client." + "The development client is missing its AccessorySetupKit test service configuration." ) ) return } - if !isActivated { - session.activate(on: .main) { _ in } - isActivated = true - } - let descriptor = ASDiscoveryDescriptor() descriptor.bluetoothServiceUUID = CBUUID(string: serviceUuid) descriptor.bluetoothNameSubstring = firstInfoPlistString( @@ -76,17 +71,38 @@ private final class AccessorySetupController { descriptor: descriptor ) - isPickerPresented = true - session.showPicker(for: [displayItem]) { [weak self] error in - self?.isPickerPresented = false - if let error { - promise.reject( - Exception(name: "AccessoryPickerFailed", description: error.localizedDescription) - ) - } else { - promise.resolve(nil) + if #available(iOS 26.0, *) { + let settings = ASPickerDisplaySettings.default + settings.discoveryTimeout = .short + session.pickerDisplaySettings = settings + } + + let presentPicker = { [weak self] in + guard let self else { return } + + session.showPicker(for: [displayItem]) { [weak self] error in + self?.isPickerPresented = false + if let error { + promise.reject( + Exception(name: "AccessoryPickerFailed", description: error.localizedDescription) + ) + } else { + promise.resolve(nil) + } } } + + isPickerPresented = true + if isActivated { + presentPicker() + return + } + + session.activate(on: .main) { [weak self] event in + guard let self, event.eventType == .activated else { return } + isActivated = true + presentPicker() + } } private func firstInfoPlistString(forKey key: String) -> String? { diff --git a/examples/test-app/src/screens/AccessorySetupScreen.tsx b/examples/test-app/src/screens/AccessorySetupScreen.tsx new file mode 100644 index 000000000..b01ffbf6d --- /dev/null +++ b/examples/test-app/src/screens/AccessorySetupScreen.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { ScrollView, StyleSheet, Text } from 'react-native'; + +import accessorySetupConfig from '../../accessory-setup.config.json'; +import { showAccessorySetupPicker } from '../accessory-setup'; +import { ActionButton, InlineBadge, ScreenTitle, SectionCard } from '../components'; +import { useAppColors, type AppColors } from '../theme'; + +type PickerStatus = 'idle' | 'opening' | 'dismissed' | 'error'; + +export function AccessorySetupScreen(props: { onBack: () => void }) { + const colors = useAppColors(); + const styles = createStyles(colors); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState('Ready to open the system accessory picker.'); + + async function openPicker() { + setStatus('opening'); + setMessage('Accessory picker requested.'); + + try { + await showAccessorySetupPicker(); + setStatus('dismissed'); + setMessage('Accessory picker dismissed.'); + } catch (error) { + setStatus('error'); + setMessage(error instanceof Error ? error.message : String(error)); + } + } + + return ( + + + + + + {accessorySetupConfig.serviceUuid} + + + Advertise this Bluetooth service near the physical iPhone before opening the picker. + + + + + + + + {message} + + + + + + ); +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + body: { + color: colors.text, + fontSize: 14, + lineHeight: 21, + }, + content: { + paddingBottom: 28, + }, + error: { + color: colors.danger, + fontSize: 14, + lineHeight: 21, + }, + uuid: { + color: colors.text, + fontFamily: 'monospace', + fontSize: 13, + lineHeight: 20, + }, + }); +} diff --git a/examples/test-app/src/screens/SettingsScreen.tsx b/examples/test-app/src/screens/SettingsScreen.tsx index de0ca9d41..6998302fb 100644 --- a/examples/test-app/src/screens/SettingsScreen.tsx +++ b/examples/test-app/src/screens/SettingsScreen.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Alert, ActivityIndicator, @@ -10,7 +9,6 @@ import { } from 'react-native'; import { ActionButton, InlineBadge, ScreenTitle, SectionCard, ToggleRow } from '../components'; -import { showAccessorySetupPicker } from '../accessory-setup'; import { useAppColors, type AppColors } from '../theme'; export interface SettingsScreenProps { @@ -19,6 +17,7 @@ export interface SettingsScreenProps { diagnosticsState: 'idle' | 'ready' | 'error'; notificationsEnabled: boolean; reducedMotionEnabled: boolean; + onOpenAccessorySetup: () => void; onLoadDiagnostics: () => void; onRetryDiagnostics: () => void; onSetNotificationsEnabled: (value: boolean) => void; @@ -30,24 +29,6 @@ export interface SettingsScreenProps { export function SettingsScreen(props: SettingsScreenProps) { const colors = useAppColors(); const styles = createStyles(colors); - const [accessoryPickerStatus, setAccessoryPickerStatus] = useState< - 'idle' | 'opening' | 'dismissed' | 'error' - >('idle'); - const [accessoryPickerMessage, setAccessoryPickerMessage] = useState(''); - - async function openAccessoryPicker() { - setAccessoryPickerStatus('opening'); - setAccessoryPickerMessage('Accessory picker requested.'); - - try { - await showAccessorySetupPicker(); - setAccessoryPickerStatus('dismissed'); - setAccessoryPickerMessage('Accessory picker dismissed.'); - } catch (error) { - setAccessoryPickerStatus('error'); - setAccessoryPickerMessage(error instanceof Error ? error.message : String(error)); - } - } function showResetAlert() { Alert.alert( @@ -79,6 +60,17 @@ export function SettingsScreen(props: SettingsScreenProps) { testID="settings-title" /> + + + + - - - - Rebuild the iOS development client after configuring the accessory Bluetooth service UUID. - - - {accessoryPickerStatus !== 'idle' ? ( - - {accessoryPickerMessage} - - ) : null} - ); } diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 5196a88ff..3dc9cdb5e 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -2134,6 +2134,11 @@ test('alert accept retries on "alert not found" and succeeds on second attempt', expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(calls).toBe(2); + expect(mockRunnerCommand.mock.calls[0]?.[1]).toMatchObject({ + command: 'alert', + action: 'accept', + timeoutMs: 10_000, + }); }); test('alert accept does not retry on non-alert errors', async () => { diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index b271646fa..a0a3986d1 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,4 +1,4 @@ -import { isMacOs } from '../../kernel/device.ts'; +import { isIosFamily, isMacOs } from '../../kernel/device.ts'; import { ALERT_ACTION_RETRY_MS, ALERT_POLL_INTERVAL_MS as POLL_INTERVAL_MS, @@ -124,13 +124,14 @@ async function handleNativeAlertAction( action: 'accept' | 'dismiss', runAlert: NativeAlertRunner, ): Promise { + const runnerTimeoutMs = isIosFamily(params.device) ? DEFAULT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; const start = Date.now(); let lastError: unknown; let firstAttempt = true; while (Date.now() - start < ALERT_ACTION_RETRY_MS) { try { const budgetMs = firstAttempt - ? ALERT_ACTION_RETRY_MS + ? runnerTimeoutMs : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); firstAttempt = false; return recordAlertResponse(params, await runAlert(action, budgetMs)); diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index d57fa2a1d..e1f2e1e16 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -47,7 +47,7 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr command: 'alert', action: 'accept', appBundleId: 'com.apple.Preferences', - timeoutMs: 2_000, + timeoutMs: 10_000, }, result: { action: 'accept', accepted: true }, }, From eadcdbc38c64e96ff1a4645fd596761797d674c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 13:50:43 +0200 Subject: [PATCH 09/10] chore: keep pnpm checks non-interactive --- pnpm-workspace.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de1e3d49c..9b90dcfdd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,7 @@ packages: - "website" +# Keep non-interactive checks from downloading tooling or reinstalling dependencies implicitly. +pmOnFail: warn +verifyDepsBeforeRun: warn allowBuilds: fallow: true From 05a99377cd85e8d7e0d3392ed40589e0a05f8451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 14:29:58 +0200 Subject: [PATCH 10/10] fix(ios): share alert command deadline --- .../RunnerTests+CommandExecution.swift | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index f06d4d63a..fcc41fa56 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -737,18 +737,24 @@ extension RunnerTests { return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds) } if Thread.isMainThread { - return try executeOnMainSafely(command: command) + let alertDeadline = command.command == .alert + ? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs)) + : nil + return try executeOnMainSafely(command: command, alertDeadline: alertDeadline) } if command.command == .snapshot { return try executeSnapshotDispatched(command: command) } if command.command == .alert { + let deadline = Date().addingTimeInterval( + Self.alertCommandTimeout(timeoutMs: command.timeoutMs) + ) return try runMainThreadWork( command: command, - timeout: Self.alertCommandTimeout(timeoutMs: command.timeoutMs), + timeout: max(0.001, deadline.timeIntervalSinceNow), timeoutError: mainThreadExecutionTimeoutError ) { - try self.executeOnMainSafely(command: command) + try self.executeOnMainSafely(command: command, alertDeadline: deadline) } } return try runMainThreadWork( @@ -834,7 +840,10 @@ extension RunnerTests { // MARK: - Command Handling - private func executeOnMainSafely(command: Command) throws -> Response { + private func executeOnMainSafely( + command: Command, + alertDeadline: Date? = nil + ) throws -> Response { var hasRetried = false while true { var response: Response? @@ -842,7 +851,7 @@ extension RunnerTests { let failureCountBefore = currentXCTestFailureCount() let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ do { - response = try self.executeOnMain(command: command) + response = try self.executeOnMain(command: command, alertDeadline: alertDeadline) } catch { swiftError = error } @@ -1073,7 +1082,7 @@ extension RunnerTests { return preparation } - private func executeOnMain(command: Command) throws -> Response { + private func executeOnMain(command: Command, alertDeadline: Date?) throws -> Response { let preparation = prepareActiveCommandContext(command: command) let activeApp: XCUIApplication switch preparation { @@ -1153,7 +1162,11 @@ extension RunnerTests { default: break } - return try executeOnMainPrepared(command: command, activeApp: activeApp) + return try executeOnMainPrepared( + command: command, + activeApp: activeApp, + alertDeadline: alertDeadline + ) } private func prepareActiveCommandContext(command: Command) -> ActiveCommandPreparation { @@ -1223,7 +1236,11 @@ extension RunnerTests { return .context(ActiveCommandContext(app: activeApp)) } - private func executeOnMainPrepared(command: Command, activeApp: XCUIApplication) throws -> Response { + private func executeOnMainPrepared( + command: Command, + activeApp: XCUIApplication, + alertDeadline: Date? = nil + ) throws -> Response { var activeApp = activeApp switch command.command { case .status, .shutdown, .recordStart, .recordStop, .uptime: @@ -1688,7 +1705,7 @@ extension RunnerTests { ) case .alert: let action = (command.action ?? "get").lowercased() - let deadline = Date().addingTimeInterval( + let deadline = alertDeadline ?? Date().addingTimeInterval( Self.alertCommandTimeout(timeoutMs: command.timeoutMs) ) guard let alert = resolveAlert(app: activeApp, deadline: deadline) else {