@@ -9,10 +9,12 @@ import XCTest
99import Network
1010
1111final 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