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>
This commit is contained in:
Kenichi Saito
2026-07-15 00:19:17 +09:00
committed by GitHub
parent 392dc1cded
commit 236016ed8a
29 changed files with 752 additions and 78 deletions
+1
View File
@@ -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 \
@@ -1,48 +1,94 @@
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? {
#if !os(macOS)
if let systemModal = firstBlockingSystemModal(in: springboard) {
return runnerAlert(root: systemModal, ownerApp: springboard)
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 let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
return runnerAlert(root: alert, ownerApp: activeApp)
#if !os(macOS)
switch resolveBlockingSystemModal(deadline: deadline) {
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
// 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, source: .appAlert)
}
if let popup = firstDismissPopupWindow(in: activeApp) {
return runnerAlert(root: popup, ownerApp: activeApp)
return runnerAlert(root: popup, ownerApp: activeApp, source: .dismissPopup)
}
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"))
}
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,
deadline: deadline
) {
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,
deadline: deadline
) {
return Response(
ok: false,
error: ErrorPayload(
@@ -64,26 +110,79 @@ extension RunnerTests {
)
}
private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? {
private func runnerAlert(_ modal: ResolvedBlockingSystemModal) -> RunnerAlert? {
let buttons = modal.actions.filter { isEnabledElement($0) }
guard !buttons.isEmpty else {
return nil
}
return RunnerAlert(
root: modal.root,
ownerApp: modal.ownerApp,
buttons: buttons,
source: .blockingSystemModal
)
}
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)
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,
deadline: Date
) -> Bool {
guard Date() < deadline,
let current = resolveAlert(source: source, app: ownerApp, deadline: deadline)
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,
deadline: Date
) -> RunnerAlert? {
switch source {
case .blockingSystemModal:
#if os(macOS)
return nil
#else
guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) 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)
}
}
@@ -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
) -> 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
@@ -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?
@@ -686,11 +737,26 @@ 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: max(0.001, deadline.timeIntervalSinceNow),
timeoutError: mainThreadExecutionTimeoutError
) {
try self.executeOnMainSafely(command: command, alertDeadline: deadline)
}
}
return try runMainThreadWork(
command: command,
timeout: mainThreadExecutionTimeout,
@@ -774,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?
@@ -782,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
}
@@ -1013,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 {
@@ -1093,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 {
@@ -1163,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:
@@ -1628,10 +1705,13 @@ extension RunnerTests {
)
case .alert:
let action = (command.action ?? "get").lowercased()
guard let alert = resolveAlert(app: activeApp) else {
let deadline = alertDeadline ?? 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(
@@ -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?
@@ -7,13 +7,11 @@ extension RunnerTests {
#if os(macOS)
return nil
#else
guard let modal = firstBlockingSystemModal(in: springboard, deadline: deadline) else {
return nil
}
let actions = actionableElements(in: modal)
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(
@@ -29,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,
@@ -57,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
}
@@ -146,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<String>()
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
}
@@ -182,11 +183,9 @@ extension RunnerTests {
return Array(elements.prefix(limit))
}
private func safeInformativeElementKey(_ candidate: XCUIElement, actionKeys: Set<String>) -> 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 }
@@ -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.
+14
View File
@@ -96,6 +96,20 @@ the same session when verification is complete:
agent-device close --platform ios --udid "<physical udid>" --session test-app-physical
```
#### AccessorySetupKit picker fixture
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:
```text
FFF0
```
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
Install dependencies and run the development build on the target Android
@@ -0,0 +1,3 @@
{
"serviceUuid": "FFF0"
}
+8
View File
@@ -1,6 +1,13 @@
const accessorySetupConfig = require('./accessory-setup.config.json');
const buildRunCacheDir =
process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache';
const accessoryInfoPlist = {
NSAccessorySetupBluetoothServices: [accessorySetupConfig.serviceUuid],
NSAccessorySetupKitSupports: ['Bluetooth'],
};
module.exports = {
expo: {
name: 'Agent Device Tester',
@@ -19,6 +26,7 @@ module.exports = {
ios: {
supportsTablet: true,
bundleIdentifier: 'com.callstack.agentdevicelab',
infoPlist: accessoryInfoPlist,
},
android: {
package: 'com.callstack.agentdevicelab',
@@ -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}
+1
View File
@@ -23,6 +23,7 @@ function RootLayoutContent() {
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="accessory-setup" />
<Stack.Screen name="product/[productId]" />
</Stack>
{toastMessage ? <ToastViewport message={toastMessage} /> : null}
+14
View File
@@ -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 (
<AppFrame>
<AccessorySetupScreen onBack={() => router.back()} />
</AppFrame>
);
}
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"apple": {
"modules": ["AccessorySetupLabModule"]
}
}
@@ -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
@@ -0,0 +1,112 @@
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:
"The development client is missing its AccessorySetupKit test service configuration."
)
)
return
}
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
)
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? {
let values = Bundle.main.object(forInfoDictionaryKey: key) as? [String]
return values?.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
}
+19
View File
@@ -0,0 +1,19 @@
import { requireOptionalNativeModule } from 'expo-modules-core';
import { Platform } from 'react-native';
type AccessorySetupLabModule = {
showPickerAsync(): Promise<void>;
};
export async function showAccessorySetupPicker(): Promise<void> {
if (Platform.OS !== 'ios') {
throw new Error('AccessorySetupKit is available only on iOS.');
}
const module = requireOptionalNativeModule<AccessorySetupLabModule>('AccessorySetupLab');
if (!module) {
throw new Error('Rebuild the iOS development client to include AccessorySetupLab.');
}
await module.showPickerAsync();
}
@@ -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<PickerStatus>('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 (
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ScreenTitle
badge="iOS 18+"
subtitle="Launch the out-of-process AccessorySetupUI picker from one stable test surface."
testID="accessory-setup-title"
title="Accessory setup lab"
/>
<SectionCard
subtitle="The development client is already configured for this service."
title="Test accessory"
>
<Text selectable style={styles.uuid} testID="accessory-service-uuid">
{accessorySetupConfig.serviceUuid}
</Text>
<Text style={styles.body}>
Advertise this Bluetooth service near the physical iPhone before opening the picker.
</Text>
</SectionCard>
<SectionCard
subtitle="Use snapshot, wait, and selector click against the system surface."
title="Picker"
>
<ActionButton
label="Open accessory picker"
onPress={openPicker}
testID="open-accessory-picker"
/>
<InlineBadge
label={
status === 'idle'
? 'Ready'
: status === 'opening'
? 'Opening'
: status === 'dismissed'
? 'Dismissed'
: 'Error'
}
tone={status === 'error' ? 'danger' : status === 'dismissed' ? 'success' : 'accent'}
/>
<Text
accessibilityLiveRegion="polite"
style={status === 'error' ? styles.error : styles.body}
testID="accessory-picker-status"
>
{message}
</Text>
</SectionCard>
<ActionButton
kind="secondary"
label="Back to settings"
onPress={props.onBack}
testID="accessory-setup-back"
/>
</ScrollView>
);
}
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,
},
});
}
@@ -17,6 +17,7 @@ export interface SettingsScreenProps {
diagnosticsState: 'idle' | 'ready' | 'error';
notificationsEnabled: boolean;
reducedMotionEnabled: boolean;
onOpenAccessorySetup: () => void;
onLoadDiagnostics: () => void;
onRetryDiagnostics: () => void;
onSetNotificationsEnabled: (value: boolean) => void;
@@ -59,6 +60,17 @@ export function SettingsScreen(props: SettingsScreenProps) {
testID="settings-title"
/>
<SectionCard
subtitle="Open the physical iOS AccessorySetupUI verification fixture."
title="Accessory setup"
>
<ActionButton
label="Open accessory setup lab"
onPress={props.onOpenAccessorySetup}
testID="open-accessory-setup-lab"
/>
</SectionCard>
<SectionCard subtitle="Simple switch rows for durable selectors." title="Preferences">
<ToggleRow
description="Disabled notifications should remain visible in plain snapshots."
+3
View File
@@ -1,4 +1,7 @@
packages:
- "website"
# Keep non-interactive checks from downloading tooling or reinstalling dependencies implicitly.
pmOnFail: warn
verifyDepsBeforeRun: warn
allowBuilds:
fallow: true
+10 -1
View File
@@ -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' },
],
@@ -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<string, (device: DeviceInfo) => boolean> = {
keyboard: supportsAndroidOrIosNonTv,
orientation: 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,
@@ -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<string, (device: DeviceInfo) => boolean> = {
keyboard: supportsAndroidOrIosNonTv,
orientation: 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
@@ -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 () => {
+20 -7
View File
@@ -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,
@@ -26,7 +26,7 @@ type HandleAlertCommandParams = {
};
type NativeAlertAction = Exclude<AlertAction, 'wait'>;
type NativeAlertRunner = (action: NativeAlertAction) => Promise<unknown>;
type NativeAlertRunner = (action: NativeAlertAction, timeoutMs: number) => Promise<unknown>;
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<DaemonResponse> {
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
}
@@ -121,11 +124,17 @@ async function handleNativeAlertAction(
action: 'accept' | 'dismiss',
runAlert: NativeAlertRunner,
): Promise<DaemonResponse> {
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 {
return recordAlertResponse(params, await runAlert(action));
const budgetMs = firstAttempt
? runnerTimeoutMs
: 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 +145,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<string, unknown>;
recordIfSession(params.sessionStore, params.session, params.req, responseData);
+2 -1
View File
@@ -35,9 +35,10 @@ export type AppleOsCapabilityProfile = {
/** `orientation` — 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;
};
@@ -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;
+14 -4
View File
@@ -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<string, (device: DeviceInfo) => boolean>
[PUBLIC_COMMANDS.keyboard]: supportsKeyboard,
[PUBLIC_COMMANDS.orientation]: 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,
@@ -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: 10_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);
}