mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+94
@@ -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
|
||||
+88
-8
@@ -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?
|
||||
|
||||
+32
-33
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
packages:
|
||||
- "website"
|
||||
# Keep non-interactive checks from downloading tooling or reinstalling dependencies implicitly.
|
||||
pmOnFail: warn
|
||||
verifyDepsBeforeRun: warn
|
||||
allowBuilds:
|
||||
fallow: true
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user