| 1 | import Foundation |
| 2 | |
| 3 | // The registry runs on macOS without starting a simulator. Only the OS-facing |
| 4 | // types are replaced; the tests compile the dependency's actual Swift source. |
| 5 | public enum UIBackgroundFetchResult { |
| 6 | case noData |
| 7 | } |
| 8 | |
| 9 | public struct UNNotificationPresentationOptions: OptionSet { |
| 10 | public let rawValue: Int |
| 11 | public init(rawValue: Int) { self.rawValue = rawValue } |
| 12 | } |
| 13 | |
| 14 | public final class UNNotification: NSObject {} |
| 15 | |
| 16 | public final class UNNotificationResponse: NSObject { |
| 17 | let identifier: String |
| 18 | init(_ identifier: String) { self.identifier = identifier } |
| 19 | } |
| 20 | |
| 21 | public protocol UNUserNotificationCenterDelegate: AnyObject {} |
| 22 | |
| 23 | public final class UNUserNotificationCenter: NSObject { |
| 24 | private static let instance = UNUserNotificationCenter() |
| 25 | public weak var delegate: UNUserNotificationCenterDelegate? |
| 26 | public static func current() -> UNUserNotificationCenter { instance } |
| 27 | } |
| 28 | |
| 29 | private final class TestDelegate: NotificationDelegate { |
| 30 | private let lock = NSLock() |
| 31 | private var recordedEvents: [String] = [] |
| 32 | var onEvent: ((String) -> Void)? |
| 33 | var onResponse: ((UNNotificationResponse) -> Bool)? |
| 34 | |
| 35 | var events: [String] { lock.withLock { recordedEvents } } |
| 36 | |
| 37 | private func record(_ event: String) { |
| 38 | lock.withLock { recordedEvents.append(event) } |
| 39 | onEvent?(event) |
| 40 | } |
| 41 | |
| 42 | func didRegister(_ deviceToken: String) { record("registered") } |
| 43 | func didFailRegistration(_ error: Error) { record("failed") } |
| 44 | func openSettings(_ notification: UNNotification?) { record("settings") } |
| 45 | |
| 46 | func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { |
| 47 | record("present") |
| 48 | return false |
| 49 | } |
| 50 | |
| 51 | func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { |
| 52 | record("background") |
| 53 | return false |
| 54 | } |
| 55 | |
| 56 | func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { |
| 57 | record(response.identifier) |
| 58 | return onResponse?(response) ?? false |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { |
| 63 | if !condition() { |
| 64 | throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | private func deliver(_ identifier: String) { |
| 69 | NotificationCenterManager.shared.userNotificationCenter( |
| 70 | UNUserNotificationCenter.current(), |
| 71 | didReceive: UNNotificationResponse(identifier), |
| 72 | withCompletionHandler: {} |
| 73 | ) |
| 74 | } |
| 75 | |
| 76 | private func reentrantCallbacks() throws { |
| 77 | let manager = NotificationCenterManager.shared |
| 78 | let center = UNUserNotificationCenter.current() |
| 79 | let callbacks: [(String, () -> Void)] = [ |
| 80 | ("registered", { manager.didRegister("token") }), |
| 81 | ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), |
| 82 | ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), |
| 83 | ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), |
| 84 | ("background", { manager.didReceive([:], completionHandler: { _ in }) }) |
| 85 | ] |
| 86 | |
| 87 | for (event, callback) in callbacks { |
| 88 | let original = TestDelegate() |
| 89 | let replacement = TestDelegate() |
| 90 | original.onEvent = { [weak original] _ in |
| 91 | guard let original else { return } |
| 92 | manager.removeDelegate(original) |
| 93 | manager.addDelegate(replacement) |
| 94 | } |
| 95 | manager.addDelegate(original) |
| 96 | callback() |
| 97 | try require(original.events == [event], "The original delegate must receive the callback once") |
| 98 | try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") |
| 99 | callback() |
| 100 | try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") |
| 101 | manager.removeDelegate(replacement) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | private func registrationDuringDelivery() throws { |
| 106 | let manager = NotificationCenterManager.shared |
| 107 | let original = TestDelegate() |
| 108 | let receiver = TestDelegate() |
| 109 | receiver.onResponse = { _ in true } |
| 110 | original.onResponse = { _ in |
| 111 | manager.addDelegate(receiver) |
| 112 | return false |
| 113 | } |
| 114 | manager.addDelegate(original) |
| 115 | deliver("handoff") |
| 116 | try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") |
| 117 | manager.removeDelegate(original) |
| 118 | manager.removeDelegate(receiver) |
| 119 | } |
| 120 | |
| 121 | private func responseDuringReplay() throws { |
| 122 | let manager = NotificationCenterManager.shared |
| 123 | deliver("first") |
| 124 | let original = TestDelegate() |
| 125 | original.onResponse = { response in |
| 126 | if response.identifier == "first" { |
| 127 | deliver("second") |
| 128 | return true |
| 129 | } |
| 130 | return false |
| 131 | } |
| 132 | manager.addDelegate(original) |
| 133 | let receiver = TestDelegate() |
| 134 | receiver.onResponse = { _ in true } |
| 135 | manager.addDelegate(receiver) |
| 136 | try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") |
| 137 | manager.removeDelegate(original) |
| 138 | manager.removeDelegate(receiver) |
| 139 | } |
| 140 | |
| 141 | private func concurrentRegistrations() throws { |
| 142 | let manager = NotificationCenterManager.shared |
| 143 | let delegates = (0..<512).map { _ in |
| 144 | let delegate = TestDelegate() |
| 145 | delegate.onResponse = { _ in true } |
| 146 | return delegate |
| 147 | } |
| 148 | DispatchQueue.concurrentPerform(iterations: delegates.count) { index in |
| 149 | manager.addDelegate(delegates[index]) |
| 150 | if index.isMultiple(of: 16) { |
| 151 | manager.didRegister("during-add") |
| 152 | deliver("during-add") |
| 153 | } |
| 154 | } |
| 155 | let before = delegates.map { $0.events.count } |
| 156 | manager.didRegister("after-add") |
| 157 | for (index, delegate) in delegates.enumerated() { |
| 158 | try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") |
| 159 | } |
| 160 | DispatchQueue.concurrentPerform(iterations: delegates.count) { index in |
| 161 | manager.removeDelegate(delegates[index]) |
| 162 | if index.isMultiple(of: 16) { |
| 163 | manager.didRegister("during-remove") |
| 164 | deliver("during-remove") |
| 165 | } |
| 166 | } |
| 167 | let removed = delegates.map { $0.events.count } |
| 168 | manager.didRegister("after-remove") |
| 169 | try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") |
| 170 | } |
| 171 | |
| 172 | @main |
| 173 | private enum RegressionTests { |
| 174 | static func main() { |
| 175 | do { |
| 176 | switch CommandLine.arguments.last { |
| 177 | case "reentrant": try reentrantCallbacks() |
| 178 | case "handoff": try registrationDuringDelivery() |
| 179 | case "pending": try responseDuringReplay() |
| 180 | case "concurrent": try concurrentRegistrations() |
| 181 | default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) |
| 182 | } |
| 183 | print("passed") |
| 184 | } catch { |
| 185 | FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) |
| 186 | exit(1) |
| 187 | } |
| 188 | } |
| 189 | } |