Skip to content

Commit 613974a

Browse files
authored
Handle iOS system blocker alerts in snapshots (#38)
1 parent 31a639a commit 613974a

1 file changed

Lines changed: 149 additions & 0 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import XCTest
99
import Network
1010

1111
final class RunnerTests: XCTestCase {
12+
private static let springboardBundleId = "com.apple.springboard"
1213
private var listener: NWListener?
1314
private var port: UInt16 = 0
1415
private var doneExpectation: XCTestExpectation?
1516
private let app = XCUIApplication()
17+
private lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId)
1618
private var currentApp: XCUIApplication?
1719
private var currentBundleId: String?
1820
private let maxRequestBytes = 2 * 1024 * 1024
@@ -36,6 +38,15 @@ final class RunnerTests: XCTestCase {
3638
.secureTextField,
3739
.textView,
3840
]
41+
// Keep blocker actions narrow to avoid false positives from generic hittable containers.
42+
private let actionableTypes: Set<XCUIElement.ElementType> = [
43+
.button,
44+
.cell,
45+
.link,
46+
.menuItem,
47+
.checkBox,
48+
.switch,
49+
]
3950

4051
override func setUp() {
4152
continueAfterFailure = false
@@ -535,6 +546,10 @@ final class RunnerTests: XCTestCase {
535546
}
536547

537548
private func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
549+
if let blocking = blockingSystemAlertSnapshot() {
550+
return blocking
551+
}
552+
538553
var nodes: [SnapshotNode] = []
539554
var truncated = false
540555
let maxDepth = options.depth ?? Int.max
@@ -636,6 +651,10 @@ final class RunnerTests: XCTestCase {
636651
}
637652

638653
private func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
654+
if let blocking = blockingSystemAlertSnapshot() {
655+
return blocking
656+
}
657+
639658
let root = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
640659
var nodes: [SnapshotNode] = []
641660
var truncated = false
@@ -688,6 +707,136 @@ final class RunnerTests: XCTestCase {
688707
return DataPayload(nodes: nodes, truncated: truncated)
689708
}
690709

710+
private func blockingSystemAlertSnapshot() -> DataPayload? {
711+
guard let modal = firstBlockingSystemModal(in: springboard) else {
712+
return nil
713+
}
714+
let actions = actionableElements(in: modal)
715+
guard !actions.isEmpty else {
716+
return nil
717+
}
718+
719+
let title = preferredSystemModalTitle(modal)
720+
721+
var nodes: [SnapshotNode] = [
722+
makeSnapshotNode(
723+
element: modal,
724+
index: 0,
725+
type: "Alert",
726+
labelOverride: title,
727+
identifierOverride: modal.identifier,
728+
depth: 0,
729+
hittableOverride: true
730+
)
731+
]
732+
733+
for action in actions {
734+
nodes.append(
735+
makeSnapshotNode(
736+
element: action,
737+
index: nodes.count,
738+
type: elementTypeName(action.elementType),
739+
depth: 1,
740+
hittableOverride: true
741+
)
742+
)
743+
}
744+
745+
return DataPayload(nodes: nodes, truncated: false)
746+
}
747+
748+
private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
749+
for alert in springboard.alerts.allElementsBoundByIndex {
750+
if isBlockingSystemModal(alert, in: springboard) {
751+
return alert
752+
}
753+
}
754+
755+
for sheet in springboard.sheets.allElementsBoundByIndex {
756+
if isBlockingSystemModal(sheet, in: springboard) {
757+
return sheet
758+
}
759+
}
760+
761+
return nil
762+
}
763+
764+
private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
765+
guard element.exists else { return false }
766+
let frame = element.frame
767+
if frame.isNull || frame.isEmpty { return false }
768+
769+
let viewport = springboard.frame
770+
if viewport.isNull || viewport.isEmpty { return false }
771+
772+
let center = CGPoint(x: frame.midX, y: frame.midY)
773+
if !viewport.contains(center) { return false }
774+
775+
return true
776+
}
777+
778+
private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
779+
var seen = Set<String>()
780+
var actions: [XCUIElement] = []
781+
let descendants = element.descendants(matching: .any).allElementsBoundByIndex
782+
for candidate in descendants {
783+
if !candidate.exists || !candidate.isHittable { continue }
784+
if !actionableTypes.contains(candidate.elementType) { continue }
785+
let frame = candidate.frame
786+
if frame.isNull || frame.isEmpty { continue }
787+
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
788+
if seen.contains(key) { continue }
789+
seen.insert(key)
790+
actions.append(candidate)
791+
}
792+
return actions
793+
}
794+
795+
private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
796+
let label = element.label
797+
if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
798+
return label
799+
}
800+
let identifier = element.identifier
801+
if !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
802+
return identifier
803+
}
804+
return "System Alert"
805+
}
806+
807+
private func makeSnapshotNode(
808+
element: XCUIElement,
809+
index: Int,
810+
type: String,
811+
labelOverride: String? = nil,
812+
identifierOverride: String? = nil,
813+
depth: Int,
814+
hittableOverride: Bool? = nil
815+
) -> SnapshotNode {
816+
let label = (labelOverride ?? element.label).trimmingCharacters(in: .whitespacesAndNewlines)
817+
let identifier = (identifierOverride ?? element.identifier).trimmingCharacters(in: .whitespacesAndNewlines)
818+
return SnapshotNode(
819+
index: index,
820+
type: type,
821+
label: label.isEmpty ? nil : label,
822+
identifier: identifier.isEmpty ? nil : identifier,
823+
value: nil,
824+
rect: snapshotRect(from: element.frame),
825+
enabled: element.isEnabled,
826+
hittable: hittableOverride ?? element.isHittable,
827+
depth: depth
828+
)
829+
}
830+
831+
private func snapshotRect(from frame: CGRect) -> SnapshotRect {
832+
return SnapshotRect(
833+
x: Double(frame.origin.x),
834+
y: Double(frame.origin.y),
835+
width: Double(frame.size.width),
836+
height: Double(frame.size.height)
837+
)
838+
}
839+
691840
private func shouldInclude(
692841
element: XCUIElement,
693842
label: String,

0 commit comments

Comments
 (0)