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 7b4fdd38f..5cdee9ae8 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -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 + } + + 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) -> RunnerAlert? { + 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) - if let systemModal = firstBlockingSystemModal(in: springboard) { - return runnerAlert(root: systemModal, ownerApp: springboard) + switch resolveBlockingSystemModal(deadline: deadline) { + case .resolved(let modal): + return runnerAlert(modal) + case .unresolved: + return nil + case .absent: + break } #endif - if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) { - return runnerAlert(root: alert, ownerApp: activeApp) + // 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) } } diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+BlockingSystemModalResolution.swift new file mode 100644 index 000000000..b32e79424 --- /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 + ) -> 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+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 9c77235e5..fcc41fa56 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? @@ -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( 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+SystemModal.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift index 45db60cbe..77597ba0a 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift @@ -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() 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? { + 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/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/examples/test-app/README.md b/examples/test-app/README.md index 57e787619..e52684a0e 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -96,6 +96,20 @@ the same session when verification is complete: agent-device close --platform ios --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 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 b6c2e4111..f94db38d0 100644 --- a/examples/test-app/app.config.js +++ b/examples/test-app/app.config.js @@ -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', 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/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..f69457339 --- /dev/null +++ b/examples/test-app/modules/accessory-setup-lab/ios/AccessorySetupLabModule.swift @@ -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 }) + } +} 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/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 5313fb8b1..6998302fb 100644 --- a/examples/test-app/src/screens/SettingsScreen.tsx +++ b/examples/test-app/src/screens/SettingsScreen.tsx @@ -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" /> + + + + !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/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 7416d4e2d..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, @@ -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 } @@ -121,11 +124,17 @@ 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 { - 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; recordIfSession(params.sessionStore, params.session, params.req, responseData); 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/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/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, diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index 7818577b9..e1f2e1e16 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: 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); }