Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading