From b54d9b0d4357bb11a5cbf35b351929136e1c54b7 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Thu, 2 Oct 2025 14:07:46 -0700 Subject: [PATCH 1/8] Added ability to subscribe to user state changes. --- Sources/Segment/Analytics.swift | 61 +++++- Sources/Segment/Settings.swift | 3 + Sources/Segment/State.swift | 6 +- Tests/Segment-Tests/Analytics_Tests.swift | 242 ++++++++++++++++++++++ 4 files changed, 309 insertions(+), 3 deletions(-) diff --git a/Sources/Segment/Analytics.swift b/Sources/Segment/Analytics.swift index 4917b079..c04dc9a2 100644 --- a/Sources/Segment/Analytics.swift +++ b/Sources/Segment/Analytics.swift @@ -292,7 +292,66 @@ extension Analytics { } } } - + + /// Subscribes to UserInfo state changes. + /// + /// The handler is called immediately with the current UserInfo, then again whenever + /// the user's identity, traits, or referrer changes. The subscription remains active + /// for the lifetime of the Analytics instance unless explicitly unsubscribed. + /// + /// - Parameter handler: A closure called on the main queue with updated UserInfo. + /// + /// - Returns: A subscription ID that can be passed to `unsubscribe(_:)` to stop + /// receiving updates. If you don't need to unsubscribe, you can ignore the return value. + /// + /// - Note: Multiple calls create multiple independent subscriptions. + /// + /// ## Example + /// ```swift + /// // Subscribe for the lifetime of Analytics + /// analytics.subscribeToUserInfo { userInfo in + /// print("User: \(userInfo.userId ?? userInfo.anonymousId)") + /// if let referrer = userInfo.referrer { + /// print("Referred from: \(referrer)") + /// } + /// } + /// + /// // Subscribe with manual cleanup + /// let subscriptionId = analytics.subscribeToUserInfo { userInfo in + /// // ... handle update + /// } + /// // Later, when you're done... + /// analytics.unsubscribe(subscriptionId) + /// ``` + @discardableResult + public func subscribeToUserInfo(handler: @escaping (UserInfo) -> ()) -> Int { + return store.subscribe(self, initialState: true, queue: .main) { (state: UserInfo) in + handler(state) + } + } + + /// Unsubscribes from state updates. + /// + /// Stops receiving updates for the subscription associated with the given ID. + /// After calling this, the handler will no longer be invoked for state changes. + /// + /// - Parameter id: The subscription ID returned from a previous subscribe call. + /// + /// - Note: Unsubscribing an already-unsubscribed or invalid ID is a no-op. + /// + /// ## Example + /// ```swift + /// let id = analytics.subscribeToUserInfo { userInfo in + /// print("User changed: \(userInfo.userId ?? "anonymous")") + /// } + /// + /// // Later, stop listening + /// analytics.unsubscribe(id) + /// ``` + public func unsubscribe(_ id: Int) { + store.unsubscribe(identifier: id) + } + /// Retrieve the version of this library in use. /// - Returns: A string representing the version in "BREAKING.FEATURE.FIX" format. public func version() -> String { diff --git a/Sources/Segment/Settings.swift b/Sources/Segment/Settings.swift index 28e23e3d..d7f82b1f 100644 --- a/Sources/Segment/Settings.swift +++ b/Sources/Segment/Settings.swift @@ -12,6 +12,7 @@ public struct Settings: Codable { public var plan: JSON? = nil public var edgeFunction: JSON? = nil public var middlewareSettings: JSON? = nil + public var autoInstrumentation: JSON? = nil public var metrics: JSON? = nil public var consentSettings: JSON? = nil @@ -39,6 +40,7 @@ public struct Settings: Codable { self.plan = try? values.decode(JSON.self, forKey: CodingKeys.plan) self.edgeFunction = try? values.decode(JSON.self, forKey: CodingKeys.edgeFunction) self.middlewareSettings = try? values.decode(JSON.self, forKey: CodingKeys.middlewareSettings) + self.autoInstrumentation = try? values.decode(JSON.self, forKey: CodingKeys.autoInstrumentation) self.metrics = try? values.decode(JSON.self, forKey: CodingKeys.metrics) self.consentSettings = try? values.decode(JSON.self, forKey: CodingKeys.consentSettings) } @@ -60,6 +62,7 @@ public struct Settings: Codable { case plan case edgeFunction case middlewareSettings + case autoInstrumentation case metrics case consentSettings } diff --git a/Sources/Segment/State.swift b/Sources/Segment/State.swift index 3b0e53ee..6d644f2d 100644 --- a/Sources/Segment/State.swift +++ b/Sources/Segment/State.swift @@ -166,14 +166,16 @@ struct System: State { // MARK: - User information -struct UserInfo: Codable, State { +public struct UserInfo: Codable, State { let anonymousId: String let userId: String? let traits: JSON let referrer: URL? @Noncodable var anonIdGenerator: AnonymousIdGenerator? - +} + +extension UserInfo { struct ResetAction: Action { func reduce(state: UserInfo) -> UserInfo { var anonId: String diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 1eb9cadf..8165e8b4 100644 --- a/Tests/Segment-Tests/Analytics_Tests.swift +++ b/Tests/Segment-Tests/Analytics_Tests.swift @@ -1052,4 +1052,246 @@ final class Analytics_Tests: XCTestCase { let trackEvent2: TrackEvent? = outputReader.lastEvent as? TrackEvent XCTAssertEqual(trackEvent2?.context?.value(forKeyPath: "__eventOrigin.type"), "mobile") } + + func testUserInfoSubscription() { + Storage.hardSettingsReset(writeKey: "test") + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + + waitUntilStarted(analytics: analytics) + + var callCount = 0 + var capturedUserInfo: UserInfo? + + let initialExpectation = XCTestExpectation(description: "Initial state received") + let identifyExpectation = XCTestExpectation(description: "Identify update received") + + // Subscribe and verify we get initial state immediately + let subscriptionId = analytics.subscribeToUserInfo { userInfo in + callCount += 1 + capturedUserInfo = userInfo + + if callCount == 1 { + initialExpectation.fulfill() + } else if callCount == 2 { + identifyExpectation.fulfill() + } + } + + // Wait for initial callback + wait(for: [initialExpectation], timeout: 2.0) + + XCTAssertEqual(1, callCount) + XCTAssertNotNil(capturedUserInfo) + XCTAssertNotNil(capturedUserInfo?.anonymousId) + XCTAssertNil(capturedUserInfo?.userId) + + let initialAnonId = analytics.anonymousId + XCTAssertEqual(initialAnonId, capturedUserInfo?.anonymousId) + + // Update user info and verify handler is called again + analytics.identify(userId: "brandon", traits: MyTraits(email: "blah@blah.com")) + + wait(for: [identifyExpectation], timeout: 2.0) + + XCTAssertEqual(2, callCount) + XCTAssertEqual("brandon", capturedUserInfo?.userId) + XCTAssertEqual("brandon", analytics.userId) + + let traits: MyTraits? = analytics.traits() + XCTAssertEqual("blah@blah.com", traits?.email) + + // Unsubscribe and verify handler stops firing + analytics.unsubscribe(subscriptionId) + + let oldCallCount = callCount + analytics.identify(userId: "different_user") + + // Give it a moment to potentially fire (it shouldn't) + let noCallExpectation = XCTestExpectation(description: "Should not be called") + noCallExpectation.isInverted = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if callCount > oldCallCount { + noCallExpectation.fulfill() + } + } + + wait(for: [noCallExpectation], timeout: 1.0) + XCTAssertEqual(oldCallCount, callCount) + XCTAssertEqual("brandon", capturedUserInfo?.userId) // Still has old value + } + + func testUserInfoSubscriptionWithReset() { + Storage.hardSettingsReset(writeKey: "test") + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + + waitUntilStarted(analytics: analytics) + + var callCount = 0 + var capturedUserInfo: UserInfo? + + let initialExpectation = XCTestExpectation(description: "Initial") + let identifyExpectation = XCTestExpectation(description: "Identify") + let resetExpectation = XCTestExpectation(description: "Reset") + + analytics.subscribeToUserInfo { userInfo in + callCount += 1 + capturedUserInfo = userInfo + + if callCount == 1 { + initialExpectation.fulfill() + } else if callCount == 2 { + identifyExpectation.fulfill() + } else if callCount == 3 { + resetExpectation.fulfill() + } + } + + wait(for: [initialExpectation], timeout: 2.0) + + let originalAnonId = capturedUserInfo?.anonymousId + XCTAssertEqual(1, callCount) + + // Set some user data + analytics.identify(userId: "brandon", traits: MyTraits(email: "blah@blah.com")) + wait(for: [identifyExpectation], timeout: 2.0) + + XCTAssertEqual(2, callCount) + XCTAssertEqual("brandon", capturedUserInfo?.userId) + + // Reset and verify handler is called with cleared data + analytics.reset() + wait(for: [resetExpectation], timeout: 2.0) + + XCTAssertEqual(3, callCount) + XCTAssertNil(capturedUserInfo?.userId) + XCTAssertNil(capturedUserInfo?.referrer) + XCTAssertNotEqual(originalAnonId, capturedUserInfo?.anonymousId) + + // Check analytics state AFTER waiting for callback + let traitsDict: [String: Any]? = analytics.traits() + XCTAssertEqual(traitsDict?.count, 0) + } + + func testUserInfoSubscriptionWithReferrer() { + Storage.hardSettingsReset(writeKey: "test") + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + + waitUntilStarted(analytics: analytics) + + var callCount = 0 + var capturedUserInfo: UserInfo? + + let initialExpectation = XCTestExpectation(description: "Initial") + let referrerExpectation = XCTestExpectation(description: "Referrer") + + analytics.subscribeToUserInfo { userInfo in + callCount += 1 + capturedUserInfo = userInfo + + if callCount == 1 { + initialExpectation.fulfill() + } else if callCount == 2 { + referrerExpectation.fulfill() + } + } + + wait(for: [initialExpectation], timeout: 2.0) + + XCTAssertEqual(1, callCount) + XCTAssertNil(capturedUserInfo?.referrer) + + // Set a referrer + analytics.openURL(URL(string: "https://google.com")!) + wait(for: [referrerExpectation], timeout: 2.0) + + XCTAssertEqual(2, callCount) + XCTAssertEqual("https://google.com", capturedUserInfo?.referrer?.absoluteString) + } + + func testMultipleUserInfoSubscriptions() { + Storage.hardSettingsReset(writeKey: "test") + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + + waitUntilStarted(analytics: analytics) + + var firstCallCount = 0 + var secondCallCount = 0 + + let initialExpectation = XCTestExpectation(description: "Initial callbacks") + initialExpectation.expectedFulfillmentCount = 2 // Both subscriptions + + let identifyExpectation = XCTestExpectation(description: "Identify callbacks") + identifyExpectation.expectedFulfillmentCount = 2 // Both subscriptions + + // Create two subscriptions + analytics.subscribeToUserInfo { _ in + firstCallCount += 1 + if firstCallCount == 1 { + initialExpectation.fulfill() + } else if firstCallCount == 2 { + identifyExpectation.fulfill() + } + } + + analytics.subscribeToUserInfo { _ in + secondCallCount += 1 + if secondCallCount == 1 { + initialExpectation.fulfill() + } else if secondCallCount == 2 { + identifyExpectation.fulfill() + } + } + + // Both should be called for initial state + wait(for: [initialExpectation], timeout: 2.0) + XCTAssertEqual(1, firstCallCount) + XCTAssertEqual(1, secondCallCount) + + // Both should fire when state changes + analytics.identify(userId: "brandon") + wait(for: [identifyExpectation], timeout: 2.0) + + XCTAssertEqual(2, firstCallCount) + XCTAssertEqual(2, secondCallCount) + } + + func testUserInfoSubscriptionCalledOnMainQueue() { + Storage.hardSettingsReset(writeKey: "test") + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + + waitUntilStarted(analytics: analytics) + + let expectation = XCTestExpectation(description: "Handler called on main queue") + expectation.expectedFulfillmentCount = 2 // Initial + identify + + analytics.subscribeToUserInfo { userInfo in + XCTAssertTrue(Thread.isMainThread, "Handler should be called on main thread") + expectation.fulfill() + } + + analytics.identify(userId: "brandon") + + wait(for: [expectation], timeout: 2.0) + } + + func testUnsubscribeWithInvalidId() { + Storage.hardSettingsReset(writeKey: "test") + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + + waitUntilStarted(analytics: analytics) + + // Should not crash with invalid ID + analytics.unsubscribe(999999) + analytics.unsubscribe(-1) + + // Should work fine after bogus unsubscribe calls + let expectation = XCTestExpectation(description: "Subscription works after invalid unsubscribe") + + analytics.subscribeToUserInfo { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2.0) + } } From 3125a0a8e1dd324487486f42e515a188bf277fe7 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 10:34:03 -0700 Subject: [PATCH 2/8] Made some logging ability public. --- Sources/Segment/Utilities/Logging.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Segment/Utilities/Logging.swift b/Sources/Segment/Utilities/Logging.swift index 98868ec0..5204f3f6 100644 --- a/Sources/Segment/Utilities/Logging.swift +++ b/Sources/Segment/Utilities/Logging.swift @@ -8,14 +8,14 @@ import Foundation extension Analytics { - internal enum LogKind: CustomStringConvertible, CustomDebugStringConvertible { + public enum LogKind: CustomStringConvertible, CustomDebugStringConvertible { case error case warning case debug case none - var description: String { return string } - var debugDescription: String { return string } + public var description: String { return string } + public var debugDescription: String { return string } var string: String { switch self { @@ -35,7 +35,7 @@ extension Analytics { Self.segmentLog(message: message, kind: .none) } - static internal func segmentLog(message: String, kind: LogKind) { + static public func segmentLog(message: String, kind: LogKind) { #if DEBUG if Self.debugLogsEnabled { print("\(kind)\(message)") From ae83036367bdad2fdea5a6099b2cffc29048582a Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 11:05:12 -0700 Subject: [PATCH 3/8] test ci update --- .github/workflows/swift.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index a3c02f57..d534df83 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -51,11 +51,11 @@ jobs: build_and_test_ios: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: From e14a6e15fcb3dff8303388fb7b98367c366458d4 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 11:09:29 -0700 Subject: [PATCH 4/8] more ci updates --- .github/workflows/swift.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index d534df83..10c0c0d4 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -60,7 +60,7 @@ jobs: - uses: webfactory/ssh-agent@v0.8.0 with: ssh-private-key: ${{ secrets.SOVRAN_SSH_KEY }} - - run: xcodebuild -scheme Segment test -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16' + - run: xcodebuild -scheme Segment test -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build_and_test_tvos: needs: cancel_previous @@ -107,11 +107,11 @@ jobs: build_and_test_examples: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: From 7411f7494d12b3381290bd0543cdff75647a9e5b Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 11:23:54 -0700 Subject: [PATCH 5/8] more test stuff --- .github/workflows/swift.yml | 20 +- Tests/Segment-Tests/Analytics_Tests.swift | 529 +++++++++++----------- 2 files changed, 284 insertions(+), 265 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 10c0c0d4..3eb85595 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -16,11 +16,11 @@ jobs: generate_code_coverage: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: @@ -37,11 +37,11 @@ jobs: build_and_test_spm_mac: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: @@ -64,11 +64,11 @@ jobs: build_and_test_tvos: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: @@ -77,11 +77,11 @@ jobs: build_and_test_watchos: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: @@ -90,11 +90,11 @@ jobs: build_and_test_visionos: needs: cancel_previous - runs-on: macos-15 + runs-on: macos-26 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "16.2" + xcode-version: "26" - uses: actions/checkout@v2 - uses: webfactory/ssh-agent@v0.8.0 with: diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 8165e8b4..8251fd1b 100644 --- a/Tests/Segment-Tests/Analytics_Tests.swift +++ b/Tests/Segment-Tests/Analytics_Tests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Segment final class Analytics_Tests: XCTestCase { @@ -16,7 +17,7 @@ final class Analytics_Tests: XCTestCase { let traits = MyTraits(email: "brandon@redf.net") analytics.identify(userId: "brandon", traits: traits) - + waitUntilStarted(analytics: analytics) checkIfLeaked(analytics) } @@ -34,7 +35,7 @@ final class Analytics_Tests: XCTestCase { XCTAssertNotNil(ziggy.analytics) XCTAssertNotNil(myDestination.analytics) XCTAssertNotNil(goober.analytics) - + waitUntilStarted(analytics: analytics) } @@ -54,7 +55,7 @@ final class Analytics_Tests: XCTestCase { let traits = MyTraits(email: "brandon@redf.net") analytics.identify(userId: "brandon", traits: traits) analytics.remove(plugin: ziggy) - + wait(for: [expectation], timeout: .infinity) } @@ -92,17 +93,16 @@ final class Analytics_Tests: XCTestCase { let dest = analytics.find(key: myDestination.key) XCTAssertNotNil(dest) XCTAssertTrue(dest is MyDestination) - + wait(for: [expectation], timeout: .infinity) - + XCTAssertEqual(myDestination.receivedInitialUpdate, 1) XCTAssertEqual(ziggy1.receivedInitialUpdate, 1) XCTAssertEqual(ziggy2.receivedInitialUpdate, 1) - + checkIfLeaked(analytics) } - func testDestinationEnabled() { // need to clear settings for this one. UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.test") @@ -132,44 +132,44 @@ final class Analytics_Tests: XCTestCase { let dest = analytics.find(key: myDestination.key) XCTAssertNotNil(dest) XCTAssertTrue(dest is MyDestination) - + wait(for: [expectation], timeout: .infinity) } // Linux & Windows don't support XCTExpectFailure -#if !os(Linux) && !os(Windows) - func testDestinationNotEnabled() { - // need to clear settings for this one. - UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.test") + #if !os(Linux) && !os(Windows) + func testDestinationNotEnabled() { + // need to clear settings for this one. + UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.test") + + let expectation = XCTestExpectation(description: "MyDestination Expectation") + let myDestination = MyDestination(disabled: true) { + expectation.fulfill() + print("called") + return true + } - let expectation = XCTestExpectation(description: "MyDestination Expectation") - let myDestination = MyDestination(disabled: true) { - expectation.fulfill() - print("called") - return true - } - - let configuration = Configuration(writeKey: "testDestNotEnabled") - let analytics = Analytics(configuration: configuration) + let configuration = Configuration(writeKey: "testDestNotEnabled") + let analytics = Analytics(configuration: configuration) - analytics.add(plugin: myDestination) + analytics.add(plugin: myDestination) - waitUntilStarted(analytics: analytics) + waitUntilStarted(analytics: analytics) - analytics.track(name: "testDestinationEnabled") + analytics.track(name: "testDestinationEnabled") - XCTExpectFailure { - wait(for: [expectation], timeout: 1.0) + XCTExpectFailure { + wait(for: [expectation], timeout: 1.0) + } } - } -#endif + #endif func testAnonymousId() { let analytics = Analytics(configuration: Configuration(writeKey: "test")) let anonId = analytics.anonymousId XCTAssertTrue(anonId != "") - XCTAssertTrue(anonId.count == 36) // it's a UUID y0. + XCTAssertTrue(anonId.count == 36) // it's a UUID y0. waitUntilStarted(analytics: analytics) } @@ -201,13 +201,12 @@ final class Analytics_Tests: XCTestCase { XCTAssertEqual(referrer["url"] as! String, "https://google.com") // these keys not present on linux or Windows -#if !os(Linux) && !os(Windows) - XCTAssertNotNil(context?["app"], "app missing!") - XCTAssertNotNil(context?["locale"], "locale missing!") -#endif + #if !os(Linux) && !os(Windows) + XCTAssertNotNil(context?["app"], "app missing!") + XCTAssertNotNil(context?["locale"], "locale missing!") + #endif } - func testContextWithUserAgent() { let configuration = Configuration(writeKey: "test") configuration.userAgent("testing user agent") @@ -236,12 +235,12 @@ final class Analytics_Tests: XCTestCase { let referrer = context?["referrer"] as! [String: Any] XCTAssertEqual(referrer["url"] as! String, "https://google.com") - + // these keys not present on linux -#if !os(Linux) && !os(Windows) - XCTAssertNotNil(context?["app"], "app missing!") - XCTAssertNotNil(context?["locale"], "locale missing!") -#endif + #if !os(Linux) && !os(Windows) + XCTAssertNotNil(context?["app"], "app missing!") + XCTAssertNotNil(context?["locale"], "locale missing!") + #endif } func testDeviceToken() { @@ -260,39 +259,39 @@ final class Analytics_Tests: XCTestCase { XCTAssertTrue(token == "1234") } -#if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - func testDeviceTokenData() { - let analytics = Analytics(configuration: Configuration(writeKey: "test")) - let outputReader = OutputReaderPlugin() - analytics.add(plugin: outputReader) + #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) + func testDeviceTokenData() { + let analytics = Analytics(configuration: Configuration(writeKey: "test")) + let outputReader = OutputReaderPlugin() + analytics.add(plugin: outputReader) - waitUntilStarted(analytics: analytics) + waitUntilStarted(analytics: analytics) - let dataToken = UUID().asData() - analytics.registeredForRemoteNotifications(deviceToken: dataToken) - analytics.track(name: "token check") + let dataToken = UUID().asData() + analytics.registeredForRemoteNotifications(deviceToken: dataToken) + analytics.track(name: "token check") + + let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent + let device = trackEvent?.context?.dictionaryValue + let token = device?[keyPath: "device.token"] as? String + XCTAssertTrue(token?.count == 32) // it's a uuid w/o the dashes. 36 becomes 32. + } + #endif - let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent - let device = trackEvent?.context?.dictionaryValue - let token = device?[keyPath: "device.token"] as? String - XCTAssertTrue(token?.count == 32) // it's a uuid w/o the dashes. 36 becomes 32. - } -#endif - func testOpenURL() { let analytics = Analytics(configuration: Configuration(writeKey: "test")) let outputReader = OutputReaderPlugin() analytics.add(plugin: outputReader) waitUntilStarted(analytics: analytics) - + let url = URL(string: "https://blah.com")! - + // you ain't got no options Lt. Dan! analytics.openURL(url) let urlEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent XCTAssertEqual(urlEvent?.properties?.dictionaryValue!["url"] as! String, "https://blah.com") - + // Anyway, like I was sayin' ... let options = [ "Shrimp": [ @@ -303,7 +302,7 @@ final class Analytics_Tests: XCTestCase { "broil", "bake", "saute", - "fried (implied)" + "fried (implied)", ], "Types": [ "shrimp kabobs", @@ -321,14 +320,15 @@ final class Analytics_Tests: XCTestCase { "shrimp and potatoes", "shrimp burger", "shrimp sandwich", - "That- that's about it" - ] + "That- that's about it", + ], ] ] - + analytics.openURL(url, options: options) let urlOptionEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent - XCTAssertEqual(urlOptionEvent?.properties?.dictionaryValue!["url"] as! String, "https://blah.com") + XCTAssertEqual( + urlOptionEvent?.properties?.dictionaryValue!["url"] as! String, "https://blah.com") XCTAssertNotNil(urlOptionEvent?.properties?.dictionaryValue!["Shrimp"]) } @@ -353,7 +353,7 @@ final class Analytics_Tests: XCTestCase { analytics.add(plugin: outputReader) waitUntilStarted(analytics: analytics) - + // traits should be an empty object. let currentTraits = analytics.traits() XCTAssertNotNil(currentTraits) @@ -365,9 +365,9 @@ final class Analytics_Tests: XCTestCase { XCTAssertTrue(identifyEvent?.userId == "brandon") let traits = identifyEvent?.traits?.dictionaryValue XCTAssertTrue(traits?["email"] as? String == "blah@blah.com") - + analytics.reset() - + let emptyTraits = analytics.traits() XCTAssertNotNil(emptyTraits) XCTAssertTrue(emptyTraits!.isEmpty == true) @@ -399,7 +399,6 @@ final class Analytics_Tests: XCTestCase { XCTAssertEqual("blah@blah.com", analyticsTraits?.email) } - func testScreen() { let analytics = Analytics(configuration: Configuration(writeKey: "test")) let outputReader = OutputReaderPlugin() @@ -413,7 +412,8 @@ final class Analytics_Tests: XCTestCase { XCTAssertTrue(screen1Event?.name == "screen1") XCTAssertTrue(screen1Event?.category == "category1") - analytics.screen(title: "screen2", category: "category2", properties: MyTraits(email: "blah@blah.com")) + analytics.screen( + title: "screen2", category: "category2", properties: MyTraits(email: "blah@blah.com")) let screen2Event: ScreenEvent? = outputReader.lastEvent as? ScreenEvent XCTAssertTrue(screen2Event?.name == "screen2") @@ -471,23 +471,27 @@ final class Analytics_Tests: XCTestCase { func testFlush() { // Use a specific writekey to this test so we do not collide with other cached items. - let analytics = Analytics(configuration: Configuration(writeKey: "testFlush_do_not_reuse_this_writekey").flushInterval(9999).flushAt(9999)) + let analytics = Analytics( + configuration: Configuration(writeKey: "testFlush_do_not_reuse_this_writekey") + .flushInterval(9999).flushAt(9999)) waitUntilStarted(analytics: analytics) analytics.storage.hardReset(doYouKnowHowToUseThis: true) analytics.identify(userId: "brandon", traits: MyTraits(email: "blah@blah.com")) - + let currentBatchCount = analytics.storage.read(.events)!.dataFiles!.count - + analytics.flush() analytics.track(name: "test") - + let batches = analytics.storage.read(.events)!.dataFiles let newBatchCount = batches!.count // 1 new temp file - XCTAssertTrue(newBatchCount == currentBatchCount + 1, "New Count (\(newBatchCount)) should be \(currentBatchCount) + 1") + XCTAssertTrue( + newBatchCount == currentBatchCount + 1, + "New Count (\(newBatchCount)) should be \(currentBatchCount) + 1") } func testEnabled() { @@ -555,10 +559,11 @@ final class Analytics_Tests: XCTestCase { func testPurgeStorage() { // Use a specific writekey to this test so we do not collide with other cached items. - let analytics = Analytics(configuration: Configuration(writeKey: "testFlush_do_not_reuse_this_writekey_either") - .flushInterval(9999) - .flushAt(9999) - .operatingMode(.synchronous)) + let analytics = Analytics( + configuration: Configuration(writeKey: "testFlush_do_not_reuse_this_writekey_either") + .flushInterval(9999) + .flushAt(9999) + .operatingMode(.synchronous)) waitUntilStarted(analytics: analytics) @@ -578,14 +583,14 @@ final class Analytics_Tests: XCTestCase { analytics.flush() analytics.track(name: "test") - + let newPendingCount = analytics.pendingUploads!.count XCTAssertEqual(newPendingCount, 1) let pending = analytics.pendingUploads! analytics.purgeStorage(fileURL: pending.first!) XCTAssertNil(analytics.pendingUploads) - + analytics.purgeStorage() XCTAssertNil(analytics.pendingUploads) } @@ -612,7 +617,7 @@ final class Analytics_Tests: XCTestCase { let type: PluginType let key: String weak var analytics: Analytics? - + init(key: String) { self.key = key self.type = .destination @@ -639,17 +644,16 @@ final class Analytics_Tests: XCTestCase { [ "Customer.io", "Mixpanel", - "Amplitude" + "Amplitude", ] ]), - "Mixpanel": JSON(["someKey": "someVal"]) + "Mixpanel": JSON(["someKey": "someVal"]), ]) settings.integrations = integrations analytics.store.dispatch(action: System.UpdateSettingsAction(settings: settings)) waitUntilStarted(analytics: analytics) - analytics.track(name: "sampleEvent") let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent @@ -681,14 +685,13 @@ final class Analytics_Tests: XCTestCase { ]), "Mixpanel": JSON(["someKey": "someVal"]), "Amplitude": JSON(["someKey": "somVal"]), - "dest1": JSON(["someKey": "someVal"]) + "dest1": JSON(["someKey": "someVal"]), ]) settings.integrations = integrations analytics.store.dispatch(action: System.UpdateSettingsAction(settings: settings)) waitUntilStarted(analytics: analytics) - analytics.track(name: "sampleEvent") let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent @@ -701,14 +704,17 @@ final class Analytics_Tests: XCTestCase { func testRequestFactory() { let config = Configuration(writeKey: "testSequential").requestFactory { request in XCTAssertEqual(request.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=utf-8") - XCTAssertTrue(request.value(forHTTPHeaderField: "User-Agent")!.contains("analytics-ios/")) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=utf-8" + ) + XCTAssertTrue( + request.value(forHTTPHeaderField: "User-Agent")!.contains("analytics-ios/")) return request }.errorHandler { error in switch error { case AnalyticsError.networkServerRejected(_, _): // we expect this one; it's a bogus writekey - break; + break default: XCTFail("\(error)") } @@ -788,39 +794,41 @@ final class Analytics_Tests: XCTestCase { func testAsyncOperatingMode() throws { // Use a specific writekey to this test so we do not collide with other cached items. - let analytics = Analytics(configuration: Configuration(writeKey: "testFlush_asyncMode") - .flushInterval(9999) - .flushAt(9999) - .operatingMode(.asynchronous)) + let analytics = Analytics( + configuration: Configuration(writeKey: "testFlush_asyncMode") + .flushInterval(9999) + .flushAt(9999) + .operatingMode(.asynchronous)) waitUntilStarted(analytics: analytics) analytics.storage.hardReset(doYouKnowHowToUseThis: true) let expectation = XCTestExpectation() - + // put an event in the pipe ... analytics.track(name: "completion test1") - + RunLoop.main.run(until: .distantPast) - + // flush it, that'll get us an upload going analytics.flush { // verify completion is called. expectation.fulfill() } - + wait(for: [expectation], timeout: .infinity) - + XCTAssertNil(analytics.pendingUploads) } func testSyncOperatingMode() throws { // Use a specific writekey to this test so we do not collide with other cached items. - let analytics = Analytics(configuration: Configuration(writeKey: "testFlush_syncMode") - .flushInterval(9999) - .flushAt(9999) - .operatingMode(.synchronous)) + let analytics = Analytics( + configuration: Configuration(writeKey: "testFlush_syncMode") + .flushInterval(9999) + .flushAt(9999) + .operatingMode(.synchronous)) waitUntilStarted(analytics: analytics) @@ -834,11 +842,11 @@ final class Analytics_Tests: XCTestCase { // verify completion is called. expectation.fulfill() } - + wait(for: [expectation], timeout: .infinity) - + XCTAssertNil(analytics.pendingUploads) - + // put another event in the pipe. analytics.track(name: "completion test2") analytics.flush() @@ -849,10 +857,11 @@ final class Analytics_Tests: XCTestCase { } func testFindAll() throws { - let analytics = Analytics(configuration: Configuration(writeKey: "testFindAll") - .flushInterval(9999) - .flushAt(9999) - .operatingMode(.synchronous)) + let analytics = Analytics( + configuration: Configuration(writeKey: "testFindAll") + .flushInterval(9999) + .flushAt(9999) + .operatingMode(.synchronous)) analytics.add(plugin: ZiggyPlugin()) analytics.add(plugin: ZiggyPlugin()) @@ -872,94 +881,97 @@ final class Analytics_Tests: XCTestCase { XCTAssertEqual(ziggysFound!.count, 3) XCTAssertEqual(goobersFound!.count, 2) } - + func testJSONNaNDefaultHandlingZero() throws { // notice we didn't set the nan handling option. zero is the default. let analytics = Analytics(configuration: Configuration(writeKey: "test")) let outputReader = OutputReaderPlugin() analytics.add(plugin: outputReader) - + waitUntilStarted(analytics: analytics) - + analytics.track(name: "test track", properties: ["TestNaN": Double.nan]) - + let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent XCTAssertTrue(trackEvent?.event == "test track") XCTAssertTrue(trackEvent?.type == "track") let d: Double? = trackEvent?.properties?.value(forKeyPath: "TestNaN") XCTAssertTrue(d! == 0) } - + func testJSONNaNHandlingNull() throws { - let analytics = Analytics(configuration: Configuration(writeKey: "test") - .jsonNonConformingNumberStrategy(.null) + let analytics = Analytics( + configuration: Configuration(writeKey: "test") + .jsonNonConformingNumberStrategy(.null) ) let outputReader = OutputReaderPlugin() analytics.add(plugin: outputReader) - + waitUntilStarted(analytics: analytics) - + analytics.track(name: "test track", properties: ["TestNaN": Double.nan]) - + let trackEvent: TrackEvent? = outputReader.lastEvent as? TrackEvent XCTAssertTrue(trackEvent?.event == "test track") XCTAssertTrue(trackEvent?.type == "track") let d: Double? = trackEvent?.properties?.value(forKeyPath: "TestNaN") XCTAssertNil(d) } - + // Linux doesn't know what URLProtocol is and on watchOS it somehow works differently and isn't hit. #if !os(Linux) && !os(watchOS) && !os(Windows) - func testFailedSegmentResponse() throws { - //register our network blocker (returns 400 response) - guard URLProtocol.registerClass(FailedNetworkCalls.self) else { - XCTFail(); return } - - let analytics = Analytics(configuration: Configuration(writeKey: "networkTest")) - - waitUntilStarted(analytics: analytics) - - //set the httpClient to use our blocker session - let segment = analytics.find(pluginType: SegmentDestination.self) - let configuration = URLSessionConfiguration.ephemeral - configuration.allowsCellularAccess = true - configuration.timeoutIntervalForRequest = 30 - configuration.timeoutIntervalForRequest = 60 - configuration.httpMaximumConnectionsPerHost = 2 - configuration.protocolClasses = [FailedNetworkCalls.self] - configuration.httpAdditionalHeaders = [ - "Content-Type": "application/json; charset=utf-8", - "Authorization": "Basic test", - "User-Agent": "analytics-ios/\(Analytics.version())" - ] - - let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil) - - segment?.httpClient?.session = blockSession - - analytics.track(name: "test track", properties: ["Malformed Paylod": "My Failed Prop"]) - - //get fileUrl from track call - let storedEvents = analytics.storage.read(.events) - let fileURL = storedEvents!.dataFiles![0] - - - let expectation = XCTestExpectation() - - analytics.flush { - expectation.fulfill() + func testFailedSegmentResponse() throws { + //register our network blocker (returns 400 response) + guard URLProtocol.registerClass(FailedNetworkCalls.self) else { + XCTFail() + return + } + + let analytics = Analytics(configuration: Configuration(writeKey: "networkTest")) + + waitUntilStarted(analytics: analytics) + + //set the httpClient to use our blocker session + let segment = analytics.find(pluginType: SegmentDestination.self) + let configuration = URLSessionConfiguration.ephemeral + configuration.allowsCellularAccess = true + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForRequest = 60 + configuration.httpMaximumConnectionsPerHost = 2 + configuration.protocolClasses = [FailedNetworkCalls.self] + configuration.httpAdditionalHeaders = [ + "Content-Type": "application/json; charset=utf-8", + "Authorization": "Basic test", + "User-Agent": "analytics-ios/\(Analytics.version())", + ] + + let blockSession = URLSession( + configuration: configuration, delegate: nil, delegateQueue: nil) + + segment?.httpClient?.session = blockSession + + analytics.track(name: "test track", properties: ["Malformed Paylod": "My Failed Prop"]) + + //get fileUrl from track call + let storedEvents = analytics.storage.read(.events) + let fileURL = storedEvents!.dataFiles![0] + + let expectation = XCTestExpectation() + + analytics.flush { + expectation.fulfill() + } + + wait(for: [expectation], timeout: .infinity) + + let newStoredEvents: [URL]? = analytics.storage.read(.events) + + XCTAssertNil(newStoredEvents) + + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } - - wait(for: [expectation], timeout: .infinity) - - let newStoredEvents: [URL]? = analytics.storage.read(.events) - - XCTAssertNil(newStoredEvents) - - XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) - } #endif - + func testAnonIDGenerator() throws { class MyAnonIdGenerator: AnonymousIdGenerator { var currentId: String = "blah-" @@ -968,30 +980,32 @@ final class Analytics_Tests: XCTestCase { return currentId } } - + // need to clear settings for this one. UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.anonIdGenerator") - + let anonIdGenerator = MyAnonIdGenerator() - var analytics: Analytics? = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator)) + var analytics: Analytics? = Analytics( + configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator( + anonIdGenerator)) let outputReader = OutputReaderPlugin() analytics?.add(plugin: outputReader) - + waitUntilStarted(analytics: analytics) XCTAssertEqual(analytics?.anonymousId, "blah-1") - + analytics?.track(name: "Test1") XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1") XCTAssertEqual(anonIdGenerator.currentId, "blah-1") XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) - + analytics?.track(name: "Test2") XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1") XCTAssertEqual(anonIdGenerator.currentId, "blah-1") XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) analytics?.reset() - + analytics?.track(name: "Test3") XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11") XCTAssertEqual(anonIdGenerator.currentId, "blah-11") @@ -1001,49 +1015,53 @@ final class Analytics_Tests: XCTestCase { XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11") XCTAssertEqual(anonIdGenerator.currentId, "blah-11") XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) - + analytics?.reset() - + analytics?.screen(title: "Screen") XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111") XCTAssertEqual(anonIdGenerator.currentId, "blah-111") XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) - + // get rid of this instance, leave it time to go away ... // ... also let any state updates happen as handlers get called async - RunLoop.main.run(until: .distantPast) + /*RunLoop.main.run(until: .distantPast) analytics = nil // ... give it some time to release all it's stuff. - RunLoop.main.run(until: .distantPast) - + RunLoop.main.run(until: .distantPast)*/ + // make sure it makes it to the next instance - analytics = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator)) + analytics = Analytics( + configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator( + anonIdGenerator)) analytics?.add(plugin: outputReader) - + waitUntilStarted(analytics: analytics) - + // same anonId as last time, yes? analytics?.screen(title: "Screen") XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111") XCTAssertEqual(anonIdGenerator.currentId, "blah-111") XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) } - + func testSingularEnrichment() throws { let analytics = Analytics(configuration: Configuration(writeKey: "test")) let outputReader = OutputReaderPlugin() analytics.add(plugin: outputReader) - + let addEventOrigin: EnrichmentClosure = { event in - return Context.insertOrigin(event: event, data: [ - "type": "mobile" - ]) + return Context.insertOrigin( + event: event, + data: [ + "type": "mobile" + ]) } - + analytics.track(name: "enrichment check pre startup", enrichments: [addEventOrigin]) waitUntilStarted(analytics: analytics) - + let trackEvent1: TrackEvent? = outputReader.lastEvent as? TrackEvent XCTAssertEqual(trackEvent1?.context?.value(forKeyPath: "__eventOrigin.type"), "mobile") @@ -1052,92 +1070,92 @@ final class Analytics_Tests: XCTestCase { let trackEvent2: TrackEvent? = outputReader.lastEvent as? TrackEvent XCTAssertEqual(trackEvent2?.context?.value(forKeyPath: "__eventOrigin.type"), "mobile") } - + func testUserInfoSubscription() { Storage.hardSettingsReset(writeKey: "test") let analytics = Analytics(configuration: Configuration(writeKey: "test")) - + waitUntilStarted(analytics: analytics) - + var callCount = 0 var capturedUserInfo: UserInfo? - + let initialExpectation = XCTestExpectation(description: "Initial state received") let identifyExpectation = XCTestExpectation(description: "Identify update received") - + // Subscribe and verify we get initial state immediately let subscriptionId = analytics.subscribeToUserInfo { userInfo in callCount += 1 capturedUserInfo = userInfo - + if callCount == 1 { initialExpectation.fulfill() } else if callCount == 2 { identifyExpectation.fulfill() } } - + // Wait for initial callback wait(for: [initialExpectation], timeout: 2.0) - + XCTAssertEqual(1, callCount) XCTAssertNotNil(capturedUserInfo) XCTAssertNotNil(capturedUserInfo?.anonymousId) XCTAssertNil(capturedUserInfo?.userId) - + let initialAnonId = analytics.anonymousId XCTAssertEqual(initialAnonId, capturedUserInfo?.anonymousId) - + // Update user info and verify handler is called again analytics.identify(userId: "brandon", traits: MyTraits(email: "blah@blah.com")) - + wait(for: [identifyExpectation], timeout: 2.0) - + XCTAssertEqual(2, callCount) XCTAssertEqual("brandon", capturedUserInfo?.userId) XCTAssertEqual("brandon", analytics.userId) - + let traits: MyTraits? = analytics.traits() XCTAssertEqual("blah@blah.com", traits?.email) - + // Unsubscribe and verify handler stops firing analytics.unsubscribe(subscriptionId) - + let oldCallCount = callCount analytics.identify(userId: "different_user") - + // Give it a moment to potentially fire (it shouldn't) let noCallExpectation = XCTestExpectation(description: "Should not be called") noCallExpectation.isInverted = true - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if callCount > oldCallCount { noCallExpectation.fulfill() } } - + wait(for: [noCallExpectation], timeout: 1.0) XCTAssertEqual(oldCallCount, callCount) - XCTAssertEqual("brandon", capturedUserInfo?.userId) // Still has old value + XCTAssertEqual("brandon", capturedUserInfo?.userId) // Still has old value } - + func testUserInfoSubscriptionWithReset() { Storage.hardSettingsReset(writeKey: "test") let analytics = Analytics(configuration: Configuration(writeKey: "test")) - + waitUntilStarted(analytics: analytics) - + var callCount = 0 var capturedUserInfo: UserInfo? - + let initialExpectation = XCTestExpectation(description: "Initial") let identifyExpectation = XCTestExpectation(description: "Identify") let resetExpectation = XCTestExpectation(description: "Reset") - + analytics.subscribeToUserInfo { userInfo in callCount += 1 capturedUserInfo = userInfo - + if callCount == 1 { initialExpectation.fulfill() } else if callCount == 2 { @@ -1146,65 +1164,65 @@ final class Analytics_Tests: XCTestCase { resetExpectation.fulfill() } } - + wait(for: [initialExpectation], timeout: 2.0) - + let originalAnonId = capturedUserInfo?.anonymousId XCTAssertEqual(1, callCount) - + // Set some user data analytics.identify(userId: "brandon", traits: MyTraits(email: "blah@blah.com")) wait(for: [identifyExpectation], timeout: 2.0) - + XCTAssertEqual(2, callCount) XCTAssertEqual("brandon", capturedUserInfo?.userId) - + // Reset and verify handler is called with cleared data analytics.reset() wait(for: [resetExpectation], timeout: 2.0) - + XCTAssertEqual(3, callCount) XCTAssertNil(capturedUserInfo?.userId) XCTAssertNil(capturedUserInfo?.referrer) XCTAssertNotEqual(originalAnonId, capturedUserInfo?.anonymousId) - + // Check analytics state AFTER waiting for callback let traitsDict: [String: Any]? = analytics.traits() XCTAssertEqual(traitsDict?.count, 0) } - + func testUserInfoSubscriptionWithReferrer() { Storage.hardSettingsReset(writeKey: "test") let analytics = Analytics(configuration: Configuration(writeKey: "test")) - + waitUntilStarted(analytics: analytics) - + var callCount = 0 var capturedUserInfo: UserInfo? - + let initialExpectation = XCTestExpectation(description: "Initial") let referrerExpectation = XCTestExpectation(description: "Referrer") - + analytics.subscribeToUserInfo { userInfo in callCount += 1 capturedUserInfo = userInfo - + if callCount == 1 { initialExpectation.fulfill() } else if callCount == 2 { referrerExpectation.fulfill() } } - + wait(for: [initialExpectation], timeout: 2.0) - + XCTAssertEqual(1, callCount) XCTAssertNil(capturedUserInfo?.referrer) - + // Set a referrer analytics.openURL(URL(string: "https://google.com")!) wait(for: [referrerExpectation], timeout: 2.0) - + XCTAssertEqual(2, callCount) XCTAssertEqual("https://google.com", capturedUserInfo?.referrer?.absoluteString) } @@ -1212,18 +1230,18 @@ final class Analytics_Tests: XCTestCase { func testMultipleUserInfoSubscriptions() { Storage.hardSettingsReset(writeKey: "test") let analytics = Analytics(configuration: Configuration(writeKey: "test")) - + waitUntilStarted(analytics: analytics) - + var firstCallCount = 0 var secondCallCount = 0 - + let initialExpectation = XCTestExpectation(description: "Initial callbacks") - initialExpectation.expectedFulfillmentCount = 2 // Both subscriptions - + initialExpectation.expectedFulfillmentCount = 2 // Both subscriptions + let identifyExpectation = XCTestExpectation(description: "Identify callbacks") - identifyExpectation.expectedFulfillmentCount = 2 // Both subscriptions - + identifyExpectation.expectedFulfillmentCount = 2 // Both subscriptions + // Create two subscriptions analytics.subscribeToUserInfo { _ in firstCallCount += 1 @@ -1233,7 +1251,7 @@ final class Analytics_Tests: XCTestCase { identifyExpectation.fulfill() } } - + analytics.subscribeToUserInfo { _ in secondCallCount += 1 if secondCallCount == 1 { @@ -1242,16 +1260,16 @@ final class Analytics_Tests: XCTestCase { identifyExpectation.fulfill() } } - + // Both should be called for initial state wait(for: [initialExpectation], timeout: 2.0) XCTAssertEqual(1, firstCallCount) XCTAssertEqual(1, secondCallCount) - + // Both should fire when state changes analytics.identify(userId: "brandon") wait(for: [identifyExpectation], timeout: 2.0) - + XCTAssertEqual(2, firstCallCount) XCTAssertEqual(2, secondCallCount) } @@ -1259,39 +1277,40 @@ final class Analytics_Tests: XCTestCase { func testUserInfoSubscriptionCalledOnMainQueue() { Storage.hardSettingsReset(writeKey: "test") let analytics = Analytics(configuration: Configuration(writeKey: "test")) - + waitUntilStarted(analytics: analytics) - + let expectation = XCTestExpectation(description: "Handler called on main queue") - expectation.expectedFulfillmentCount = 2 // Initial + identify - + expectation.expectedFulfillmentCount = 2 // Initial + identify + analytics.subscribeToUserInfo { userInfo in XCTAssertTrue(Thread.isMainThread, "Handler should be called on main thread") expectation.fulfill() } - + analytics.identify(userId: "brandon") - + wait(for: [expectation], timeout: 2.0) } func testUnsubscribeWithInvalidId() { Storage.hardSettingsReset(writeKey: "test") let analytics = Analytics(configuration: Configuration(writeKey: "test")) - + waitUntilStarted(analytics: analytics) - + // Should not crash with invalid ID analytics.unsubscribe(999999) analytics.unsubscribe(-1) - + // Should work fine after bogus unsubscribe calls - let expectation = XCTestExpectation(description: "Subscription works after invalid unsubscribe") - + let expectation = XCTestExpectation( + description: "Subscription works after invalid unsubscribe") + analytics.subscribeToUserInfo { _ in expectation.fulfill() } - + wait(for: [expectation], timeout: 2.0) } } From 1018594dae8174a9480806351e03cc1712196499 Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 11:32:04 -0700 Subject: [PATCH 6/8] more ci updates --- .github/workflows/swift.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 3eb85595..f2f17de3 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -99,11 +99,7 @@ jobs: - uses: webfactory/ssh-agent@v0.8.0 with: ssh-private-key: ${{ secrets.SOVRAN_SSH_KEY }} - - run: defaults write com.apple.dt.Xcode AllowUnsupportedVisionOSHost -bool YES - - run: defaults write com.apple.CoreSimulator AllowUnsupportedVisionOSHost -bool YES - - run: xcodebuild -downloadPlatform visionOS - - run: echo - skip until apple fixes this - xcodebuild -scheme Segment test -sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro' - - run: xcodebuild -scheme Segment -sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro' + - run: xcodebuild -scheme Segment test -sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro' build_and_test_examples: needs: cancel_previous From cca3fcb5be79bed6657680bbf3382738dedd212b Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 11:33:47 -0700 Subject: [PATCH 7/8] Test update --- Tests/Segment-Tests/Analytics_Tests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 8251fd1b..7b8ff1cb 100644 --- a/Tests/Segment-Tests/Analytics_Tests.swift +++ b/Tests/Segment-Tests/Analytics_Tests.swift @@ -1025,10 +1025,10 @@ final class Analytics_Tests: XCTestCase { // get rid of this instance, leave it time to go away ... // ... also let any state updates happen as handlers get called async - /*RunLoop.main.run(until: .distantPast) + RunLoop.main.run(until: .distantPast) analytics = nil // ... give it some time to release all it's stuff. - RunLoop.main.run(until: .distantPast)*/ + RunLoop.main.run(until: .distantPast) // make sure it makes it to the next instance analytics = Analytics( From fdbd9af8bc798a9db2b820b34a1f03a27dba0f6d Mon Sep 17 00:00:00 2001 From: Brandon Sneed Date: Fri, 3 Oct 2025 11:38:51 -0700 Subject: [PATCH 8/8] more ci updates --- .github/workflows/swift.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index f2f17de3..931e9236 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -86,7 +86,7 @@ jobs: - uses: webfactory/ssh-agent@v0.8.0 with: ssh-private-key: ${{ secrets.SOVRAN_SSH_KEY }} - - run: xcodebuild -scheme Segment test -sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 10 (42mm)' + - run: xcodebuild -scheme Segment test -sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Ultra 3 (49mm)' build_and_test_visionos: needs: cancel_previous @@ -99,7 +99,7 @@ jobs: - uses: webfactory/ssh-agent@v0.8.0 with: ssh-private-key: ${{ secrets.SOVRAN_SSH_KEY }} - - run: xcodebuild -scheme Segment test -sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro' + - run: xcodebuild -scheme Segment test -sdk xrsimulator -destination 'platform=visionOS Simulator,os=26,name=Apple Vision Pro' build_and_test_examples: needs: cancel_previous