From ce36bc9eb02b7980a2ee72b13998b8c1f7c7328f Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 27 Nov 2024 12:02:40 -0500 Subject: [PATCH 1/9] Update SDK to support swift 6 * Update TCA to latest * incorporate Anycodable * fix many concurrency issues --- .github/workflows/swift.yml | 29 +- .swiftformat | 2 +- .swiftlint.yml | 1 + KlaviyoCore.podspec | 4 +- KlaviyoSDKDependencies.podspec | 15 + KlaviyoSwift.podspec | 4 +- KlaviyoSwiftExtension.podspec | 2 +- KlaviyoUI.podspec | 2 +- Makefile | 3 +- Package.resolved | 41 +- Package.swift | 47 +- Package@swift-6.0.swift | 81 + Sources/KlaviyoCore/AppContextInfo.swift | 114 +- Sources/KlaviyoCore/AppLifeCycleEvents.swift | 44 +- Sources/KlaviyoCore/KlaviyoEnvironment.swift | 198 +- .../Models/APIModels/CreateEventPayload.swift | 38 +- .../APIModels/CreateProfilePayload.swift | 4 +- .../Models/APIModels/FullFormsResponse.swift | 8 +- .../Models/APIModels/ProfilePayload.swift | 9 +- .../Models/APIModels/PushTokenPayload.swift | 31 +- .../UnregisterPushTokenPayload.swift | 8 +- .../KlaviyoCore/Models/PushBackground.swift | 2 +- .../KlaviyoCore/Models/PushEnablement.swift | 2 +- .../KlaviyoCore/Networking/KlaviyoAPI.swift | 16 +- .../Networking/KlaviyoEndpoint.swift | 4 +- .../Networking/KlaviyoRequest.swift | 6 +- .../Networking/NetworkSession.swift | 55 +- .../Networking/SDKRequestIterator.swift | 20 +- Sources/KlaviyoCore/Utils/ArchivalUtils.swift | 12 +- Sources/KlaviyoCore/Utils/FileUtils.swift | 8 +- Sources/KlaviyoCore/Utils/LoggerClient.swift | 4 +- .../Vendor/ReachabilitySwift.swift | 4 +- .../AnyCodable/AnyCodable.swift | 150 + .../AnyCodable/AnyDecodable.swift | 191 ++ .../AnyCodable/AnyEncodable.swift | 294 ++ .../CasePaths/AnyCasePath.swift | 118 + .../CasePaths/CasePathIterable.swift | 20 + .../CasePaths/CasePathReflectable.swift | 30 + .../CasePaths/CasePathable.swift | 566 +++ .../CasePaths/EnumReflection.swift | 539 +++ .../Internal/CasePathsLockIsolated.swift | 22 + .../Internal/CasePathsUncheckedSendable.swift | 12 + .../CasePaths/Internal/OpenExistential.swift | 12 + .../CasePaths/Internal/TypeName.swift | 50 + .../CasePaths/Never+CasePathable.swift | 39 + .../CasePaths/Optional+CasePathable.swift | 91 + .../CasePaths/Result+CasePathable.swift | 45 + .../CombineSchedulers/UIScheduler.swift | 74 + .../ComposableArchitecture/Effect.swift | 357 ++ .../Effects/Cancellation.swift | 318 ++ .../Effects/Publisher.swift | 49 + .../Internal/AssumeIsolated.swift | 33 + .../ComposableArchitecture/Internal/Box.swift | 15 + .../Internal}/Create.swift | 83 +- .../Internal/CurrentValueRelay.swift | 156 + .../Internal/Debug.swift | 13 + .../Internal/Locking.swift | 30 + .../Internal/Logger.swift | 47 + .../ComposableArchitecture/Reducer.swift | 112 + .../Reducer/ReducerBuilder.swift | 140 + .../Reducer/Reducers/EmptyReducer.swift | 23 + .../Reducer/Reducers/Reduce.swift | 39 + .../Reducer/Reducers/SignPostReducer.swift | 178 + .../ComposableArchitecture/RootStore.swift | 150 + .../ComposableArchitecture/Store.swift | 622 ++++ .../ConcurrencyExtras/ActorIsolated.swift | 116 + .../AnyHashableSendable.swift | 45 + .../ConcurrencyExtras/AsyncStream.swift | 88 + .../AsyncThrowingStream.swift | 43 + .../Internal/ConcurrencyExtrasLocking.swift | 14 + .../Internal/UncheckedBox.swift | 9 + .../ConcurrencyExtras/LockIsolated.swift | 122 + .../MainSerialExecutor.swift | 93 + .../ConcurrencyExtras/Result.swift | 17 + .../ConcurrencyExtras/Task.swift | 87 + .../ConcurrencyExtras/UncheckedSendable.swift | 114 + .../CustomDump/CustomDumpReflectable.swift | 134 + .../CustomDump/CustomDumpRepresentable.swift | 29 + .../CustomDumpStringConvertible.swift | 69 + .../CustomDump/Diff.swift | 819 +++++ .../CustomDump/Dump.swift | 483 +++ .../CustomDump/ExpectDifference.swift | 165 + .../CustomDump/ExpectNoDifference.swift | 97 + .../CustomDump/Internal/AnyType.swift | 50 + .../Internal/CollectionDifference.swift | 11 + .../CustomDump/Internal/Identifiable.swift | 11 + .../CustomDump/Internal/Mirror.swift | 57 + .../CustomDump/Internal/String.swift | 33 + .../CustomDump/Internal/Unordered.swift | 10 + .../Internal/AppHostWarning.swift | 82 + .../Internal/Deprecations.swift | 16 + .../Internal/FailureObserver.swift | 24 + .../Internal/IssueReportingLockIsolated.swift | 25 + .../IssueReportingUncheckedSendable.swift | 13 + .../IssueReporting/Internal/Rethrows.swift | 20 + .../Internal/SwiftTesting.swift | 504 +++ .../IssueReporting/Internal/Warn.swift | 17 + .../IssueReporting/Internal/XCTest.swift | 129 + .../IssueReporting/IsTesting.swift | 46 + .../IssueReporting/IssueReporter.swift | 200 ++ .../IssueReporters/BreakpointReporter.swift | 55 + .../IssueReporters/FatalErrorReporter.swift | 28 + .../RuntimeWarningReporter.swift | 124 + .../IssueReporting/ReportIssue.swift | 154 + .../IssueReporting/TestContext.swift | 82 + .../IssueReporting/Unimplemented.swift | 306 ++ .../IssueReporting/WithExpectedIssue.swift | 206 ++ .../IssueReporting/WithIssueContext.swift | 65 + Sources/KlaviyoSwift/Klaviyo.swift | 80 +- .../KlaviyoSwiftEnvironment.swift | 111 +- Sources/KlaviyoSwift/Models/Event.swift | 18 +- Sources/KlaviyoSwift/Models/Profile.swift | 10 +- .../APIRequestErrorHandling.swift | 15 +- .../StateManagement/KlaviyoState.swift | 87 +- .../StateChangePublisher.swift | 45 +- .../StateManagement/StateManagement.swift | 225 +- .../ComposableArchitecture/Cancellation.swift | 332 -- .../ConcurrencySupport.swift | 356 -- .../ComposableArchitecture/Effect.swift | 491 --- .../Vendor/ComposableArchitecture/Misc.swift | 199 -- .../ComposableArchitecture/Publisher.swift | 544 --- .../ReducerProtocol.swift | 308 -- .../Vendor/ComposableArchitecture/Store.swift | 411 --- .../KlaviyoExtension.swift | 8 +- .../JSTestWebViewModel.swift | 7 +- .../KlaviyoWebViewController.swift | 4 +- .../KlaviyoWebView/KlaviyoWebViewModel.swift | 4 +- .../KlaviyoWebViewModeling.swift | 2 +- .../KlaviyoCoreTests/ArchivalUtilsTests.swift | 30 +- Tests/KlaviyoCoreTests/EncodableTests.swift | 36 +- Tests/KlaviyoCoreTests/FileUtilsTests.swift | 11 +- Tests/KlaviyoCoreTests/KlaviyoAPITests.swift | 75 +- .../NetworkSessionTests.swift | 24 +- .../SimpleMockURLProtocol.swift | 1 + Tests/KlaviyoCoreTests/TestUtils.swift | 44 +- .../APIRequestErrorHandlingTests.swift | 126 +- .../AppLifeCycleEventsTests.swift | 65 +- Tests/KlaviyoSwiftTests/EncodableTests.swift | 15 +- Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift | 39 +- .../KlaviyoSwiftTests/KlaviyoStateTests.swift | 23 +- .../KlaviyoSwiftTests/KlaviyoTestUtils.swift | 84 +- .../StateChangePublisherTests.swift | 187 +- .../StateManagementEdgeCaseTests.swift | 180 +- .../StateManagementTests.swift | 253 +- Tests/KlaviyoSwiftTests/TestData.swift | 37 +- .../DispatchQueue.swift | 38 + .../KeyPath+Sendable.swift | 92 + .../OpenExistential.swift | 21 + .../ComposableArchitecture/Reference.swift | 20 + .../SharedChangeTracker.swift | 115 + .../ComposableArchitecture/TaskResult.swift | 334 ++ .../ComposableArchitecture/TestStore.swift | 3021 ++++++++++------- .../XCTAssertDifference.swift | 106 + .../XCTAssertNoDifference.swift | 54 + .../XCTestSupport.swift | 72 + .../Conformances/CoreLocation.swift | 158 + .../CustomDump/Conformances/Foundation.swift | 324 ++ .../CustomDump/Conformances/KeyPath.swift | 15 + .../CustomDump/Conformances/Swift.swift | 47 + .../IdentifiedCollections/Collections.swift | 6 + 160 files changed, 14049 insertions(+), 5109 deletions(-) create mode 100644 KlaviyoSDKDependencies.podspec create mode 100644 Package@swift-6.0.swift create mode 100644 Sources/KlaviyoSDKDependencies/AnyCodable/AnyCodable.swift create mode 100644 Sources/KlaviyoSDKDependencies/AnyCodable/AnyDecodable.swift create mode 100644 Sources/KlaviyoSDKDependencies/AnyCodable/AnyEncodable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/AnyCasePath.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/CasePathIterable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/CasePathReflectable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/CasePathable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/EnumReflection.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsLockIsolated.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsUncheckedSendable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Internal/OpenExistential.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Internal/TypeName.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Never+CasePathable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Optional+CasePathable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CasePaths/Result+CasePathable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CombineSchedulers/UIScheduler.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effect.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Cancellation.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Publisher.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/AssumeIsolated.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Box.swift rename Sources/{KlaviyoSwift/Vendor/ComposableArchitecture => KlaviyoSDKDependencies/ComposableArchitecture/Internal}/Create.swift (71%) create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/CurrentValueRelay.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Debug.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Locking.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Logger.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/ReducerBuilder.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/EmptyReducer.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/Reduce.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/SignPostReducer.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/RootStore.swift create mode 100644 Sources/KlaviyoSDKDependencies/ComposableArchitecture/Store.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/ActorIsolated.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AnyHashableSendable.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncStream.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncThrowingStream.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/ConcurrencyExtrasLocking.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/UncheckedBox.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/LockIsolated.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/MainSerialExecutor.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Result.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Task.swift create mode 100644 Sources/KlaviyoSDKDependencies/ConcurrencyExtras/UncheckedSendable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpReflectable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpRepresentable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpStringConvertible.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Diff.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Dump.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/ExpectDifference.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/ExpectNoDifference.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Internal/AnyType.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Internal/CollectionDifference.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Internal/Identifiable.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Internal/Mirror.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Internal/String.swift create mode 100644 Sources/KlaviyoSDKDependencies/CustomDump/Internal/Unordered.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/AppHostWarning.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Deprecations.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/FailureObserver.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingLockIsolated.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingUncheckedSendable.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Rethrows.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/SwiftTesting.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Warn.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Internal/XCTest.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/IsTesting.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/BreakpointReporter.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/FatalErrorReporter.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/ReportIssue.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/TestContext.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/Unimplemented.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/WithExpectedIssue.swift create mode 100644 Sources/KlaviyoSDKDependencies/IssueReporting/WithIssueContext.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Cancellation.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ConcurrencySupport.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Effect.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Publisher.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ReducerProtocol.swift delete mode 100644 Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Store.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift create mode 100644 Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 4e041653..ddf3df83 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -14,21 +14,40 @@ concurrency: cancel-in-progress: true jobs: - library: + library-macos-14: name: Build and Run Unit Tests runs-on: macos-14 strategy: matrix: - xcode: ['15.2', '15.4', '16.0'] + xcode: ['15.2', '15.4'] config: ['debug', 'release'] steps: - uses: actions/checkout@v3 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run ${{ matrix.config }} tests - run: make CONFIG=${{ matrix.config }} test-library + run: make XCODE=${{ matrix.xcode }} CONFIG=${{ matrix.config }} test-library - - uses: conradev/xcresulttool@v1.8.0 + - uses: slidoapp/xcresulttool@v3.1.0 with: - path: TestResults.xcresult + path: TestResults-${{ matrix.xcode }}-${{ matrix.config }}.xcresult + if: success() || failure() + + library-macos-15: + name: Build and Run Unit Tests + runs-on: macos-15 + strategy: + matrix: + xcode: ['16.0'] + config: ['debug', 'release'] + steps: + - uses: actions/checkout@v3 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Run ${{ matrix.config }} tests + run: make XCODE=${{ matrix.xcode }} CONFIG=${{ matrix.config }} test-library + + - uses: slidoapp/xcresulttool@v3.1.0 + with: + path: TestResults-${{ matrix.xcode }}-${{ matrix.config }}.xcresult if: success() || failure() diff --git a/.swiftformat b/.swiftformat index 3486c013..ae6583aa 100644 --- a/.swiftformat +++ b/.swiftformat @@ -10,7 +10,7 @@ # format options ---exclude Sources/KlaviyoSwift/Vendor,Tests/KlaviyoSwiftTests/Vendor,Tests/KlaviyoSwiftTests/__Snapshots__ +--exclude Sources/KlaviyoSDKDependencies,Sources/KlaviyoSwift/Vendor,Tests/KlaviyoSwiftTests/Vendor,Tests/KlaviyoSwiftTests/__Snapshots__ --closingparen same-line --commas inline --comments indent diff --git a/.swiftlint.yml b/.swiftlint.yml index 6d6a079b..fc3d2a36 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -17,6 +17,7 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - Sources/KlaviyoSwift/Vendor - Tests/KlaviyoSwiftTests/Vendor - Tests/KlaviyoSwiftTests/__Snapshots__ + - Sources/KlaviyoSDKDependencies analyzer_rules: # Rules run by `swiftlint analyze` (experimental) - explicit_self diff --git a/KlaviyoCore.podspec b/KlaviyoCore.podspec index 7844d2fb..7f65a538 100644 --- a/KlaviyoCore.podspec +++ b/KlaviyoCore.podspec @@ -10,7 +10,7 @@ Pod::Spec.new do |s| s.author = { "Mobile @ Klaviyo" => "mobile@klaviyo.com" } s.source = { :git => "https://github.com/klaviyo/klaviyo-swift-sdk.git", :tag => s.version.to_s } s.swift_version = '5.7' - s.platform = :ios, '13.0' + s.platform = :ios, '15.0' s.source_files = 'Sources/KlaviyoCore/**/*.swift' - s.dependency 'AnyCodable-FlightSchool' + s.dependency 'KlaviyoSDKDependencies', '~>4.0.0' end diff --git a/KlaviyoSDKDependencies.podspec b/KlaviyoSDKDependencies.podspec new file mode 100644 index 00000000..8688cb66 --- /dev/null +++ b/KlaviyoSDKDependencies.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = "KlaviyoSDKDependencies" + s.version = "4.0.0" + s.summary = "Dependency for the Klaviyo SDK" + s.description = <<-DESC + Klaviyo external dependencies all rolled in one package + DESC + s.homepage = "https://github.com/klaviyo/klaviyo-swift-sdk" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "Mobile @ Klaviyo" => "mobile@klaviyo.com" } + s.source = { :git => "https://github.com/klaviyo/klaviyo-swift-sdk.git", :tag => s.version.to_s } + s.swift_version = '5.7' + s.platform = :ios, '15.0' + s.source_files = 'Sources/KlaviyoCore/**/*.swift' +end diff --git a/KlaviyoSwift.podspec b/KlaviyoSwift.podspec index 1e211dc6..6fbdebc9 100644 --- a/KlaviyoSwift.podspec +++ b/KlaviyoSwift.podspec @@ -13,9 +13,9 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/klaviyo/klaviyo-swift-sdk.git", :tag => s.version.to_s } s.swift_version = '5.7' s.platform = :ios - s.ios.deployment_target = '13.0' + s.ios.deployment_target = '15.0' s.source_files = 'Sources/KlaviyoSwift/**/*.swift' s.resource_bundles = {"KlaviyoSwift" => ["Sources/KlaviyoSwift/PrivacyInfo.xcprivacy"]} s.dependency 'KlaviyoCore', '~> 4.0.0' - s.dependency 'AnyCodable-FlightSchool' + s.dependency 'KlaviyoSDKDependencies', '~>4.0.0' end diff --git a/KlaviyoSwiftExtension.podspec b/KlaviyoSwiftExtension.podspec index d7abda9d..11b8e4a1 100644 --- a/KlaviyoSwiftExtension.podspec +++ b/KlaviyoSwiftExtension.podspec @@ -13,6 +13,6 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/klaviyo/klaviyo-swift-sdk.git", :tag => s.version.to_s } s.swift_version = '5.7' s.platform = :ios - s.ios.deployment_target = '13.0' + s.ios.deployment_target = '15.0' s.source_files = 'Sources/KlaviyoSwiftExtension/**/*.swift' end diff --git a/KlaviyoUI.podspec b/KlaviyoUI.podspec index fe5bbe64..dd8d11bc 100644 --- a/KlaviyoUI.podspec +++ b/KlaviyoUI.podspec @@ -10,7 +10,7 @@ Pod::Spec.new do |s| s.author = { "Mobile @ Klaviyo" => "mobile@klaviyo.com" } s.source = { :git => "https://github.com/klaviyo/klaviyo-swift-sdk.git", :tag => s.version.to_s } s.swift_version = '5.7' - s.platform = :ios, '13.0' + s.platform = :ios, '15.0' s.source_files = 'Sources/KlaviyoUI/**/*.swift' # update once modularization changes are merged in. s.dependency 'KlaviyoSwift', '~> 4.0.0' diff --git a/Makefile b/Makefile index 1234344f..167178d0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ CONFIG = debug +XCODE = 15.2 PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS,iPhone \d\+ Pro [^M]) @@ -10,7 +11,7 @@ test-all: $(MAKE) CONFIG=debug test-library test-library: for platform in "$(PLATFORM_IOS)"; do \ xcodebuild test \ - -resultBundlePath TestResults \ + -resultBundlePath TestResults-$(XCODE)-$(CONFIG) \ -enableCodeCoverage YES \ -configuration=$(CONFIG) \ -scheme klaviyo-swift-sdk-Package \ diff --git a/Package.resolved b/Package.resolved index bbf4f541..c7b579e8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,48 +1,39 @@ { "pins" : [ - { - "identity" : "anycodable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Flight-School/AnyCodable", - "state" : { - "revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05", - "version" : "0.6.7" - } - }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", - "version" : "0.9.1" + "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", + "version" : "1.0.2" } }, { - "identity" : "swift-case-paths", + "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb436421f57269fbcfe7360735985321585a86e5", - "version" : "0.10.1" + "revision" : "6054df64b55186f08b6d0fd87152081b8ad8d613", + "version" : "1.2.0" } }, { - "identity" : "swift-custom-dump", + "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "ead7d30cc224c3642c150b546f4f1080d1c411a8", - "version" : "0.6.1" + "revision" : "42a086182681cf661f5c47c9b7dc3931de18c6d7", + "version" : "1.17.6" } }, { - "identity" : "swift-snapshot-testing", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f29e2014f6230cf7d5138fc899da51c7f513d467", - "version" : "1.10.0" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -50,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "5a5457a744239896e9b0b03a8e1a5069c3e7b91f", - "version" : "0.6.0" + "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", + "version" : "1.4.2" } } ], diff --git a/Package.swift b/Package.swift index a7363b9d..aaea7fd1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,12 @@ -// swift-tools-version: 5.6 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import PackageDescription let package = Package( name: "klaviyo-swift-sdk", - platforms: [.iOS(.v13)], + platforms: [.iOS(.v15), .macOS(.v10_15)], products: [ .library( name: "KlaviyoSwift", @@ -19,29 +20,27 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.10.0"), - .package( - url: "https://github.com/Flight-School/AnyCodable", - from: "0.6.0"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.6.1"), - .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.10.0"), - .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.9.1") + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0") ], targets: [ .target( name: "KlaviyoCore", - dependencies: [.product(name: "AnyCodable", package: "AnyCodable")], + dependencies: ["KlaviyoSDKDependencies"], path: "Sources/KlaviyoCore"), .testTarget( name: "KlaviyoCoreTests", dependencies: [ "KlaviyoCore", .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "CasePaths", package: "swift-case-paths") + "KlaviyoSDKDependencies" ]), .target( name: "KlaviyoSwift", - dependencies: [.product(name: "AnyCodable", package: "AnyCodable"), "KlaviyoCore"], + dependencies: [ + "KlaviyoCore", + "KlaviyoSDKDependencies" + ], path: "Sources/KlaviyoSwift", resources: [.copy("PrivacyInfo.xcprivacy")]), .testTarget( @@ -49,10 +48,10 @@ let package = Package( dependencies: [ "KlaviyoSwift", .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "CasePaths", package: "swift-case-paths"), + "KlaviyoSDKDependencies", .product(name: "CombineSchedulers", package: "combine-schedulers"), - "KlaviyoCore" + "KlaviyoCore", + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay") ], exclude: [ "__Snapshots__" @@ -70,10 +69,24 @@ let package = Package( name: "KlaviyoUITests", dependencies: [ "KlaviyoSwift", - "KlaviyoCore" + "KlaviyoCore", + "KlaviyoSDKDependencies" ]), .target( name: "KlaviyoSwiftExtension", dependencies: [], - path: "Sources/KlaviyoSwiftExtension") + path: "Sources/KlaviyoSwiftExtension"), + + // Vendorized Things + .target( + name: "KlaviyoSDKDependencies", + dependencies: [], + path: "Sources/KlaviyoSDKDependencies") + ]) + +for target in package.targets { + target.swiftSettings = target.swiftSettings ?? [] + target.swiftSettings?.append(contentsOf: [ + .enableExperimentalFeature("StrictConcurrency") ]) +} diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 00000000..f934fcda --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,81 @@ +// swift-tools-version:6.0 + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "klaviyo-swift-sdk", + platforms: [.iOS(.v15), .macOS(.v10_15)], + products: [ + .library( + name: "KlaviyoSwift", + targets: ["KlaviyoSwift"]), + .library( + name: "KlaviyoUI", + targets: ["KlaviyoUI"]), + .library( + name: "KlaviyoSwiftExtension", + targets: ["KlaviyoSwiftExtension"]) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.10.0"), + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0") + ], + targets: [ + .target( + name: "KlaviyoCore", + dependencies: ["KlaviyoSDKDependencies"], + path: "Sources/KlaviyoCore"), + .testTarget( + name: "KlaviyoCoreTests", + dependencies: [ + "KlaviyoCore", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + "KlaviyoSDKDependencies" + ]), + .target( + name: "KlaviyoSwift", + dependencies: [ + "KlaviyoSDKDependencies", + "KlaviyoCore" + ], + path: "Sources/KlaviyoSwift", + resources: [.copy("PrivacyInfo.xcprivacy")]), + .testTarget( + name: "KlaviyoSwiftTests", + dependencies: [ + "KlaviyoSwift", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + "KlaviyoSDKDependencies", + .product(name: "CombineSchedulers", package: "combine-schedulers"), + "KlaviyoCore", + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay") + ], + exclude: [ + "__Snapshots__" + ]), + .target( + name: "KlaviyoUI", + dependencies: ["KlaviyoSwift"], + path: "Sources/KlaviyoUI", + resources: [.process("KlaviyoWebView/Resources")]), + .testTarget( + name: "KlaviyoUITests", + dependencies: [ + "KlaviyoSwift", + "KlaviyoCore", + "KlaviyoSDKDependencies" + ]), + .target( + name: "KlaviyoSwiftExtension", + dependencies: [], + path: "Sources/KlaviyoSwiftExtension"), + + // Vendorized Things + .target( + name: "KlaviyoSDKDependencies", + dependencies: [], + path: "Sources/KlaviyoSDKDependencies") + ], + swiftLanguageModes: [.v6]) diff --git a/Sources/KlaviyoCore/AppContextInfo.swift b/Sources/KlaviyoCore/AppContextInfo.swift index 336e656d..3211cd44 100644 --- a/Sources/KlaviyoCore/AppContextInfo.swift +++ b/Sources/KlaviyoCore/AppContextInfo.swift @@ -7,18 +7,25 @@ import Foundation import UIKit -public struct AppContextInfo { - private static let info = Bundle.main.infoDictionary - public static let defaultExecutable: String = (info?["CFBundleExecutable"] as? String) ?? +private let _defaultAppContextInfo: AppContextInfo? = nil + +@MainActor +public func getDefaultAppContextInfo() -> AppContextInfo { + if let appContextInfo = _defaultAppContextInfo { + return appContextInfo + } + let info = Bundle.main.infoDictionary + let defaultExecutable: String = (info?["CFBundleExecutable"] as? String) ?? (ProcessInfo.processInfo.arguments.first?.split(separator: "/").last.map(String.init)) ?? "Unknown" - public static let defaultBundleId: String = info?["CFBundleIdentifier"] as? String ?? "Unknown" - public static let defaultAppVersion: String = info?["CFBundleShortVersionString"] as? String ?? "Unknown" - public static let defaultAppBuild: String = info?["CFBundleVersion"] as? String ?? "Unknown" - public static let defaultAppName: String = info?["CFBundleName"] as? String ?? "Unknown" - public static let defaultOSVersion = ProcessInfo.processInfo.operatingSystemVersion - public static let defaultManufacturer = "Apple" - public static let defaultOSName = "iOS" - public static let defaultDeviceModel: String = { + let defaultBundleId: String = info?["CFBundleIdentifier"] as? String ?? "Unknown" + let defaultAppVersion: String = info?["CFBundleShortVersionString"] as? String ?? "Unknown" + let defaultAppBuild: String = info?["CFBundleVersion"] as? String ?? "Unknown" + let defaultAppName: String = info?["CFBundleName"] as? String ?? "Unknown" + let defaultOSVersion = ProcessInfo.processInfo.operatingSystemVersion + let defaultManufacturer = "Apple" + let defaultOSName = "iOS" + let defaultDeviceId = UIDevice.current.identifierForVendor?.uuidString ?? "" + let defaultDeviceModel: String = { var size = 0 var deviceModel = "" sysctlbyname("hw.machine", nil, &size, nil, 0) @@ -30,61 +37,82 @@ public struct AppContextInfo { return deviceModel }() - private static let deviceIdStoreKey = "_klaviyo_device_id" + let defaultKlaviyoSdk = { + let plist = loadPlist(named: "klaviyo-sdk-configuration") ?? [:] + if let sdkName = plist["klaviyo_sdk_name"] as? String { + return sdkName + } + return __klaviyoSwiftName + }() + let defaultSdkVersion = { + let plist = loadPlist(named: "klaviyo-sdk-configuration") ?? [:] + if let sdkVersion = plist["klaviyo_sdk_version"] as? String { + return sdkVersion + } + return __klaviyoSwiftVersion + }() + + let defaultEnvironment = UIDevice.current.pushEnvironment.value + + return AppContextInfo(executable: defaultExecutable, bundleId: defaultBundleId, appVersion: defaultAppVersion, appBuild: defaultAppBuild, appName: defaultAppName, version: defaultOSVersion, osName: defaultOSName, manufacturer: defaultManufacturer, deviceModel: defaultDeviceModel, deviceId: defaultDeviceId, environment: defaultEnvironment, klaviyoSdk: defaultKlaviyoSdk, sdkVersion: defaultSdkVersion) +} + +public struct AppContextInfo: Sendable, Equatable { let executable: String let bundleId: String let appVersion: String let appBuild: String let appName: String - let version: OperatingSystemVersion + let version: OSVersion let osName: String let manufacturer: String let deviceModel: String let deviceId: String let environment: String + let klaviyoSdk: String + let sdkVersion: String var osVersion: String { "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" } + struct OSVersion: Equatable { + let majorVersion: Int + let minorVersion: Int + let patchVersion: Int + } + var osVersionName: String { "\(osName) \(osVersion)" } - public init(executable: String = defaultExecutable, - bundleId: String = defaultBundleId, - appVersion: String = defaultAppVersion, - appBuild: String = defaultAppBuild, - appName: String = defaultAppName, - version: OperatingSystemVersion = defaultOSVersion, - osName: String = defaultOSName, - manufacturer: String = defaultManufacturer, - deviceModel: String = defaultDeviceModel, - deviceId: String = UIDevice.current.identifierForVendor?.uuidString ?? "") { + public init(executable: String, + bundleId: String, + appVersion: String, + appBuild: String, + appName: String, + version: OperatingSystemVersion, + osName: String, + manufacturer: String, + deviceModel: String, + deviceId: String, + environment: String, + klaviyoSdk: String, + sdkVersion: String) { self.executable = executable self.bundleId = bundleId self.appVersion = appVersion self.appBuild = appBuild self.appName = appName - self.version = version + self.version = OSVersion(majorVersion: version.majorVersion, minorVersion: version.minorVersion, patchVersion: version.patchVersion) self.osName = osName self.manufacturer = manufacturer self.deviceModel = deviceModel self.deviceId = deviceId - - switch UIDevice.current.pushEnvironment { - case .development: - environment = "debug" - case .production: - environment = "release" - case .unknown: - #if DEBUG - environment = "debug" - #else - environment = "release" - #endif - } + self.environment = environment + self.sdkVersion = sdkVersion + self.klaviyoSdk = klaviyoSdk } } @@ -93,6 +121,18 @@ extension UIDevice { case unknown case development case production + + var value: String { + switch self { + case .development: return "debug" + case .production: return "production" + #if DEBUG + default: return "debug" + #else + default: return "production" + #endif + } + } } public var pushEnvironment: PushEnvironment { diff --git a/Sources/KlaviyoCore/AppLifeCycleEvents.swift b/Sources/KlaviyoCore/AppLifeCycleEvents.swift index 175c36f7..042a9654 100644 --- a/Sources/KlaviyoCore/AppLifeCycleEvents.swift +++ b/Sources/KlaviyoCore/AppLifeCycleEvents.swift @@ -20,37 +20,43 @@ public enum LifeCycleEvents { case reachabilityChanged(status: Reachability.NetworkStatus) } +@MainActor public struct AppLifeCycleEvents { - public var lifeCycleEvents: () -> AnyPublisher + public var lifeCycleEvents: @MainActor ( + (NSNotification.Name) -> AnyPublisher, + @escaping () throws -> Void, + @escaping () -> Void, + @escaping () -> Reachability.NetworkStatus?) -> AnyPublisher - public init(lifeCycleEvents: @escaping () -> AnyPublisher = { - let terminated = environment - .notificationCenterPublisher(UIApplication.willTerminateNotification) + public init(lifeCycleEvents: @MainActor @escaping ( + (NSNotification.Name) -> AnyPublisher, + @escaping () throws -> Void, + @escaping () -> Void, + @escaping () -> Reachability.NetworkStatus?) -> AnyPublisher = { notificationPublisher, startReachability, stopReachability, reachabilityStatus in + let terminated = notificationPublisher(UIApplication.willTerminateNotification) .handleEvents(receiveOutput: { _ in - environment.stopReachability() + stopReachability() }) .map { _ in LifeCycleEvents.terminated } - let foregrounded = environment - .notificationCenterPublisher(UIApplication.didBecomeActiveNotification) + let foregrounded = notificationPublisher(UIApplication.didBecomeActiveNotification) .handleEvents(receiveOutput: { _ in do { - try environment.startReachability() + try startReachability() } catch { - environment.emitDeveloperWarning("failure to start reachability notifier") + // ND: no-op for now... } }) .map { _ in LifeCycleEvents.foregrounded } - let backgrounded = environment - .notificationCenterPublisher(UIApplication.didEnterBackgroundNotification) + let backgrounded = notificationPublisher(UIApplication.didEnterBackgroundNotification) .handleEvents(receiveOutput: { _ in - environment.stopReachability() + stopReachability() }) .map { _ in LifeCycleEvents.backgrounded } // The below is a bit convoluted since network status can be nil. - let reachability = environment - .notificationCenterPublisher(ReachabilityChangedNotification) + let reachability = notificationPublisher(ReachabilityChangedNotification) + .receive(on: DispatchQueue.main) .compactMap { _ in - let status = environment.reachabilityStatus() ?? .reachableViaWWAN + let status = reachabilityStatus() ?? .reachableViaWWAN return LifeCycleEvents.reachabilityChanged(status: status) } @@ -59,16 +65,16 @@ public struct AppLifeCycleEvents { .merge(with: foregrounded, backgrounded) .handleEvents(receiveSubscription: { _ in do { - try environment.startReachability() + try startReachability() } catch { - environment.emitDeveloperWarning("failure to start reachability notifier") + environment.logger.error("failure to start reachability notifier") } }) - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .eraseToAnyPublisher() }) { self.lifeCycleEvents = lifeCycleEvents } - static let production = Self() + public static let production = Self() } diff --git a/Sources/KlaviyoCore/KlaviyoEnvironment.swift b/Sources/KlaviyoCore/KlaviyoEnvironment.swift index ea286c2d..e11567af 100644 --- a/Sources/KlaviyoCore/KlaviyoEnvironment.swift +++ b/Sources/KlaviyoCore/KlaviyoEnvironment.swift @@ -5,50 +5,49 @@ // Created by Noah Durell on 9/28/22. // -import AnyCodable import Combine import Foundation +import KlaviyoSDKDependencies import UIKit +// Though this is a var it should never be modified outside of tests. +#if swift(>=5.10) +public nonisolated(unsafe) var environment = KlaviyoEnvironment.production +#else public var environment = KlaviyoEnvironment.production +#endif -public struct KlaviyoEnvironment { +public struct KlaviyoEnvironment: Sendable { public init( archiverClient: ArchiverClient, fileClient: FileClient, - dataFromUrl: @escaping (URL) throws -> Data, + dataFromUrl: @Sendable @escaping (URL) throws -> Data, logger: LoggerClient, - appLifeCycle: AppLifeCycleEvents, - notificationCenterPublisher: @escaping (NSNotification.Name) -> AnyPublisher, - getNotificationSettings: @escaping () async -> PushEnablement, - getBackgroundSetting: @escaping () -> PushBackground, - getBadgeAutoClearingSetting: @escaping () async -> Bool, - startReachability: @escaping () throws -> Void, - stopReachability: @escaping () -> Void, - reachabilityStatus: @escaping () -> Reachability.NetworkStatus?, - randomInt: @escaping () -> Int, - raiseFatalError: @escaping (String) -> Void, - emitDeveloperWarning: @escaping (String) -> Void, - networkSession: @escaping () -> NetworkSession, - apiURL: @escaping () -> String, - encodeJSON: @escaping (AnyEncodable) throws -> Data, + notificationCenterPublisher: @Sendable @escaping (NSNotification.Name) -> AnyPublisher, + getNotificationSettings: @Sendable @escaping () async -> PushEnablement, + getBadgeAutoClearingSetting: @Sendable @escaping () async -> Bool, + startReachability: @Sendable @escaping () throws -> Void, + stopReachability: @Sendable @escaping () -> Void, + reachabilityStatus: @Sendable @escaping () -> Reachability + .NetworkStatus?, + randomInt: @Sendable @escaping () -> Int, + raiseFatalError: @Sendable @escaping (String) -> Void, + emitDeveloperWarning: @Sendable @escaping (String) -> Void, + apiURL: @Sendable @escaping () -> String, + encodeJSON: @Sendable @escaping (AnyEncodable) throws -> Data, decoder: DataDecoder, - uuid: @escaping () -> UUID, - date: @escaping () -> Date, - timeZone: @escaping () -> String, - appContextInfo: @escaping () -> AppContextInfo, + uuid: @Sendable @escaping () -> UUID, + date: @Sendable @escaping () -> Date, + timeZone: @Sendable @escaping () -> String, klaviyoAPI: KlaviyoAPI, - timer: @escaping (Double) -> AnyPublisher, - SDKName: @escaping () -> String, - SDKVersion: @escaping () -> String) { + timer: @Sendable @escaping (Double) -> AsyncStream, + appContextInfo: @Sendable @escaping () async -> AppContextInfo) { self.archiverClient = archiverClient self.fileClient = fileClient self.dataFromUrl = dataFromUrl self.logger = logger - self.appLifeCycle = appLifeCycle self.notificationCenterPublisher = notificationCenterPublisher self.getNotificationSettings = getNotificationSettings - self.getBackgroundSetting = getBackgroundSetting self.getBadgeAutoClearingSetting = getBadgeAutoClearingSetting self.startReachability = startReachability self.stopReachability = stopReachability @@ -56,18 +55,15 @@ public struct KlaviyoEnvironment { self.randomInt = randomInt self.raiseFatalError = raiseFatalError self.emitDeveloperWarning = emitDeveloperWarning - self.networkSession = networkSession self.apiURL = apiURL self.encodeJSON = encodeJSON self.decoder = decoder self.uuid = uuid self.date = date self.timeZone = timeZone - self.appContextInfo = appContextInfo self.klaviyoAPI = klaviyoAPI self.timer = timer - sdkName = SDKName - sdkVersion = SDKVersion + self.appContextInfo = appContextInfo } static let productionHost = "a.klaviyo.com" @@ -87,72 +83,46 @@ public struct KlaviyoEnvironment { public var archiverClient: ArchiverClient public var fileClient: FileClient - public var dataFromUrl: (URL) throws -> Data + public var dataFromUrl: @Sendable (URL) throws -> Data public var logger: LoggerClient - public var appLifeCycle: AppLifeCycleEvents - - public var notificationCenterPublisher: (NSNotification.Name) -> AnyPublisher - public var getNotificationSettings: () async -> PushEnablement - public var getBackgroundSetting: () -> PushBackground - public var getBadgeAutoClearingSetting: () async -> Bool - - public var startReachability: () throws -> Void - public var stopReachability: () -> Void - public var reachabilityStatus: () -> Reachability.NetworkStatus? + public var notificationCenterPublisher: + @Sendable (NSNotification.Name) -> AnyPublisher + public var getNotificationSettings: @Sendable () async -> PushEnablement + public var getBadgeAutoClearingSetting: @Sendable () async -> Bool + public var startReachability: @Sendable () throws -> Void + public var stopReachability: @Sendable () -> Void + public var reachabilityStatus: @Sendable () -> Reachability.NetworkStatus? + public var randomInt: @Sendable () -> Int - public var randomInt: () -> Int + public var raiseFatalError: @Sendable (String) -> Void + public var emitDeveloperWarning: @Sendable (String) async -> Void - public var raiseFatalError: (String) -> Void - public var emitDeveloperWarning: (String) -> Void - - public var networkSession: () -> NetworkSession - public var apiURL: () -> String - public var encodeJSON: (AnyEncodable) throws -> Data + public var apiURL: @Sendable () -> String + public var encodeJSON: @Sendable (AnyEncodable) throws -> Data public var decoder: DataDecoder - public var uuid: () -> UUID - public var date: () -> Date - public var timeZone: () -> String - public var appContextInfo: () -> AppContextInfo + public var uuid: @Sendable () -> UUID + public var date: @Sendable () -> Date + public var timeZone: @Sendable () -> String public var klaviyoAPI: KlaviyoAPI - public var timer: (Double) -> AnyPublisher - - public var sdkName: () -> String - public var sdkVersion: () -> String - - private static let rnSDKConfig: [String: AnyObject] = loadPlist(named: "klaviyo-sdk-configuration") ?? [:] - - private static func getSDKName() -> String { - if let sdkName = KlaviyoEnvironment.rnSDKConfig["klaviyo_sdk_name"] as? String { - return sdkName - } - return __klaviyoSwiftName - } - - private static func getSDKVersion() -> String { - if let sdkVersion = KlaviyoEnvironment.rnSDKConfig["klaviyo_sdk_version"] as? String { - return sdkVersion - } - return __klaviyoSwiftVersion - } + public var timer: @Sendable (Double) -> AsyncStream + public var appContextInfo: @Sendable () async -> AppContextInfo - public static var production = KlaviyoEnvironment( + public static let production = KlaviyoEnvironment( archiverClient: ArchiverClient.production, fileClient: FileClient.production, dataFromUrl: { url in try Data(contentsOf: url) }, logger: LoggerClient.production, - appLifeCycle: AppLifeCycleEvents.production, notificationCenterPublisher: { name in NotificationCenter.default.publisher(for: name) .eraseToAnyPublisher() }, getNotificationSettings: { - let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() - return PushEnablement.create(from: notificationSettings.authorizationStatus) - }, - getBackgroundSetting: { - .create(from: UIApplication.shared.backgroundRefreshStatus) + let notificationSettings = await UNUserNotificationCenter.current() + .notificationSettings() + return PushEnablement.create( + from: notificationSettings.authorizationStatus) }, getBadgeAutoClearingSetting: { Bundle.main.object(forInfoDictionaryKey: "Klaviyo_badge_autoclearing") as? Bool ?? true @@ -173,30 +143,53 @@ public struct KlaviyoEnvironment { #endif }, emitDeveloperWarning: { runtimeWarn($0) }, - networkSession: createNetworkSession, apiURL: { KlaviyoEnvironment.productionHost }, encodeJSON: { encodable in try encoder.encode(encodable) }, decoder: DataDecoder.production, uuid: { UUID() }, date: { Date() }, timeZone: { TimeZone.autoupdatingCurrent.identifier }, - appContextInfo: { AppContextInfo() }, klaviyoAPI: KlaviyoAPI(), timer: { interval in - Timer.publish(every: interval, on: .main, in: .default) - .autoconnect() - .eraseToAnyPublisher() - }, - SDKName: KlaviyoEnvironment.getSDKName, - SDKVersion: KlaviyoEnvironment.getSDKVersion) + AsyncStream { continuation in + let timerActor = TimerActor() + Task { + // Start the timer via the TimerActor + #if swift(>=6) + timerActor.startTimer(interval: interval, continuation: continuation) + #else + await timerActor.startTimer(interval: interval, continuation: continuation) + #endif + } + + continuation.onTermination = { _ in + // Stop the timer when the stream terminates + Task { + #if swift(>=6) + timerActor.stopTimer() + #else + await timerActor.stopTimer() + #endif + } + } + } + }, appContextInfo: { + #if swift(>=6) + getDefaultAppContextInfo() + #else + await getDefaultAppContextInfo() + #endif + }) } -public var networkSession: NetworkSession! +@MainActor var networkSession: NetworkSession? = nil + +@MainActor public func createNetworkSession() -> NetworkSession { if networkSession == nil { networkSession = NetworkSession.production } - return networkSession + return networkSession! } public enum KlaviyoDecodingError: Error { @@ -204,7 +197,7 @@ public enum KlaviyoDecodingError: Error { case invalidJson } -public struct DataDecoder { +public struct DataDecoder: @unchecked Sendable { public init(jsonDecoder: JSONDecoder) { self.jsonDecoder = jsonDecoder } @@ -216,3 +209,28 @@ public struct DataDecoder { try jsonDecoder.decode(T.self, from: data) } } + +actor TimerActor { + private var timer: DispatchSourceTimer? + + func startTimer( + interval: TimeInterval, continuation: AsyncStream.Continuation) { + // Ensure any previous timer is invalidated + stopTimer() + + // Create a new DispatchSourceTimer and start it + let newTimer = DispatchSource.makeTimerSource(queue: .global()) + newTimer.schedule(deadline: .now(), repeating: interval) + newTimer.setEventHandler { + continuation.yield(Date()) + } + newTimer.resume() + timer = newTimer + } + + func stopTimer() { + // Invalidate the existing timer if there is one + timer?.cancel() + timer = nil + } +} diff --git a/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift b/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift index f7e21d41..d140ccea 100644 --- a/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift +++ b/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift @@ -5,16 +5,16 @@ // Created by Ajay Subramanya on 8/5/24. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies -public struct CreateEventPayload: Equatable, Codable { - public struct Event: Equatable, Codable { - public struct Attributes: Equatable, Codable { - public struct Metric: Equatable, Codable { +public struct CreateEventPayload: Equatable, Codable, Sendable { + public struct Event: Equatable, Codable, Sendable { + public struct Attributes: Equatable, Codable, Sendable { + public struct Metric: Equatable, Codable, Sendable { public let data: MetricData - public struct MetricData: Equatable, Codable { + public struct MetricData: Equatable, Codable, Sendable { var type: String = "metric" public let attributes: MetricAttributes @@ -23,7 +23,7 @@ public struct CreateEventPayload: Equatable, Codable { attributes = .init(name: name) } - public struct MetricAttributes: Equatable, Codable { + public struct MetricAttributes: Equatable, Codable, Sendable { public let name: String } } @@ -33,7 +33,7 @@ public struct CreateEventPayload: Equatable, Codable { } } - public struct Profile: Equatable, Codable { + public struct Profile: Equatable, Codable, Sendable { public let data: ProfilePayload public init(data: ProfilePayload) { @@ -47,6 +47,7 @@ public struct CreateEventPayload: Equatable, Codable { public let time: Date public let value: Double? public let uniqueId: String + public init(name: String, properties: [String: Any]? = nil, email: String? = nil, @@ -91,10 +92,11 @@ public struct CreateEventPayload: Equatable, Codable { value: Double? = nil, time: Date? = nil, uniqueId: String? = nil, - pushToken: String? = nil) { + pushToken: String? = nil, + appContextInfo: AppContextInfo) { attributes = Attributes( name: name, - properties: properties?.appendMetadataToProperties(pushToken: pushToken), + properties: properties?.appendMetadataToProperties(context: appContextInfo, pushToken: pushToken), email: email, phoneNumber: phoneNumber, externalId: externalId, @@ -112,23 +114,25 @@ public struct CreateEventPayload: Equatable, Codable { } extension Dictionary where Key == String, Value == Any { - fileprivate func appendMetadataToProperties(pushToken: String?) -> [String: Any]? { - let context = environment.appContextInfo() - let metadata: [String: Any] = [ + func appendMetadataToProperties(context: AppContextInfo, pushToken: String?) -> [String: Any]? { + var metadata: [String: Any] = [ "Device ID": context.deviceId, "Device Manufacturer": context.manufacturer, "Device Model": context.deviceModel, "OS Name": context.osName, "OS Version": context.osVersion, - "SDK Name": environment.sdkName(), - "SDK Version": environment.sdkVersion(), + "SDK Name": context.klaviyoSdk, + "SDK Version": context.sdkVersion, "App Name": context.appName, "App ID": context.bundleId, "App Version": context.appVersion, - "App Build": context.appBuild, - "Push Token": pushToken ?? "" + "App Build": context.appBuild ] + if let pushToken { + metadata["Push Token"] = pushToken + } + return merging(metadata) { _, new in new } } } diff --git a/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift b/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift index ce198546..e28ee28b 100644 --- a/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift +++ b/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift @@ -5,10 +5,10 @@ // Created by Ajay Subramanya on 8/5/24. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies -public struct CreateProfilePayload: Equatable, Codable { +public struct CreateProfilePayload: Equatable, Codable, Sendable { public init(data: ProfilePayload) { self.data = data } diff --git a/Sources/KlaviyoCore/Models/APIModels/FullFormsResponse.swift b/Sources/KlaviyoCore/Models/APIModels/FullFormsResponse.swift index c6bfd4f0..5fbad66a 100644 --- a/Sources/KlaviyoCore/Models/APIModels/FullFormsResponse.swift +++ b/Sources/KlaviyoCore/Models/APIModels/FullFormsResponse.swift @@ -7,7 +7,7 @@ import Foundation -public struct FullFormsResponse: Codable, Equatable { +public struct FullFormsResponse: Codable, Equatable, Sendable { public let fullForms: [FullForm] public let formSettings: FormSettings public let dynamicInfoConfig: DynamicInfoConfig? @@ -20,19 +20,19 @@ public struct FullFormsResponse: Codable, Equatable { } extension FullFormsResponse { - public struct FullForm: Codable, Equatable { + public struct FullForm: Codable, Equatable, Sendable { // TODO: determine which properties we need to decode } } extension FullFormsResponse { - public struct FormSettings: Codable, Equatable { + public struct FormSettings: Codable, Equatable, Sendable { // TODO: determine which properties we need to decode } } extension FullFormsResponse { - public struct DynamicInfoConfig: Codable, Equatable { + public struct DynamicInfoConfig: Codable, Equatable, Sendable { // TODO: determine which properties we need to decode } } diff --git a/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift b/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift index 2711a771..266b221d 100644 --- a/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift +++ b/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift @@ -5,15 +5,15 @@ // Created by Ajay Subramanya on 8/6/24. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies /** Internal structure which has details not needed by the API. */ -public struct ProfilePayload: Equatable, Codable { +public struct ProfilePayload: Equatable, Codable, Sendable { var type = "profile" - public struct Attributes: Equatable, Codable { + public struct Attributes: Equatable, Codable, Sendable { public let anonymousId: String public let email: String? public let phoneNumber: String? @@ -63,7 +63,7 @@ public struct ProfilePayload: Equatable, Codable { self.anonymousId = anonymousId } - public struct Location: Equatable, Codable { + public struct Location: Equatable, Codable, Sendable { public var address1: String? public var address2: String? public var city: String? @@ -73,6 +73,7 @@ public struct ProfilePayload: Equatable, Codable { public var region: String? public var zip: String? public var timezone: String? + public init(address1: String? = nil, address2: String? = nil, city: String? = nil, diff --git a/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift b/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift index 441f5c88..0431b7c7 100644 --- a/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift +++ b/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift @@ -7,25 +7,27 @@ import Foundation -public struct PushTokenPayload: Equatable, Codable { +public struct PushTokenPayload: Equatable, Codable, Sendable { public let data: PushToken - public struct PushToken: Equatable, Codable { + public struct PushToken: Equatable, Codable, Sendable { var type = "push-token" public var attributes: Attributes public init(pushToken: String, enablement: String, background: String, - profile: ProfilePayload) { + profile: ProfilePayload, + appContextInfo: AppContextInfo) { attributes = Attributes( pushToken: pushToken, enablement: enablement, background: background, - profile: profile) + profile: profile, + appContextInfo: appContextInfo) } - public struct Attributes: Equatable, Codable { + public struct Attributes: Equatable, Codable, Sendable { public let profile: Profile public let token: String public let enablementStatus: String @@ -47,16 +49,17 @@ public struct PushTokenPayload: Equatable, Codable { public init(pushToken: String, enablement: String, background: String, - profile: ProfilePayload) { + profile: ProfilePayload, + appContextInfo: AppContextInfo) { token = pushToken enablementStatus = enablement backgroundStatus = background self.profile = Profile(data: profile) - deviceMetadata = MetaData(context: environment.appContextInfo()) + deviceMetadata = MetaData(context: appContextInfo) } - public struct Profile: Equatable, Codable { + public struct Profile: Equatable, Codable, Sendable { public let data: ProfilePayload public init(data: ProfilePayload) { @@ -64,7 +67,7 @@ public struct PushTokenPayload: Equatable, Codable { } } - public struct MetaData: Equatable, Codable { + public struct MetaData: Equatable, Codable, Sendable { public let deviceId: String public let deviceModel: String public let manufacturer: String @@ -104,8 +107,8 @@ public struct PushTokenPayload: Equatable, Codable { appVersion = context.appVersion appBuild = context.appBuild environment = context.environment - klaviyoSdk = KlaviyoCore.environment.sdkName() - sdkVersion = KlaviyoCore.environment.sdkVersion() + klaviyoSdk = context.klaviyoSdk + sdkVersion = context.sdkVersion } } } @@ -118,11 +121,13 @@ public struct PushTokenPayload: Equatable, Codable { public init(pushToken: String, enablement: String, background: String, - profile: ProfilePayload) { + profile: ProfilePayload, + appContextInfo: AppContextInfo) { data = PushToken( pushToken: pushToken, enablement: enablement, background: background, - profile: profile) + profile: profile, + appContextInfo: appContextInfo) } } diff --git a/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift b/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift index 4e17044b..727dee6e 100644 --- a/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift +++ b/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift @@ -7,10 +7,10 @@ import Foundation -public struct UnregisterPushTokenPayload: Equatable, Codable { +public struct UnregisterPushTokenPayload: Equatable, Codable, Sendable { public let data: PushToken - public struct PushToken: Equatable, Codable { + public struct PushToken: Equatable, Codable, Sendable { var type = "push-token-unregister" public let attributes: Attributes @@ -27,7 +27,7 @@ public struct UnregisterPushTokenPayload: Equatable, Codable { anonymousId: anonymousId) } - public struct Attributes: Equatable, Codable { + public struct Attributes: Equatable, Codable, Sendable { public let profile: Profile public let token: String public let platform: String = "ios" @@ -67,7 +67,7 @@ public struct UnregisterPushTokenPayload: Equatable, Codable { anonymousId: anonymousId) } - public struct Profile: Equatable, Codable { + public struct Profile: Equatable, Codable, Sendable { public let data: ProfilePayload public init(email: String? = nil, diff --git a/Sources/KlaviyoCore/Models/PushBackground.swift b/Sources/KlaviyoCore/Models/PushBackground.swift index ee0d58ae..01de8c4c 100644 --- a/Sources/KlaviyoCore/Models/PushBackground.swift +++ b/Sources/KlaviyoCore/Models/PushBackground.swift @@ -7,7 +7,7 @@ import UIKit -public enum PushBackground: String, Codable { +public enum PushBackground: String, Codable, Sendable { case available = "AVAILABLE" case restricted = "RESTRICTED" case denied = "DENIED" diff --git a/Sources/KlaviyoCore/Models/PushEnablement.swift b/Sources/KlaviyoCore/Models/PushEnablement.swift index 1a321596..040dbc05 100644 --- a/Sources/KlaviyoCore/Models/PushEnablement.swift +++ b/Sources/KlaviyoCore/Models/PushEnablement.swift @@ -7,7 +7,7 @@ import UIKit -public enum PushEnablement: String, Codable { +public enum PushEnablement: String, Codable, Sendable { case notDetermined = "NOT_DETERMINED" case denied = "DENIED" case authorized = "AUTHORIZED" diff --git a/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift b/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift index 81a44913..fbd0309c 100644 --- a/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift +++ b/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift @@ -5,13 +5,13 @@ // Created by Noah Durell on 11/8/22. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies -public struct KlaviyoAPI { - public var send: (KlaviyoRequest, Int) async -> Result +public struct KlaviyoAPI: Sendable { + public var send: @Sendable (NetworkSession, KlaviyoRequest, Int) async -> Result - public init(send: @escaping (KlaviyoRequest, Int) async -> Result = { request, attemptNumber in + public init(send: @Sendable @escaping (NetworkSession, KlaviyoRequest, Int) async -> Result = { session, request, attemptNumber in let start = environment.date() var urlRequest: URLRequest @@ -27,7 +27,7 @@ public struct KlaviyoAPI { var response: URLResponse var data: Data do { - (data, response) = try await environment.networkSession().data(urlRequest) + (data, response) = try await session.data(urlRequest) } catch { requestHandler(request, urlRequest, .error(.requestFailed(error))) return .failure(KlaviyoAPIError.networkError(error)) @@ -55,7 +55,7 @@ public struct KlaviyoAPI { } guard 200..<300 ~= httpResponse.statusCode else { - requestHandler(request, urlRequest, .error(.httpError(statusCode: httpResponse.statusCode, duration: duration))) + requestHandler(request, urlRequest, .error(.httpError(statusCode: httpResponse.statusCode, duration: duration, data: data))) return .failure(KlaviyoAPIError.httpError(httpResponse.statusCode, data)) } @@ -67,5 +67,9 @@ public struct KlaviyoAPI { } // For internal testing use only + #if swift(>=5.10) + public nonisolated(unsafe) static var requestHandler: (KlaviyoRequest, URLRequest?, RequestStatus) -> Void = { _, _, _ in } + #else public static var requestHandler: (KlaviyoRequest, URLRequest?, RequestStatus) -> Void = { _, _, _ in } + #endif } diff --git a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift index 93507d8c..018676a1 100644 --- a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift +++ b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift @@ -6,10 +6,10 @@ // Created by Noah Durell on 11/25/22. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies -public enum KlaviyoEndpoint: Equatable, Codable { +public enum KlaviyoEndpoint: Equatable, Codable, Sendable { case createProfile(CreateProfilePayload) case createEvent(CreateEventPayload) case registerPushToken(PushTokenPayload) diff --git a/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift b/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift index 357229d7..28f926b0 100644 --- a/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift +++ b/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift @@ -5,10 +5,10 @@ // Created by Ajay Subramanya on 8/5/24. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies -public struct KlaviyoRequest: Equatable, Codable { +public struct KlaviyoRequest: Equatable, Codable, Sendable { public let apiKey: String public let endpoint: KlaviyoEndpoint public var uuid: String @@ -16,7 +16,7 @@ public struct KlaviyoRequest: Equatable, Codable { public init( apiKey: String, endpoint: KlaviyoEndpoint, - uuid: String = environment.uuid().uuidString) { + uuid: String) { self.apiKey = apiKey self.endpoint = endpoint self.uuid = uuid diff --git a/Sources/KlaviyoCore/Networking/NetworkSession.swift b/Sources/KlaviyoCore/Networking/NetworkSession.swift index 72f955aa..7b3c7d3b 100644 --- a/Sources/KlaviyoCore/Networking/NetworkSession.swift +++ b/Sources/KlaviyoCore/Networking/NetworkSession.swift @@ -7,24 +7,46 @@ import Foundation -public func createEmphemeralSession(protocolClasses: [AnyClass] = URLProtocolOverrides.protocolClasses) -> URLSession { +@MainActor var userAgent: String? + +@MainActor let defaultUserAgent = { () async -> String in + if let userAgent = await userAgent { + return userAgent + } + let appContext = await environment.appContextInfo() + let sdkVersion = appContext.sdkVersion + let sdkName = appContext.klaviyoSdk + let klaivyoSDKVersion = "klaviyo-\(sdkName)/\(sdkVersion)" + let userAgent = "\(appContext.executable)/\(appContext.appVersion) (\(appContext.bundleId); build:\(appContext.appBuild); \(appContext.osVersionName)) \(klaivyoSDKVersion)" + return userAgent +} + +@MainActor var urlSession: URLSession? + +@MainActor +public func createEmphemeralSession(userAgent: String, protocolClasses: [AnyClass] = URLProtocolOverrides.protocolClasses) -> URLSession { + if let urlSession = urlSession { + return urlSession + } let configuration = URLSessionConfiguration.ephemeral configuration.httpAdditionalHeaders = [ "Accept-Encoding": NetworkSession.acceptedEncodings, - "User-Agent": NetworkSession.defaultUserAgent, + "User-Agent": userAgent, "revision": NetworkSession.currentApiRevision, "content-type": NetworkSession.applicationJson, "accept": NetworkSession.applicationJson, "X-Klaviyo-Mobile": NetworkSession.mobileHeader ] configuration.protocolClasses = protocolClasses - return URLSession(configuration: configuration) + let session = URLSession(configuration: configuration) + urlSession = session + return session } -public struct NetworkSession { - public var data: (URLRequest) async throws -> (Data, URLResponse) +public struct NetworkSession: Sendable { + public var data: @Sendable (URLRequest) async throws -> (Data, URLResponse) - public init(data: @escaping (URLRequest) async throws -> (Data, URLResponse)) { + public init(data: @Sendable @escaping (URLRequest) async throws -> (Data, URLResponse)) { self.data = data } @@ -33,17 +55,16 @@ public struct NetworkSession { fileprivate static let acceptedEncodings = ["br", "gzip", "deflate"] fileprivate static let mobileHeader = "1" - public static let defaultUserAgent = { () -> String in - let appContext = environment.appContextInfo() - let klaivyoSDKVersion = "klaviyo-\(environment.sdkName())/\(environment.sdkVersion())" - return "\(appContext.executable)/\(appContext.appVersion) (\(appContext.bundleId); build:\(appContext.appBuild); \(appContext.osVersionName)) \(klaivyoSDKVersion)" - }() - public static let production = { () -> NetworkSession in - let session = createEmphemeralSession() - - return NetworkSession(data: { request async throws -> (Data, URLResponse) in + NetworkSession(data: { request async throws -> (Data, URLResponse) in + let userAgent = await defaultUserAgent() + #if swift(>=6) + let session = createEmphemeralSession(userAgent: userAgent) + #else + let session = await createEmphemeralSession(userAgent: userAgent) + #endif + // ND: why assign protocols again?? session.configuration.protocolClasses = URLProtocolOverrides.protocolClasses if #available(iOS 15, *) { return try await session.data(for: request) @@ -64,5 +85,9 @@ public struct NetworkSession { } public enum URLProtocolOverrides { + #if swift(>=6) + public nonisolated(unsafe) static var protocolClasses = [AnyClass]() + #else public static var protocolClasses = [AnyClass]() + #endif } diff --git a/Sources/KlaviyoCore/Networking/SDKRequestIterator.swift b/Sources/KlaviyoCore/Networking/SDKRequestIterator.swift index dfb03359..256d87c7 100644 --- a/Sources/KlaviyoCore/Networking/SDKRequestIterator.swift +++ b/Sources/KlaviyoCore/Networking/SDKRequestIterator.swift @@ -5,14 +5,14 @@ // Created by Noah Durell on 2/13/23. // -import AnyCodable import Foundation +import KlaviyoSDKDependencies @_spi(KlaviyoPrivate) -public struct SDKRequest: Identifiable, Equatable { +public struct SDKRequest: Sendable, Identifiable, Equatable { @_spi(KlaviyoPrivate) - public enum RequestType: Equatable { - public struct EventInfo: Equatable { + public enum RequestType: Sendable, Equatable { + public struct EventInfo: Sendable, Equatable { public let eventName: String @_spi(KlaviyoPrivate) @@ -22,7 +22,7 @@ public struct SDKRequest: Identifiable, Equatable { } @_spi(KlaviyoPrivate) - public struct ProfileInfo: Equatable { + public struct ProfileInfo: Sendable, Equatable { public var email: String? public var phoneNumber: String? public var externalId: String? @@ -81,10 +81,10 @@ public struct SDKRequest: Identifiable, Equatable { } @_spi(KlaviyoPrivate) - public enum Response: Equatable { + public enum Response: Sendable, Equatable { case inProgress case success(String, Double) - case httpError(Int, Double) + case httpError(Int, Double, Data) case requestError(String, Double) } @@ -137,7 +137,7 @@ public enum RequestStatus { /// - Parameter retryAfter: The amount of time, in seconds, that the client should wait before making another request. case rateLimited(retryAfter: Int) /// - Parameter duration: The elapsed time, in seconds, between the API call and the server’s response. - case httpError(statusCode: Int, duration: TimeInterval) + case httpError(statusCode: Int, duration: TimeInterval, data: Data) } case started @@ -165,8 +165,8 @@ public func requestIterator() -> AsyncStream { case let .requestFailed(requestError): let duration = 0.0 continuation.yield(SDKRequest.fromAPIRequest(request: request, urlRequest: urlRequest, response: .requestError(requestError.localizedDescription, duration))) - case let .httpError(statusCode, duration: duration): - continuation.yield(SDKRequest.fromAPIRequest(request: request, urlRequest: urlRequest, response: .httpError(statusCode, duration))) + case let .httpError(statusCode, duration: duration, data: data): + continuation.yield(SDKRequest.fromAPIRequest(request: request, urlRequest: urlRequest, response: .httpError(statusCode, duration, data))) case let .rateLimited(retryAfter): continuation.yield(SDKRequest.fromAPIRequest(request: request, urlRequest: urlRequest, response: .requestError("Rate Limited", Double(retryAfter)))) } diff --git a/Sources/KlaviyoCore/Utils/ArchivalUtils.swift b/Sources/KlaviyoCore/Utils/ArchivalUtils.swift index cd9c79e3..6cece0d9 100644 --- a/Sources/KlaviyoCore/Utils/ArchivalUtils.swift +++ b/Sources/KlaviyoCore/Utils/ArchivalUtils.swift @@ -7,7 +7,7 @@ import Foundation -public struct ArchiverClient { +public struct ArchiverClient: @unchecked Sendable { public init( archivedData: @escaping (Any, Bool) throws -> Data, unarchivedMutableArray: @escaping (Data) throws -> NSMutableArray?) { @@ -31,21 +31,21 @@ public struct ArchiverClient { }) } -public func archiveQueue(queue: NSArray, to fileURL: URL) { +public func archiveQueue(fileClient: FileClient, queue: NSArray, to fileURL: URL) { guard let archiveData = try? environment.archiverClient.archivedData(queue, true) else { print("unable to archive the data to \(fileURL)") return } do { - try environment.fileClient.write(archiveData, fileURL) + try fileClient.write(archiveData, fileURL) } catch { print("Unable to write archive data to file at URL: \(fileURL) error: \(error.localizedDescription)") } } -public func unarchiveFromFile(fileURL: URL) -> NSMutableArray? { - guard environment.fileClient.fileExists(fileURL.path) else { +public func unarchiveFromFile(fileClient: FileClient, fileURL: URL) -> NSMutableArray? { + guard fileClient.fileExists(fileURL.path) else { print("Archive file not found.") return nil } @@ -59,7 +59,7 @@ public func unarchiveFromFile(fileURL: URL) -> NSMutableArray? { return nil } - if !removeFile(at: fileURL) { + if !removeFile(fileClient: environment.fileClient, at: fileURL) { print("Unable to remove archived data!") } return unarchivedData diff --git a/Sources/KlaviyoCore/Utils/FileUtils.swift b/Sources/KlaviyoCore/Utils/FileUtils.swift index bb91cc66..63b3bf7b 100644 --- a/Sources/KlaviyoCore/Utils/FileUtils.swift +++ b/Sources/KlaviyoCore/Utils/FileUtils.swift @@ -11,7 +11,7 @@ func write(data: Data, url: URL) throws { try data.write(to: url, options: .atomic) } -public struct FileClient { +public struct FileClient: @unchecked Sendable { public init( write: @escaping (Data, URL) throws -> Void, fileExists: @escaping (String) -> Bool, @@ -55,10 +55,10 @@ public func filePathForData(apiKey: String, data: String) -> URL { - Parameter at: path of file to be removed - Returns: whether or not the file was removed */ -public func removeFile(at url: URL) -> Bool { - if environment.fileClient.fileExists(url.path) { +public func removeFile(fileClient: FileClient, at url: URL) -> Bool { + if fileClient.fileExists(url.path) { do { - try environment.fileClient.removeItem(url.path) + try fileClient.removeItem(url.path) return true } catch { return false diff --git a/Sources/KlaviyoCore/Utils/LoggerClient.swift b/Sources/KlaviyoCore/Utils/LoggerClient.swift index 24778205..8e396d70 100644 --- a/Sources/KlaviyoCore/Utils/LoggerClient.swift +++ b/Sources/KlaviyoCore/Utils/LoggerClient.swift @@ -10,7 +10,7 @@ import Foundation import os #endif -public struct LoggerClient { +public struct LoggerClient: @unchecked Sendable { public init(error: @escaping (String) -> Void) { self.error = error } @@ -23,7 +23,7 @@ public struct LoggerClient { @inline(__always) func runtimeWarn( _ message: @autoclosure () -> String, - category: String? = environment.sdkName(), + category: String? = nil, file: StaticString? = nil, line: UInt? = nil) { #if DEBUG diff --git a/Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift b/Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift index 3f383bb9..98457c8f 100644 --- a/Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift +++ b/Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift @@ -47,7 +47,7 @@ func callback(reachability: SCNetworkReachability, flags: SCNetworkReachabilityF } } -public class Reachability { +public class Reachability: @unchecked Sendable { public init( whenReachable: Reachability.NetworkReachable? = nil, whenUnreachable: Reachability.NetworkUnreachable? = nil, @@ -76,7 +76,7 @@ public class Reachability { public typealias NetworkReachable = (Reachability) -> Void public typealias NetworkUnreachable = (Reachability) -> Void - public enum NetworkStatus: CustomStringConvertible { + public enum NetworkStatus: CustomStringConvertible, Sendable { case notReachable, reachableViaWiFi, reachableViaWWAN public var description: String { diff --git a/Sources/KlaviyoSDKDependencies/AnyCodable/AnyCodable.swift b/Sources/KlaviyoSDKDependencies/AnyCodable/AnyCodable.swift new file mode 100644 index 00000000..9f2cf84d --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/AnyCodable/AnyCodable.swift @@ -0,0 +1,150 @@ +/// Adapted from AnyCodable v0.6.7 on 11/15/2024 +/// https://github.com/Flight-School/AnyCodable/tree/0.6.7 +/// Comments - modified to make this @unchecked Sendable + +import Foundation +/** + A type-erased `Codable` value. + + The `AnyCodable` type forwards encoding and decoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can encode or decode mixed-type values in dictionaries + and other collections that require `Encodable` or `Decodable` conformance + by declaring their contained type to be `AnyCodable`. + + - SeeAlso: `AnyEncodable` + - SeeAlso: `AnyDecodable` + */ +@frozen public struct AnyCodable: Codable, @unchecked Sendable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +extension AnyCodable: _AnyEncodable, _AnyDecodable {} + +extension AnyCodable: Equatable { + public static func ==(lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (Void, Void): + return true + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]): + return lhs == rhs + case let (lhs as [AnyCodable], rhs as [AnyCodable]): + return lhs == rhs + case let (lhs as [String: Any], rhs as [String: Any]): + return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs) + case let (lhs as [Any], rhs as [Any]): + return NSArray(array: lhs) == NSArray(array: rhs) + case is (NSNull, NSNull): + return true + default: + return false + } + } +} + +extension AnyCodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyCodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyCodable(\(value.debugDescription))" + default: + return "AnyCodable(\(description))" + } + } +} + +extension AnyCodable: ExpressibleByNilLiteral {} +extension AnyCodable: ExpressibleByBooleanLiteral {} +extension AnyCodable: ExpressibleByIntegerLiteral {} +extension AnyCodable: ExpressibleByFloatLiteral {} +extension AnyCodable: ExpressibleByStringLiteral {} +extension AnyCodable: ExpressibleByStringInterpolation {} +extension AnyCodable: ExpressibleByArrayLiteral {} +extension AnyCodable: ExpressibleByDictionaryLiteral {} + +extension AnyCodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyCodable]: + hasher.combine(value) + case let value as [AnyCodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/AnyCodable/AnyDecodable.swift b/Sources/KlaviyoSDKDependencies/AnyCodable/AnyDecodable.swift new file mode 100644 index 00000000..7334d666 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/AnyCodable/AnyDecodable.swift @@ -0,0 +1,191 @@ +/// Copied verbatim from swift-case-paths v0.6.7 on 11/15/2024 +/// https://github.com/Flight-School/AnyCodable/tree/0.6.7 + +#if canImport(Foundation) +import Foundation +#endif + +/** + A type-erased `Decodable` value. + + The `AnyDecodable` type forwards decoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can decode mixed-type values in dictionaries + and other collections that require `Decodable` conformance + by declaring their contained type to be `AnyDecodable`: + + let json = """ + { + "boolean": true, + "integer": 42, + "double": 3.141592653589793, + "string": "string", + "array": [1, 2, 3], + "nested": { + "a": "alpha", + "b": "bravo", + "c": "charlie" + }, + "null": null + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + let dictionary = try! decoder.decode([String: AnyDecodable].self, from: json) + */ +@frozen public struct AnyDecodable: Decodable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +@usableFromInline +protocol _AnyDecodable { + var value: Any { get } + init(_ value: T?) +} + +extension AnyDecodable: _AnyDecodable {} + +extension _AnyDecodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + #if canImport(Foundation) + self.init(NSNull()) + #else + self.init(Self?.none) + #endif + } else if let bool = try? container.decode(Bool.self) { + self.init(bool) + } else if let int = try? container.decode(Int.self) { + self.init(int) + } else if let uint = try? container.decode(UInt.self) { + self.init(uint) + } else if let double = try? container.decode(Double.self) { + self.init(double) + } else if let string = try? container.decode(String.self) { + self.init(string) + } else if let array = try? container.decode([AnyDecodable].self) { + self.init(array.map(\.value)) + } else if let dictionary = try? container.decode([String: AnyDecodable].self) { + self.init(dictionary.mapValues { $0.value }) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyDecodable value cannot be decoded") + } + } +} + +extension AnyDecodable: Equatable { + public static func ==(lhs: AnyDecodable, rhs: AnyDecodable) -> Bool { + switch (lhs.value, rhs.value) { + #if canImport(Foundation) + case is (NSNull, NSNull), is (Void, Void): + return true + #endif + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyDecodable], rhs as [String: AnyDecodable]): + return lhs == rhs + case let (lhs as [AnyDecodable], rhs as [AnyDecodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyDecodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyDecodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyDecodable(\(value.debugDescription))" + default: + return "AnyDecodable(\(description))" + } + } +} + +extension AnyDecodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyDecodable]: + hasher.combine(value) + case let value as [AnyDecodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/AnyCodable/AnyEncodable.swift b/Sources/KlaviyoSDKDependencies/AnyCodable/AnyEncodable.swift new file mode 100644 index 00000000..c30c7d9e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/AnyCodable/AnyEncodable.swift @@ -0,0 +1,294 @@ +/// Copied verbatim from swift-case-paths v0.6.7 on 11/15/2024 +/// https://github.com/Flight-School/AnyCodable/tree/0.6.7 + +#if canImport(Foundation) +import Foundation +#endif + +/** + A type-erased `Encodable` value. + + The `AnyEncodable` type forwards encoding responsibilities + to an underlying value, hiding its specific underlying type. + + You can encode mixed-type values in dictionaries + and other collections that require `Encodable` conformance + by declaring their contained type to be `AnyEncodable`: + + let dictionary: [String: AnyEncodable] = [ + "boolean": true, + "integer": 42, + "double": 3.141592653589793, + "string": "string", + "array": [1, 2, 3], + "nested": [ + "a": "alpha", + "b": "bravo", + "c": "charlie" + ], + "null": nil + ] + + let encoder = JSONEncoder() + let json = try! encoder.encode(dictionary) + */ +@frozen public struct AnyEncodable: Encodable, @unchecked Sendable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } +} + +@usableFromInline +protocol _AnyEncodable { + var value: Any { get } + init(_ value: T?) +} + +extension AnyEncodable: _AnyEncodable {} + +// MARK: - Encodable + +extension _AnyEncodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + #if canImport(Foundation) + case is NSNull: + try container.encodeNil() + #endif + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let int8 as Int8: + try container.encode(int8) + case let int16 as Int16: + try container.encode(int16) + case let int32 as Int32: + try container.encode(int32) + case let int64 as Int64: + try container.encode(int64) + case let uint as UInt: + try container.encode(uint) + case let uint8 as UInt8: + try container.encode(uint8) + case let uint16 as UInt16: + try container.encode(uint16) + case let uint32 as UInt32: + try container.encode(uint32) + case let uint64 as UInt64: + try container.encode(uint64) + case let float as Float: + try container.encode(float) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + #if canImport(Foundation) + case let number as NSNumber: + try encode(nsnumber: number, into: &container) + case let date as Date: + try container.encode(date) + case let url as URL: + try container.encode(url) + #endif + case let array as [Any?]: + try container.encode(array.map { AnyEncodable($0) }) + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues { AnyEncodable($0) }) + case let encodable as Encodable: + try encodable.encode(to: encoder) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyEncodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } + + #if canImport(Foundation) + private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws { + switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) { + case "B": + try container.encode(nsnumber.boolValue) + case "c": + try container.encode(nsnumber.int8Value) + case "s": + try container.encode(nsnumber.int16Value) + case "i", "l": + try container.encode(nsnumber.int32Value) + case "q": + try container.encode(nsnumber.int64Value) + case "C": + try container.encode(nsnumber.uint8Value) + case "S": + try container.encode(nsnumber.uint16Value) + case "I", "L": + try container.encode(nsnumber.uint32Value) + case "Q": + try container.encode(nsnumber.uint64Value) + case "f": + try container.encode(nsnumber.floatValue) + case "d": + try container.encode(nsnumber.doubleValue) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "NSNumber cannot be encoded because its type is not handled") + throw EncodingError.invalidValue(nsnumber, context) + } + } + #endif +} + +extension AnyEncodable: Equatable { + public static func ==(lhs: AnyEncodable, rhs: AnyEncodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (Void, Void): + return true + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + case let (lhs as Int, rhs as Int): + return lhs == rhs + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + case let (lhs as Float, rhs as Float): + return lhs == rhs + case let (lhs as Double, rhs as Double): + return lhs == rhs + case let (lhs as String, rhs as String): + return lhs == rhs + case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]): + return lhs == rhs + case let (lhs as [AnyEncodable], rhs as [AnyEncodable]): + return lhs == rhs + default: + return false + } + } +} + +extension AnyEncodable: CustomStringConvertible { + public var description: String { + switch value { + case is Void: + return String(describing: nil as Any?) + case let value as CustomStringConvertible: + return value.description + default: + return String(describing: value) + } + } +} + +extension AnyEncodable: CustomDebugStringConvertible { + public var debugDescription: String { + switch value { + case let value as CustomDebugStringConvertible: + return "AnyEncodable(\(value.debugDescription))" + default: + return "AnyEncodable(\(description))" + } + } +} + +extension AnyEncodable: ExpressibleByNilLiteral {} +extension AnyEncodable: ExpressibleByBooleanLiteral {} +extension AnyEncodable: ExpressibleByIntegerLiteral {} +extension AnyEncodable: ExpressibleByFloatLiteral {} +extension AnyEncodable: ExpressibleByStringLiteral {} +extension AnyEncodable: ExpressibleByStringInterpolation {} +extension AnyEncodable: ExpressibleByArrayLiteral {} +extension AnyEncodable: ExpressibleByDictionaryLiteral {} + +extension _AnyEncodable { + public init(nilLiteral _: ()) { + self.init(nil as Any?) + } + + public init(booleanLiteral value: Bool) { + self.init(value) + } + + public init(integerLiteral value: Int) { + self.init(value) + } + + public init(floatLiteral value: Double) { + self.init(value) + } + + public init(extendedGraphemeClusterLiteral value: String) { + self.init(value) + } + + public init(stringLiteral value: String) { + self.init(value) + } + + public init(arrayLiteral elements: Any...) { + self.init(elements) + } + + public init(dictionaryLiteral elements: (AnyHashable, Any)...) { + self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first })) + } +} + +extension AnyEncodable: Hashable { + public func hash(into hasher: inout Hasher) { + switch value { + case let value as Bool: + hasher.combine(value) + case let value as Int: + hasher.combine(value) + case let value as Int8: + hasher.combine(value) + case let value as Int16: + hasher.combine(value) + case let value as Int32: + hasher.combine(value) + case let value as Int64: + hasher.combine(value) + case let value as UInt: + hasher.combine(value) + case let value as UInt8: + hasher.combine(value) + case let value as UInt16: + hasher.combine(value) + case let value as UInt32: + hasher.combine(value) + case let value as UInt64: + hasher.combine(value) + case let value as Float: + hasher.combine(value) + case let value as Double: + hasher.combine(value) + case let value as String: + hasher.combine(value) + case let value as [String: AnyEncodable]: + hasher.combine(value) + case let value as [AnyEncodable]: + hasher.combine(value) + default: + break + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/AnyCasePath.swift b/Sources/KlaviyoSDKDependencies/CasePaths/AnyCasePath.swift new file mode 100644 index 00000000..703f1329 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/AnyCasePath.swift @@ -0,0 +1,118 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +import Foundation + +/// A type-erased case path that supports embedding a value in a root and attempting to extract a +/// root's embedded value. +/// +/// This type defines key path-like semantics for enum cases, and is used to derive ``CaseKeyPath``s +/// from types that conform to ``CasePathable``. +@dynamicMemberLookup +public struct AnyCasePath: Sendable { + private let _embed: @Sendable (Value) -> Root + private let _extract: @Sendable (Root) -> Value? + + /// Creates a type-erased case path from a pair of functions. + /// + /// - Parameters: + /// - embed: A function that always succeeds in embedding a value in a root. + /// - extract: A function that can optionally fail in extracting a value from a root. + public init( + embed: @escaping @Sendable (Value) -> Root, + extract: @escaping @Sendable (Root) -> Value? + ) { + self._embed = embed + self._extract = extract + } + + public static func _$embed( + _ embed: @escaping (Value) -> Root, + extract: @escaping @Sendable (Root) -> Value? + ) -> Self { + #if swift(>=5.10) + nonisolated(unsafe) let embed = embed + return Self(embed: { embed($0) }, extract: extract) + #else + @CasePathsUncheckedSendable var embed = embed + return Self(embed: { [$embed] in $embed.wrappedValue($0) }, extract: extract) + #endif + } + + /// Returns a root by embedding a value. + /// + /// - Parameter value: A value to embed. + /// - Returns: A root that embeds `value`. + public func embed(_ value: Value) -> Root { + self._embed(value) + } + + /// Attempts to extract a value from a root. + /// + /// - Parameter root: A root to extract from. + /// - Returns: A value if it can be extracted from the given root, otherwise `nil`. + public func extract(from root: Root) -> Value? { + self._extract(root) + } +} + +extension AnyCasePath where Root == Value { + /// The identity case path. + /// + /// A case path that: + /// + /// * Given a value to embed, returns the given value. + /// * Given a value to extract, returns the given value. + public init() where Root == Value { + self.init(embed: { $0 }, extract: { $0 }) + } +} + +extension AnyCasePath: CustomDebugStringConvertible { + public var debugDescription: String { + "AnyCasePath<\(typeName(Root.self)), \(typeName(Value.self))>" + } +} + +extension AnyCasePath { + @available( + iOS, deprecated: 9999, + message: "Use 'CasePathable.modify', or 'extract' and 'embed', instead." + ) + @available( + macOS, deprecated: 9999, + message: "Use 'CasePathable.modify', or 'extract' and 'embed', instead." + ) + @available( + tvOS, deprecated: 9999, + message: "Use 'CasePathable.modify', or 'extract' and 'embed', instead." + ) + @available( + watchOS, deprecated: 9999, + message: "Use 'CasePathable.modify', or 'extract' and 'embed', instead." + ) + public func modify( + _ root: inout Root, + _ body: (inout Value) throws -> Result + ) throws -> Result { + guard var value = self.extract(from: root) else { throw ExtractionFailed() } + let result = try body(&value) + root = self.embed(value) + return result + } + + @available(iOS, deprecated: 9999, message: "Chain case key paths together, instead.") + @available(macOS, deprecated: 9999, message: "Chain case key paths together, instead.") + @available(tvOS, deprecated: 9999, message: "Chain case key paths together, instead.") + @available(watchOS, deprecated: 9999, message: "Chain case key paths together, instead.") + public func appending( + path: AnyCasePath + ) -> AnyCasePath { + AnyCasePath( + embed: { self.embed(path.embed($0)) }, + extract: { self.extract(from: $0).flatMap(path.extract) } + ) + } +} + +struct ExtractionFailed: Error {} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/CasePathIterable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/CasePathIterable.swift new file mode 100644 index 00000000..54ce4b38 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/CasePathIterable.swift @@ -0,0 +1,20 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +/// A type that provides a collection of all of its case paths. +/// +/// The ``CasePathable()`` macro automatically generates a conformance to this protocol. +/// +/// You can iterate over ``CasePathable/allCasePaths`` to get access to each individual case path: +/// +/// ```swift +/// @CasePathable enum Field { +/// case title(String) +/// case body(String +/// case isLive +/// } +/// +/// Array(Field.allCasePaths) // [\.title, \.body, \.isLive] +/// ``` +public protocol CasePathIterable: CasePathable +where AllCasePaths: Sequence, AllCasePaths.Element == PartialCaseKeyPath {} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/CasePathReflectable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/CasePathReflectable.swift new file mode 100644 index 00000000..221a809e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/CasePathReflectable.swift @@ -0,0 +1,30 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +/// A type that can reflect a case path from a given case. +/// +/// The ``CasePathable()`` macro automatically generates a conformance to this protocol on the +/// enum's ``CasePathable/AllCasePaths`` type. +/// +/// You can look up an enum's case path by passing it to ``subscript(root:)``: +/// +/// ```swift +/// @CasePathable +/// enum Field { +/// case title(String) +/// case body(String) +/// case isLive +/// } +/// +/// Field.allCasePaths[.title("Hello, Blob!")] // \.title +/// ``` +public protocol CasePathReflectable { + /// The enum type that can be reflected. + associatedtype Root: CasePathable + + /// Returns the case key path for a given root value. + /// + /// - Parameter root: An root value. + /// - Returns: A case path to the root value. + subscript(root: Root) -> PartialCaseKeyPath { get } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/CasePathable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/CasePathable.swift new file mode 100644 index 00000000..b88e4869 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/CasePathable.swift @@ -0,0 +1,566 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +/// A type that provides a collection of all of its case paths. +/// +/// Use the ``CasePathable()`` macro to automatically add case paths, and this conformance, to an +/// enum. +/// +/// It is also possible, though less common, to manually conform a type to `CasePathable`. For +/// example, the `Result` type is extended to be case-pathable with the following extension: +/// +/// ```swift +/// extension Result: CasePathable { +/// public struct AllCasePaths { +/// var success: AnyCasePath { +/// AnyCasePath( +/// embed: { .success($0) }, +/// extract: { +/// guard case let .success(value) = $0 else { return nil } +/// return value +/// } +/// ) +/// } +/// +/// var failure: AnyCasePath { +/// AnyCasePath( +/// embed: { .failure($0) }, +/// extract: { +/// guard case let .failure(value) = $0 else { return nil } +/// return value +/// } +/// ) +/// } +/// } +/// +/// public static var allCasePaths: AllCasePaths { AllCasePaths() } +/// } +/// ``` +public protocol CasePathable { + /// A type that can represent a collection of all case paths of this type. + associatedtype AllCasePaths + + /// A collection of all case paths of this type. + static var allCasePaths: AllCasePaths { get } +} + +/// A type that is used to distinguish case key paths from key paths by wrapping the enum and +/// associated value types. +@_documentation(visibility: internal) +@dynamicMemberLookup +public struct Case: Sendable { + fileprivate let _embed: @Sendable (Value) -> Any + fileprivate let _extract: @Sendable (Any) -> Value? +} + +extension Case { + public init( + embed: @escaping @Sendable (Value) -> Root, + extract: @escaping @Sendable (Root) -> Value? + ) { + self._embed = embed + self._extract = { @Sendable in ($0 as? Root).flatMap(extract) } + } + + public init() { + self.init(embed: { $0 }, extract: { $0 }) + } + + public init(_ keyPath: CaseKeyPath) { + self = Case()[keyPath: keyPath] + } + + // #if swift(>=6) + // public subscript( + // dynamicMember keyPath: KeyPath> + // & Sendable + // ) -> Case + // where Value: CasePathable { + // Case( + // embed: { embed(Value.allCasePaths[keyPath: keyPath].embed($0)) }, + // extract: { extract(from: $0).flatMap(Value.allCasePaths[keyPath: keyPath].extract) } + // ) + // } + // #else + public subscript( + dynamicMember keyPath: KeyPath> + ) -> Case + where Value: CasePathable { + @CasePathsUncheckedSendable var keyPath = keyPath + return Case( + embed: { [$keyPath] in + embed(Value.allCasePaths[keyPath: $keyPath.wrappedValue].embed($0)) + }, + extract: { [$keyPath] in + extract(from: $0).flatMap(Value.allCasePaths[keyPath: $keyPath.wrappedValue].extract) + } + ) + } + // #endif + + public func embed(_ value: Value) -> Any { + self._embed(value) + } + + public func extract(from root: Any) -> Value? { + self._extract(root) + } +} + +private protocol _AnyCase { + func _extract(from root: Any) -> Any? +} + +extension Case: _AnyCase { + fileprivate func _extract(from root: Any) -> Any? { + self.extract(from: root) + } +} + +/// A key path to the associated value of an enum case. +/// +/// The most common way to make an instance of this type is by applying the ``CasePathable()`` macro +/// to an enum and using a key path expression like `\SomeEnum.Cases.someCase`, or simply +/// `\.someCase` where the type can be inferred. +/// +/// To extract an associated value from an enum using a case key path, pass the key path to the +/// ``CasePathable/subscript(case:)-6cdhl``. For example: +/// +/// ```swift +/// @CasePathable +/// enum SomeEnum { +/// case someCase(Int) +/// case anotherCase(String) +/// } +/// +/// let e = SomeEnum.someCase(12) +/// let pathToCase = \SomeEnum.Cases.someCase +/// +/// let value = e[case: pathToCase] +/// // value is Optional(12) +/// +/// let anotherValue = e[case: \.anotherCase] +/// // anotherValue is nil +/// ``` +/// +/// To replace an associated value, assign it through ``CasePathable/subscript(case:)-8yr2s``. If +/// the given path does not match the given enum case, the replacement will fail. For +/// example: +/// +/// ```swift +/// var e = SomeEnum.someCase(12) +/// +/// e[case: \.someCase] = 24 +/// // e is SomeEnum.someCase(24) +/// +/// e[case: \.anotherCase] = "Hello!" +/// // Assignment fails: e is still SomeEnum.someCase(24) +/// ``` +/// +/// To produce a whole instance from a case key path, call the key path directly with the associated +/// value you'd like to embed (via ``Swift/KeyPath/callAsFunction(_:)``): +/// +/// ```swift +/// let pathToCase = \SomeEnum.Cases.someCase +/// +/// let e = pathToCase(12) +/// // e is SomeEnum.someCase(12) +/// ``` +/// +/// The path can contain multiple case names, separated by periods, to refer to a case of a case's +/// value. This code uses the key path expression `\OuterEnum.Cases.outer.someCase` to access the +/// `someCase` associated value of the `OuterEnum` type's `outer` case: +/// +/// ```swift +/// @CasePathable +/// enum OuterEnum { +/// case outer(SomeEnum) +/// } +/// +/// var nested = OuterEnum.outer(.someCase(24)) +/// let nestedCaseKeyPath = \OuterEnum.Cases.outer.someCase +/// +/// let nestedValue = nested[case: nestedCaseKeyPath] +/// // nestedValue is Optional(24) +/// +/// nested[case: \.outer.someCase] = 42 +/// // nested is now OuterEnum.outer(.someCase(42)) +/// ``` +/// +/// Key paths have the identity key path `\SomeStructure.self`, and so case key paths have the +/// identity case key path `\SomeEnum.Cases.self`. It refers to the whole enum and can be passed to +/// a function that takes case key paths when you want to extract, change, or replace all of the +/// data stored in an enum in a single step. +public typealias CaseKeyPath = KeyPath, Case> + +extension CaseKeyPath { + /// Embeds a value in an enum at this case key path's case. + /// + /// Given a case key path to an enum case, one can produce a whole new root value to that case by + /// invoking the key path like a function with an associated value to embed. For example: + /// + /// ```swift + /// @CasePathable + /// enum SomeEnum { + /// case someCase(Int) + /// } + /// + /// let path = \SomeEnum.Cases.someCase + /// + /// let e = path(12) + /// // e is SomeEnum.someCase(12) + /// ``` + /// + /// See ``Swift/KeyPath/callAsFunction()`` for cases with no associated values. + /// + /// - Parameter value: A value to embed. + /// - Returns: An enum for the case of this key path that holds the given value. + public func callAsFunction(_ value: AssociatedValue) -> Enum + where Root == Case, Value == Case { + Case(self).embed(value) as! Enum + } + + /// Returns an enum for this case key path's case. + /// + /// Given a case key path to an enum case with no associated value, one can produce a whole new + /// root value to that case by invoking the key path like a function. For example: + /// + /// ```swift + /// @CasePathable + /// enum SomeEnum { + /// case someCase + /// } + /// + /// let path = \SomeEnum.Cases.someCase + /// + /// let e = path() + /// // e is SomeEnum.someCase + /// ``` + /// + /// See ``Swift/KeyPath/callAsFunction(_:)`` for cases with associated values. + /// + /// - Returns: An enum for the case of this key path. + public func callAsFunction() -> Enum + where Root == Case, Value == Case { + Case(self).embed(()) as! Enum + } + + /// Whether an argument matches the case key path's case. + /// + /// ```swift + /// @CasePathable enum UserAction { + /// case settings(SettingsAction) + /// } + /// @CasePathable enum SettingsAction { + /// case store(StoreAction) + /// } + /// @CasePathable enum StoreAction { + /// case subscribeButtonTapped + /// } + /// + /// switch userAction { + /// case \.settings.store.subscribeButtonTapped: + /// // ... + /// } + /// + /// // Equivalent to: + /// + /// switch userAction { + /// case .settings(.store(.subscribeButtonTapped)): + /// // ... + /// } + /// ``` + /// + /// - Parameters: + /// - lhs: A case key path. + /// - rhs: An enum. + public static func ~= (lhs: KeyPath, rhs: Enum) -> Bool + where Root == Case, Value == Case { + rhs[case: lhs] != nil + } +} + +/// A partially type-erased key path, from a concrete root enum to any resulting value type. +public typealias PartialCaseKeyPath = PartialKeyPath> + +extension _AppendKeyPath { + /// Attempts to embeds any value in an enum at this case key path's case. + /// + /// - Parameter value: A value to embed. If the value type does not match the case path's value + /// type, the operation will fail. + /// - Returns: An enum for the case of this key path that holds the given value, or `nil`. + @_disfavoredOverload + public func callAsFunction( + _ value: Any + ) -> Enum? + where Self == PartialCaseKeyPath { + func open(_ value: AnyAssociatedValue) -> Enum? { + (Case()[keyPath: self] as? Case)?.embed(value) as? Enum + ?? (Case()[keyPath: self] as? Case)?.embed(value) as? Enum + } + return _openExistential(value, do: open) + } +} + +extension CasePathable { + /// A namespace that can be used to derive case key paths from case-pathable enums. + /// + /// One can fully-qualify a ``CaseKeyPath`` for a type conforming to ``CasePathable`` through this + /// namespace. For example: + /// + /// ```swift + /// @CasePathable + /// enum SomeEnum { + /// case someCase(Int) + /// } + /// + /// \SomeEnum.Cases.someCase // CaseKeyPath + /// ``` + public typealias Cases = Case + + /// Attempts to extract the associated value from a root enum using a case key path. + /// + /// For example: + /// + /// ```swift + /// @CasePathable + /// enum SomeEnum { + /// case someCase(Int) + /// case anotherCase(String) + /// } + /// + /// let e = SomeEnum.someCase(12) + /// + /// e[case: \.someCase] // Optional(12) + /// e[case: \.anotherCase] // nil + /// ``` + /// + /// See ``CasePathable/subscript(case:)-8yr2s`` for replacing an associated value in a root + /// enum, and see ``Swift/KeyPath/callAsFunction(_:)`` for embedding an associated value in a + /// brand new root enum. + public subscript(case keyPath: CaseKeyPath) -> Value? { + Case(keyPath).extract(from: self) + } + + /// Attempts to extract the associated value from a root enum using a partial case key path. + @_disfavoredOverload + public subscript(case keyPath: PartialCaseKeyPath) -> Any? { + (Case()[keyPath: keyPath] as? any _AnyCase)?._extract(from: self) + } + + /// Replaces the associated value of a root enum at a case key path when the case matches. + /// + /// For example: + /// + /// ```swift + /// @CasePathable + /// enum SomeEnum { + /// case someCase(Int) + /// case anotherCase(String) + /// } + /// + /// var e = SomeEnum.someCase(12) + /// + /// e[case: \.someCase] = 24 + /// // e is SomeEnum.someCase(24) + /// + /// e[case: \.anotherCase] = "Hello!" + /// // e is still SomeEnum.someCase(24) + /// ``` + /// + /// See ``CasePathable/subscript(case:)-6cdhl`` for extracting an associated value from a root + /// enum, and see ``Swift/KeyPath/callAsFunction(_:)`` for embedding an associated value in a + /// brand new root enum. + @_disfavoredOverload + public subscript(case keyPath: CaseKeyPath) -> Value { + @available(*, unavailable) + get { fatalError() } + set { + let `case` = Case(keyPath) + guard `case`.extract(from: self) != nil else { return } + self = `case`.embed(newValue) as! Self + } + } + + /// Extracts the associated value of a case via dynamic member lookup. + /// + /// Simply annotate the base type with `@dynamicMemberLookup` to enable this functionality: + /// + /// ```swift + /// @CasePathable + /// @dynamicMemberLookup + /// enum UserAction { + /// case home(HomeAction) + /// case settings(SettingsAction) + /// } + /// + /// let userAction: UserAction = .home(.onAppear) + /// userAction.home // Optional(HomeAction.onAppear) + /// userAction.settings // nil + /// + /// let userActions: [UserAction] = [.home(.onAppear), .settings(.subscribeButtonTapped)] + /// userActions.compactMap(\.home) // [HomeAction.onAppear] + /// userActions.compactMap(\.settings) // [SettingsAction.subscribeButtonTapped] + /// ``` + public subscript( + dynamicMember keyPath: KeyPath> + ) -> Value? { + get { Self.allCasePaths[keyPath: keyPath].extract(from: self) } + @available(*, unavailable, message: "Write 'enum = .case(value)', not 'enum.case = value'") + set { + let casePath = Self.allCasePaths[keyPath: keyPath] + guard casePath.extract(from: self) != nil else { + return + } + if let newValue { + self = casePath.embed(newValue) + } + } + } + + /// Embeds the associated value of a case via dynamic member lookup. + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath> + ) -> Value { + @available(*, unavailable) + get { Self.allCasePaths[keyPath: keyPath].extract(from: self)! } + set { + let casePath = Self.allCasePaths[keyPath: keyPath] + guard casePath.extract(from: self) != nil else { + return + } + self = casePath.embed(newValue) + } + } + + /// Tests the associated value of a case. + /// + /// ```swift + /// @CasePathable + /// enum UserAction { + /// case home(HomeAction) + /// case settings(SettingsAction) + /// } + /// + /// let userAction: UserAction = .home(.onAppear) + /// userAction.is(\.home) // true + /// userAction.is(\.settings) // false + /// + /// let userActions: [UserAction] = [.home(.onAppear), .settings(.subscribeButtonTapped)] + /// userActions.filter { $0.is(\.home) } // [UserAction.home(.onAppear)] + /// userActions.filter { $0.is(\.settings) } // [UserAction.settings(.subscribeButtonTapped)] + /// ``` + public func `is`(_ keyPath: PartialCaseKeyPath) -> Bool { + self[case: keyPath] != nil + } + + /// Unwraps and yields a mutable associated value to a closure. + /// + /// > Warning: If the enum's case does not match the given case key path, the mutation will not be + /// > applied, and a runtime warning will be logged. To suppress these warnings, limit calls to + /// > `modify` to instances in which you have already checked the enum case. For example: + /// > + /// > ```swift + /// > switch e { + /// > case .someCase: + /// > e.modify(\.someCase) { int in + /// > int += 1 + /// > } + /// > case .anotherCase: + /// > e.modify(\.anotherCase) { string in + /// > string.append("!") + /// > } + /// > } + /// > ``` + /// + /// - Parameters: + /// - keyPath: A case key path to an associated value. + /// - yield: A closure given mutable access to that associated value. + /// - fileID: The fileID where the modify occurs. + /// - filePath: The filePath where the modify occurs. + /// - line: The line where the modify occurs. + /// - column: The column where the modify occurs. + public mutating func modify( + _ keyPath: CaseKeyPath, + yield: (inout Value) -> Void, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + let `case` = Case(keyPath) + guard var value = `case`.extract(from: self) else { + reportIssue( + """ + Can't modify '\(String(describing: self))' via 'CaseKeyPath<\(Self.self), \(Value.self)>' \ + (aka '\(String(reflecting: keyPath))') + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + yield(&value) + self = `case`.embed(value) as! Self + } +} + +extension AnyCasePath { + /// Creates a type-erased case path for given case key path. + /// + /// - Parameter keyPath: A case key path. + public init(_ keyPath: CaseKeyPath) { + let `case` = Case(keyPath) + self.init( + embed: { `case`.embed($0) as! Root }, + extract: { `case`.extract(from: $0) } + ) + } +} + +extension AnyCasePath where Value: CasePathable { + // #if swift(>=6) + // /// Returns a new case path created by appending the case path at the given key path to this one. + // /// + // /// This subscript is automatically invoked by case key path expressions via dynamic member + // /// lookup, and should not be invoked directly. + // /// + // /// - Parameter keyPath: A key path to a case-pathable case path. + // public subscript( + // dynamicMember keyPath: KeyPath> + // & Sendable + // ) -> AnyCasePath { + // AnyCasePath( + // embed: { self.embed(Value.allCasePaths[keyPath: keyPath].embed($0)) }, + // extract: { + // self.extract(from: $0).flatMap(Value.allCasePaths[keyPath: keyPath].extract(from:)) + // } + // ) + // } + // #else + /// Returns a new case path created by appending the case path at the given key path to this one. + /// + /// This subscript is automatically invoked by case key path expressions via dynamic member + /// lookup, and should not be invoked directly. + /// + /// - Parameter keyPath: A key path to a case-pathable case path. + public subscript( + dynamicMember keyPath: KeyPath> + ) -> AnyCasePath { + @CasePathsUncheckedSendable var keyPath = keyPath + return AnyCasePath( + embed: { [$keyPath] in + embed(Value.allCasePaths[keyPath: $keyPath.wrappedValue].embed($0)) + }, + extract: { [$keyPath] in + extract(from: $0).flatMap( + Value.allCasePaths[keyPath: $keyPath.wrappedValue].extract(from:) + ) + } + ) + } + // #endif +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/EnumReflection.swift b/Sources/KlaviyoSDKDependencies/CasePaths/EnumReflection.swift new file mode 100644 index 00000000..1dbcb788 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/EnumReflection.swift @@ -0,0 +1,539 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +import Foundation + +extension AnyCasePath { + /// Returns a case path for the given embed function. + /// + /// This initializer generates a case path with an extract function that dynamically resolves + /// given an enum embed function. + /// + /// > Important: This operation is provided for backwards compatibility. Avoid introducing it to + /// > your code and instead favor using types that conform to ``CasePathable`` and + /// > ``CaseKeyPath``. + /// + /// - Parameter embed: An embed function. + @available(iOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @available(macOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @available(tvOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @available(watchOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + public init(unsafe embed: @escaping @Sendable (Value) -> Root) { + func open(_: Wrapped.Type) -> @Sendable (Root) -> Value? { + optionalPromotedExtractHelp(unsafeBitCast(embed, to: (@Sendable (Value) -> Wrapped?).self)) + as! @Sendable (Root) -> Value? + } + @CasePathsUncheckedSendable var embed = embed + let extract = + ((_Witness.self as? _AnyOptional.Type)?.wrappedType) + .map { _openExistential($0, do: open) } + ?? extractHelp { [$embed] in $embed.wrappedValue($0) } + self.init( + embed: { [$embed] in $embed.wrappedValue($0) }, + extract: extract + ) + } + + /// Returns a void case path for a case with no associated value. + /// + /// > Important: This operation is provided for backwards compatibility. Avoid introducing it to + /// > your code and instead favor using types that conform to ``CasePathable`` and + /// > ``CaseKeyPath``. + @available(iOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @available(macOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @available(tvOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @available(watchOS, deprecated: 9999, message: "Use a 'CasePathable' case key path, instead") + @_disfavoredOverload + public init(unsafe root: @autoclosure @escaping @Sendable () -> Root) where Value == Void { + func open(_: Wrapped.Type) -> @Sendable (Root) -> Void? { + optionalPromotedExtractVoidHelp( + unsafeBitCast(root, to: Wrapped?.self) + ) as! @Sendable (Root) -> Void? + } + let extract = + ((_Witness.self as? _AnyOptional.Type)?.wrappedType) + .map { _openExistential($0, do: open) } + ?? extractVoidHelp(root()) + self.init(embed: root, extract: extract) + } +} + +// MARK: - Extraction helpers + +private struct Cache: Sendable { + var tag: UInt32? + var strategy: (isIndirect: Bool, associatedValueType: Any.Type)? +} + +func extractHelp( + _ embed: @escaping @Sendable (Value) -> Root +) -> @Sendable (Root) -> Value? { + guard + let metadata = EnumMetadata(Root.self), + metadata.typeDescriptor.fieldDescriptor != nil + else { + assertionFailure("embed parameter must be a valid enum case initializer") + return { _ in nil } + } + + let cache = CasePathsLockIsolated(Cache()) + + return { root in + let rootTag = metadata.tag(of: root) + + if case let (cachedTag?, (isIndirect: isIndirect, associatedValueType: associatedValueType)?) = + cache.withLock({ + ($0.tag, $0.strategy) + }) + { + guard rootTag == cachedTag else { return nil } + let value = + EnumMetadata + ._project(root, isIndirect: isIndirect, associatedValueType: associatedValueType)? + .value as? Value + return value + } + + guard + let (value, isIndirect, type) = EnumMetadata._project(root), + let value = value as? Value + else { return nil } + + let embedTag = metadata.tag(of: embed(value)) + cache.withLock { + $0.tag = embedTag + if embedTag == rootTag { + $0.strategy = (isIndirect, type) + } + } + return embedTag == rootTag ? value : nil + } +} + +func optionalPromotedExtractHelp( + _ embed: @escaping @Sendable (Value) -> Root? +) -> @Sendable (Root?) -> Value? { + guard Root.self != Value.self else { return { $0 as! Value? } } + guard + let metadata = EnumMetadata(Root.self), + metadata.typeDescriptor.fieldDescriptor != nil + else { + assertionFailure("embed parameter must be a valid enum case initializer") + return { _ in nil } + } + + let cachedTag = CasePathsLockIsolated(nil) + + return { optionalRoot in + guard let root = optionalRoot else { return nil } + + let rootTag = metadata.tag(of: root) + + if let cachedTag = cachedTag.withLock({ $0 }) { + guard rootTag == cachedTag else { return nil } + } + + guard let value = EnumMetadata.project(root) as? Value + else { return nil } + + guard let embedded = embed(value) else { return nil } + let embedTag = metadata.tag(of: embedded) + cachedTag.withLock { $0 = embedTag } + return embedTag == rootTag ? value : nil + } +} + +func extractVoidHelp(_ root: Root) -> @Sendable (Root) -> Void? { + guard + let metadata = EnumMetadata(Root.self), + metadata.typeDescriptor.fieldDescriptor != nil + else { + assertionFailure("value must be a valid enum case") + return { _ in nil } + } + + let cachedTag = metadata.tag(of: root) + return { root in metadata.tag(of: root) == cachedTag ? () : nil } +} + +func optionalPromotedExtractVoidHelp(_ root: Root?) -> @Sendable (Root?) -> Void? { + guard + let root = root, + let metadata = EnumMetadata(Root.self), + metadata.typeDescriptor.fieldDescriptor != nil + else { + assertionFailure("value must be a valid enum case") + return { _ in nil } + } + + let cachedTag = metadata.tag(of: root) + return { root in root.flatMap(metadata.tag(of:)) == cachedTag ? () : nil } +} + +// MARK: - Runtime reflection + +private protocol Metadata { + var ptr: UnsafeRawPointer { get } +} + +extension Metadata { + var valueWitnessTable: ValueWitnessTable { + ValueWitnessTable( + ptr: self.ptr.load(fromByteOffset: -pointerSize, as: UnsafeRawPointer.self) + ) + } + + var kind: MetadataKind { self.ptr.load(as: MetadataKind.self) } +} + +private struct MetadataKind: Equatable { + var rawValue: UInt + + // https://github.com/apple/swift/blob/main/include/swift/ABI/MetadataValues.h + // https://github.com/apple/swift/blob/main/include/swift/ABI/MetadataKind.def + static var enumeration: Self { .init(rawValue: 0x201) } + static var optional: Self { .init(rawValue: 0x202) } + static var tuple: Self { .init(rawValue: 0x301) } + static var existential: Self { .init(rawValue: 0x303) } +} + +@_spi(Reflection) public struct EnumMetadata: Metadata, @unchecked Sendable { + let ptr: UnsafeRawPointer + + fileprivate init(assumingEnum type: Any.Type) { + self.ptr = unsafeBitCast(type, to: UnsafeRawPointer.self) + } + + @_spi(Reflection) public init?(_ type: Any.Type) { + self.init(assumingEnum: type) + guard self.kind == .enumeration || self.kind == .optional else { return nil } + } + + fileprivate var genericArguments: GenericArgumentVector? { + guard typeDescriptor.flags.contains(.isGeneric) else { return nil } + return .init(ptr: self.ptr.advanced(by: 2 * pointerSize)) + } + + @_spi(Reflection) public var typeDescriptor: EnumTypeDescriptor { + EnumTypeDescriptor( + ptr: self.ptr.load(fromByteOffset: pointerSize, as: UnsafeRawPointer.self) + ) + } + + @_spi(Reflection) public func tag(of value: Enum) -> UInt32 { + withUnsafePointer(to: value) { + self.valueWitnessTable.getEnumTag($0, self.ptr) + } + } +} + +extension EnumMetadata { + @_spi(Reflection) public func associatedValueType(forTag tag: UInt32) -> Any.Type { + guard + let typeName = self.typeDescriptor.fieldDescriptor?.field(atIndex: tag).typeName, + let type = swift_getTypeByMangledNameInContext( + typeName.ptr, typeName.length, + genericContext: self.typeDescriptor.ptr, + genericArguments: self.genericArguments?.ptr + ) + else { + return Void.self + } + + return type + } + + @_spi(Reflection) public func caseName(forTag tag: UInt32) -> String? { + self.typeDescriptor.fieldDescriptor?.field(atIndex: tag).name + } +} + +@_silgen_name("swift_getTypeByMangledNameInContext") +private func swift_getTypeByMangledNameInContext( + _ name: UnsafePointer, + _ nameLength: UInt, + genericContext: UnsafeRawPointer?, + genericArguments: UnsafeRawPointer? +) + -> Any.Type? + +extension EnumMetadata { + func destructivelyProjectPayload(of value: UnsafeMutableRawPointer) { + self.valueWitnessTable.destructiveProjectEnumData(value, ptr) + } + + func destructivelyInjectTag(_ tag: UInt32, intoPayload payload: UnsafeMutableRawPointer) { + self.valueWitnessTable.destructiveInjectEnumData(payload, tag, ptr) + } + + @_spi(Reflection) public static func project(_ root: Enum) -> Any? { + Self._project(root)?.value + } + + fileprivate static func _project( + _ root: Enum, + isIndirect: Bool? = nil, + associatedValueType: Any.Type? = nil + ) -> (value: Any, isIndirect: Bool, associatedValueType: Any.Type)? { + guard let metadata = Self(Enum.self) + else { return nil } + + let tag = metadata.tag(of: root) + guard + let isIndirect = isIndirect + ?? metadata + .typeDescriptor + .fieldDescriptor? + .field(atIndex: tag) + .flags + .contains(.isIndirectCase) + else { return nil } + + var root = root + return withUnsafeMutableBytes(of: &root) { rawBuffer in + guard let pointer = rawBuffer.baseAddress + else { return nil } + metadata.destructivelyProjectPayload(of: pointer) + defer { metadata.destructivelyInjectTag(tag, intoPayload: pointer) } + func open(_ type: T.Type) -> T { + isIndirect + ? pointer + .load(as: UnsafeRawPointer.self) // Load the heap object pointer. + .advanced(by: 2 * pointerSize) // Skip the heap object header. + .load(as: type) + : pointer.load(as: type) + } + let type: Any.Type + if let associatedValueType = associatedValueType { + type = associatedValueType + } else { + var associatedValueType = metadata.associatedValueType(forTag: tag) + if let tupleMetadata = TupleMetadata(associatedValueType), tupleMetadata.elementCount == 1 { + associatedValueType = tupleMetadata.element(at: 0).type + } + type = associatedValueType + } + let value: Any = _openExistential(type, do: open) + return (value: value, isIndirect: isIndirect, associatedValueType: type) + } + } +} + +@_spi(Reflection) public struct EnumTypeDescriptor: Equatable { + let ptr: UnsafeRawPointer + + var flags: Flags { Flags(rawValue: self.ptr.load(as: UInt32.self)) } + + fileprivate var fieldDescriptor: FieldDescriptor? { + self.ptr + .advanced(by: 4 * 4) + .loadRelativePointer() + .map(FieldDescriptor.init) + } + + var payloadCaseCount: UInt32 { self.ptr.load(fromByteOffset: 5 * 4, as: UInt32.self) & 0xFFFFFF } + + var emptyCaseCount: UInt32 { self.ptr.load(fromByteOffset: 6 * 4, as: UInt32.self) } +} + +extension EnumTypeDescriptor { + struct Flags: OptionSet { + let rawValue: UInt32 + + static var isGeneric: Self { .init(rawValue: 0x80) } + } +} + +private struct TupleMetadata: Metadata { + let ptr: UnsafeRawPointer + + init?(_ type: Any.Type) { + self.ptr = unsafeBitCast(type, to: UnsafeRawPointer.self) + guard self.kind == .tuple else { return nil } + } + + var elementCount: UInt { + self.ptr + .advanced(by: pointerSize) // kind + .load(as: UInt.self) + } + + var labels: UnsafePointer? { + self.ptr + .advanced(by: pointerSize) // kind + .advanced(by: pointerSize) // elementCount + .load(as: UnsafePointer?.self) + } + + func element(at i: Int) -> Element { + Element( + ptr: + self.ptr + .advanced(by: pointerSize) // kind + .advanced(by: pointerSize) // elementCount + .advanced(by: pointerSize) // labels pointer + .advanced(by: i * 2 * pointerSize) + ) + } +} + +extension TupleMetadata { + struct Element: Equatable { + let ptr: UnsafeRawPointer + + var type: Any.Type { self.ptr.load(as: Any.Type.self) } + + var offset: UInt32 { self.ptr.load(fromByteOffset: pointerSize, as: UInt32.self) } + + static func == (lhs: Element, rhs: Element) -> Bool { + lhs.type == rhs.type && lhs.offset == rhs.offset + } + } +} + +extension TupleMetadata { + func hasSameLayout(as other: TupleMetadata) -> Bool { + self.elementCount == other.elementCount + && (0.. FieldRecord { + FieldRecord( + ptr: self.ptr.advanced(by: 2 * 4 + 2 * 2 + 4).advanced(by: Int(i) * recordSize) + ) + } +} + +private struct FieldRecord { + let ptr: UnsafeRawPointer + + var flags: Flags { Flags(rawValue: self.ptr.load(as: UInt32.self)) } + + var typeName: MangledTypeName? { + self.ptr + .advanced(by: 4) + .loadRelativePointer() + .map { MangledTypeName(ptr: $0.assumingMemoryBound(to: UInt8.self)) } + } + + var name: String? { + self.ptr + .advanced(by: 4) + .advanced(by: 4) + .loadRelativePointer() + .map { String(cString: $0.assumingMemoryBound(to: CChar.self)) } + } +} + +extension FieldRecord { + struct Flags: OptionSet { + var rawValue: UInt32 + + static var isIndirectCase: Self { .init(rawValue: 1) } + } +} + +private struct MangledTypeName { + let ptr: UnsafePointer + + var length: UInt { + // https://github.com/apple/swift/blob/main/docs/ABI/Mangling.rst + var ptr = self.ptr + while true { + switch ptr.pointee { + case 0: + return UInt(bitPattern: ptr - self.ptr) + case 0x01...0x17: + // Relative symbolic reference + ptr = ptr.advanced(by: 5) + case 0x18...0x1f: + // Absolute symbolic reference + ptr = ptr.advanced(by: 1 + pointerSize) + default: + ptr = ptr.advanced(by: 1) + } + } + } +} + +private struct ValueWitnessTable { + let ptr: UnsafeRawPointer + + var getEnumTag: @convention(c) (_ value: UnsafeRawPointer, _ metadata: UnsafeRawPointer) -> UInt32 + { + self.ptr.advanced(by: 10 * pointerSize + 2 * 4).loadInferredType() + } + + // This witness transforms an enum value into its associated value, in place. + var destructiveProjectEnumData: + @convention(c) (_ value: UnsafeMutableRawPointer, _ metadata: UnsafeRawPointer) -> Void + { + self.ptr.advanced(by: 11 * pointerSize + 2 * 4).loadInferredType() + } + + // This witness transforms an associated value into its enum value, in place. + var destructiveInjectEnumData: + @convention(c) (_ value: UnsafeMutableRawPointer, _ tag: UInt32, _ metadata: UnsafeRawPointer) + -> Void + { + self.ptr.advanced(by: 12 * pointerSize + 2 * 4).loadInferredType() + } +} + +private struct GenericArgumentVector { + let ptr: UnsafeRawPointer +} + +extension GenericArgumentVector { + func type(atIndex i: Int) -> Any.Type { + return ptr.load(fromByteOffset: i * pointerSize, as: Any.Type.self) + } +} + +extension UnsafeRawPointer { + fileprivate func loadInferredType() -> Type { + self.load(as: Type.self) + } + + fileprivate func loadRelativePointer() -> UnsafeRawPointer? { + let offset = Int(load(as: Int32.self)) + return offset == 0 ? nil : self + offset + } +} + +// This is the size of any Unsafe*Pointer and also the size of Int and UInt. +private let pointerSize = MemoryLayout.size + +private protocol _Optional { + associatedtype Wrapped +} +extension Optional: _Optional {} +private enum _Witness {} +private protocol _AnyOptional { + static var wrappedType: Any.Type { get } +} +extension _Witness: _AnyOptional where A: _Optional { + static var wrappedType: Any.Type { + A.Wrapped.self + } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsLockIsolated.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsLockIsolated.swift new file mode 100644 index 00000000..83b1a25b --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsLockIsolated.swift @@ -0,0 +1,22 @@ +/// Adapted from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 +/// Comments - renamed to avoid collision with other packages. + +import Foundation + +final class CasePathsLockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + func withLock( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + lock.lock() + defer { lock.unlock() } + var value = _value + defer { _value = value } + return try operation(&value) + } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsUncheckedSendable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsUncheckedSendable.swift new file mode 100644 index 00000000..a37d8f63 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/CasePathsUncheckedSendable.swift @@ -0,0 +1,12 @@ +/// Adapted from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 +/// Comments - renamed to avoid collision with other packages. + +@propertyWrapper +struct CasePathsUncheckedSendable: @unchecked Sendable { + var wrappedValue: Value + init(wrappedValue value: Value) { + self.wrappedValue = value + } + var projectedValue: Self { self } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Internal/OpenExistential.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/OpenExistential.swift new file mode 100644 index 00000000..6f467bff --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/OpenExistential.swift @@ -0,0 +1,12 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool? { + (lhs as? any Equatable)?.isEqual(other: rhs) +} + +extension Equatable { + fileprivate func isEqual(other: Any) -> Bool { + self == other as? Self + } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Internal/TypeName.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/TypeName.swift new file mode 100644 index 00000000..ababd74b --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Internal/TypeName.swift @@ -0,0 +1,50 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +// NB: This is adapted from Custom Dump and should ideally be kept in sync. +func typeName( + _ type: Any.Type, + qualified: Bool = true, + genericsAbbreviated: Bool = false // NB: This defaults to `true` in Custom Dump +) -> String { + var name = _typeName(type, qualified: qualified) + .replacingOccurrences( + of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, + with: "", + options: .regularExpression + ) + for _ in 1...10 { // NB: Only handle so much nesting + let abbreviated = + name + .replacingOccurrences( + of: #"\bSwift.Optional<([^><]+)>"#, + with: "$1?", + options: .regularExpression + ) + .replacingOccurrences( + of: #"\bSwift.Array<([^><]+)>"#, + with: "[$1]", + options: .regularExpression + ) + .replacingOccurrences( + of: #"\bSwift.Dictionary<([^,<]+), ([^><]+)>"#, + with: "[$1: $2]", + options: .regularExpression + ) + if abbreviated == name { break } + name = abbreviated + } + name = name.replacingOccurrences( + of: #"\w+\.([\w.]+)"#, + with: "$1", + options: .regularExpression + ) + if genericsAbbreviated { + name = name.replacingOccurrences( + of: #"<.+>"#, + with: "", + options: .regularExpression + ) + } + return name +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Never+CasePathable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Never+CasePathable.swift new file mode 100644 index 00000000..c161ddf0 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Never+CasePathable.swift @@ -0,0 +1,39 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +extension Never: CasePathable, CasePathIterable { + public struct AllCasePaths: CasePathReflectable, Sendable { + public subscript(root: Never) -> PartialCaseKeyPath { + \.never + } + } + + public static var allCasePaths: AllCasePaths { + AllCasePaths() + } +} + +extension Case where Value: CasePathable { + /// A case path that can never embed or extract a value. + /// + /// This property can chain any case path into a `Never` value, which, as an uninhabited type, + /// cannot be embedded nor extracted from an enum. + public var never: Case { + @Sendable func absurd(_: Never) -> T {} + return Case(embed: absurd, extract: { (_: Value) in nil }) + } +} + +extension Case { + @available(*, deprecated, message: "This enum must be '@CasePathable' to enable key path syntax") + public var never: Case { + @Sendable func absurd(_: Never) -> T {} + return Case(embed: absurd, extract: { (_: Value) in nil }) + } +} + +extension Never.AllCasePaths: Sequence { + public func makeIterator() -> some IteratorProtocol> { + [].makeIterator() + } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Optional+CasePathable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Optional+CasePathable.swift new file mode 100644 index 00000000..72af80ea --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Optional+CasePathable.swift @@ -0,0 +1,91 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +extension Optional: CasePathable, CasePathIterable { + @dynamicMemberLookup + public struct AllCasePaths: CasePathReflectable, Sendable { + public subscript(root: Optional) -> PartialCaseKeyPath { + switch root { + case .none: return \.none + case .some: return \.some + } + } + + /// A case path to the absence of a value. + public var none: AnyCasePath { + AnyCasePath( + embed: { .none }, + extract: { + guard case .none = $0 else { return nil } + return () + } + ) + } + + /// A case path to the presence of a value. + public var some: AnyCasePath { + AnyCasePath( + embed: { .some($0) }, + extract: { + guard case let .some(value) = $0 else { return nil } + return value + } + ) + } + + /// A case path to an optional-chained value. + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath> + ) -> AnyCasePath + where Wrapped: CasePathable { + let casePath = Wrapped.allCasePaths[keyPath: keyPath] + return AnyCasePath( + embed: { $0.map(casePath.embed) }, + extract: { + guard case let .some(wrapped) = $0, let member = casePath.extract(from: wrapped) + else { return .none } + return member + } + ) + } + } + + public static var allCasePaths: AllCasePaths { + AllCasePaths() + } +} + +extension Case { + // #if swift(>=6) + // /// A case path to the presence of a nested value. + // /// + // /// This subscript can chain into an optional's wrapped value without explicitly specifying each + // /// `some` component. + // @_disfavoredOverload + // public subscript( + // dynamicMember keyPath: KeyPath> & Sendable + // ) -> Case + // where Value: CasePathable { + // self[dynamicMember: keyPath].some + // } + // #else + /// A case path to the presence of a nested value. + /// + /// This subscript can chain into an optional's wrapped value without explicitly specifying each + /// `some` component. + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath> + ) -> Case + where Value: CasePathable { + self[dynamicMember: keyPath].some + } + // #endif +} + +extension Optional.AllCasePaths: Sequence { + public func makeIterator() -> some IteratorProtocol> { + [\.none, \.some].makeIterator() + } +} diff --git a/Sources/KlaviyoSDKDependencies/CasePaths/Result+CasePathable.swift b/Sources/KlaviyoSDKDependencies/CasePaths/Result+CasePathable.swift new file mode 100644 index 00000000..8b21a090 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CasePaths/Result+CasePathable.swift @@ -0,0 +1,45 @@ +/// Copied verbatim from swift-case-paths v1.5.4 on 11/15/2024 +/// https://github.com/pointfreeco/swift-case-paths/tree/1.5.4 + +extension Result: CasePathable, CasePathIterable { + public struct AllCasePaths: CasePathReflectable, Sendable { + public subscript(root: Result) -> PartialCaseKeyPath { + switch root { + case .success: return \.success + case .failure: return \.failure + } + } + + /// A success case path, for embedding or extracting a `Success` value. + public var success: AnyCasePath { + AnyCasePath( + embed: { .success($0) }, + extract: { + guard case let .success(value) = $0 else { return nil } + return value + } + ) + } + + /// A failure case path, for embedding or extracting a `Failure` value. + public var failure: AnyCasePath { + AnyCasePath( + embed: { .failure($0) }, + extract: { + guard case let .failure(value) = $0 else { return nil } + return value + } + ) + } + } + + public static var allCasePaths: AllCasePaths { + AllCasePaths() + } +} + +extension Result.AllCasePaths: Sequence { + public func makeIterator() -> some IteratorProtocol> { + [\.success, \.failure].makeIterator() + } +} diff --git a/Sources/KlaviyoSDKDependencies/CombineSchedulers/UIScheduler.swift b/Sources/KlaviyoSDKDependencies/CombineSchedulers/UIScheduler.swift new file mode 100644 index 00000000..97b603c4 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CombineSchedulers/UIScheduler.swift @@ -0,0 +1,74 @@ +/// Copied verbatim from Combine Schedulers v1.0.2 on 11/15/2024 +/// https://github.com/pointfreeco/combine-schedulers/tree/1.0.2 + +#if canImport(Combine) + import Combine + + #if swift(>=6) + @preconcurrency import Dispatch + #else + import Dispatch + #endif + + /// A scheduler that executes its work on the main queue as soon as possible. + /// + /// This scheduler is inspired by the + /// [equivalent](https://github.com/ReactiveCocoa/ReactiveSwift/blob/58d92aa01081301549c48a4049e215210f650d07/Sources/Scheduler.swift#L92) + /// scheduler in the [ReactiveSwift](https://github.com/ReactiveCocoa/ReactiveSwift) project. + /// + /// If `UIScheduler.shared.schedule` is invoked from the main thread then the unit of work will be + /// performed immediately. This is in contrast to `DispatchQueue.main.schedule`, which will incur + /// a thread hop before executing since it uses `DispatchQueue.main.async` under the hood. + /// + /// This scheduler can be useful for situations where you need work executed as quickly as + /// possible on the main thread, and for which a thread hop would be problematic, such as when + /// performing animations. + public struct UIScheduler: Scheduler, Sendable { + public typealias SchedulerOptions = Never + public typealias SchedulerTimeType = DispatchQueue.SchedulerTimeType + + /// The shared instance of the UI scheduler. + /// + /// You cannot create instances of the UI scheduler yourself. Use only the shared instance. + public static let shared = Self() + + public var now: SchedulerTimeType { DispatchQueue.main.now } + public var minimumTolerance: SchedulerTimeType.Stride { DispatchQueue.main.minimumTolerance } + + public func schedule(options: SchedulerOptions? = nil, _ action: @escaping () -> Void) { + if DispatchQueue.getSpecific(key: key) == value { + action() + } else { + DispatchQueue.main.schedule(action) + } + } + + public func schedule( + after date: SchedulerTimeType, + tolerance: SchedulerTimeType.Stride, + options: SchedulerOptions? = nil, + _ action: @escaping () -> Void + ) { + DispatchQueue.main.schedule(after: date, tolerance: tolerance, options: nil, action) + } + + public func schedule( + after date: SchedulerTimeType, + interval: SchedulerTimeType.Stride, + tolerance: SchedulerTimeType.Stride, + options: SchedulerOptions? = nil, + _ action: @escaping () -> Void + ) -> Cancellable { + DispatchQueue.main.schedule( + after: date, interval: interval, tolerance: tolerance, options: nil, action + ) + } + + private init() { + DispatchQueue.main.setSpecific(key: key, value: value) + } + } + + private let key = DispatchSpecificKey() + private let value: UInt8 = 0 +#endif diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effect.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effect.swift new file mode 100644 index 00000000..aaa592cb --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effect.swift @@ -0,0 +1,357 @@ +/// Adapted from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: Modified to remove depdendencies, swiftui and animation. + +@preconcurrency import Combine +import Foundation + +public struct Effect: Sendable { + @usableFromInline + enum Operation: Sendable { + case none + case publisher(AnyPublisher) + case run(TaskPriority? = nil, @Sendable (_ send: Send) async -> Void) + } + + @usableFromInline + let operation: Operation + + @usableFromInline + init(operation: Operation) { + self.operation = operation + } +} + +/// A convenience type alias for referring to an effect of a given reducer's domain. +/// +/// Instead of specifying the action: +/// +/// ```swift +/// let effect: Effect +/// ``` +/// +/// You can specify the reducer: +/// +/// ```swift +/// let effect: EffectOf +/// ``` +public typealias EffectOf = Effect + +// MARK: - Creating Effects + +extension Effect { + /// An effect that does nothing and completes immediately. Useful for situations where you must + /// return an effect, but you don't need to do anything. + @inlinable + public static var none: Self { + Self(operation: .none) + } + + /// Wraps an asynchronous unit of work that can emit actions any number of times in an effect. + /// + /// For example, if you had an async sequence in a dependency client: + /// + /// ```swift + /// struct EventsClient { + /// var events: () -> any AsyncSequence + /// } + /// ``` + /// + /// Then you could attach to it in a `run` effect by using `for await` and sending each action of + /// the stream back into the system: + /// + /// ```swift + /// case .startButtonTapped: + /// return .run { send in + /// for await event in self.events() { + /// send(.event(event)) + /// } + /// } + /// ``` + /// + /// See ``Send`` for more information on how to use the `send` argument passed to `run`'s closure. + /// + /// The closure provided to ``run(priority:operation:catch:fileID:filePath:line:column:)`` is + /// allowed to throw, but any non-cancellation errors thrown will cause a runtime warning when run + /// in the simulator or on a device, and will cause a test failure in tests. To catch + /// non-cancellation errors use the `catch` trailing closure. + /// + /// - Parameters: + /// - priority: Priority of the underlying task. If `nil`, the priority will come from + /// `Task.currentPriority`. + /// - operation: The operation to execute. + /// - catch: An error handler, invoked if the operation throws an error other than + /// `CancellationError`. + /// - Returns: An effect wrapping the given asynchronous work. + public static func run( + priority: TaskPriority? = nil, + operation: @escaping @Sendable (_ send: Send) async throws -> Void, + catch handler: (@Sendable (_ error: any Error, _ send: Send) async -> Void)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Self { + + Self( + operation: .run(priority) { send in + do { + try await operation(send) + } catch is CancellationError { + return + } catch { + guard let handler else { + reportIssue( + """ + An "Effect.run" returned from "\(fileID):\(line)" threw an unhandled error. … + + \(String(customDumping: error).indent(by: 4)) + + All non-cancellation errors must be explicitly handled via the "catch" parameter \ + on "Effect.run", or via a "do" block. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + await handler(error, send) + } + } + ) + } + + /// Initializes an effect that immediately emits the action passed in. + /// + /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to + /// > child-parent communication, where a child may want to emit a "delegate" action for a parent + /// > to listen to. + /// > + /// > For more information, see . + /// + /// - Parameter action: The action that is immediately emitted by the effect. + public static func send(_ action: Action) -> Self { + Self(operation: .publisher(Just(action).eraseToAnyPublisher())) + } + + /// Initializes an effect that immediately emits the action passed in. + /// + /// > Note: We do not recommend using `Effect.send` to share logic. Instead, limit usage to + /// > child-parent communication, where a child may want to emit a "delegate" action for a parent + /// > to listen to. + /// > + /// > For more information, see . + /// + /// - Parameters: + /// - action: The action that is immediately emitted by the effect. + /// - animation: An animation. +// public static func send(_ action: Action, animation: Animation? = nil) -> Self { +// .send(action).animation(animation) +// } +} + +/// A type that can send actions back into the system when used from +/// ``Effect/run(priority:operation:catch:fileID:filePath:line:column:)``. +/// +/// This type implements [`callAsFunction`][callAsFunction] so that you invoke it as a function +/// rather than calling methods on it: +/// +/// ```swift +/// return .run { send in +/// await send(.started) +/// for await event in self.events { +/// send(.event(event)) +/// } +/// await send(.finished) +/// } +/// ``` +/// +/// You can also send actions with animation and transaction: +/// +/// ```swift +/// await send(.started, animation: .spring()) +/// await send(.finished, transaction: .init(animation: .default)) +/// ``` +/// +/// See ``Effect/run(priority:operation:catch:fileID:filePath:line:column:)`` for more information on how to +/// use this value to construct effects that can emit any number of times in an asynchronous +/// context. +/// +/// [callAsFunction]: https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622 +@MainActor +public struct Send: Sendable { + let send: @MainActor @Sendable (Action) -> Void + + public init(send: @escaping @MainActor @Sendable (Action) -> Void) { + self.send = send + } + + /// Sends an action back into the system from an effect. + /// + /// - Parameter action: An action. + public func callAsFunction(_ action: Action) { + guard !Task.isCancelled else { return } + self.send(action) + } + +} + +// MARK: - Composing Effects + +extension Effect { + /// Merges a variadic list of effects together into a single effect, which runs the effects at the + /// same time. + /// + /// - Parameter effects: A variadic list of effects. + /// - Returns: A new effect + @inlinable + public static func merge(_ effects: Self...) -> Self { + Self.merge(effects) + } + + /// Merges a sequence of effects together into a single effect, which runs the effects at the same + /// time. + /// + /// - Parameter effects: A sequence of effects. + /// - Returns: A new effect + @inlinable + public static func merge(_ effects: some Sequence) -> Self { + effects.reduce(.none) { $0.merge(with: $1) } + } + + /// Merges this effect and another into a single effect that runs both at the same time. + /// + /// - Parameter other: Another effect. + /// - Returns: An effect that runs this effect and the other at the same time. + @inlinable + public func merge(with other: Self) -> Self { + switch (self.operation, other.operation) { + case (_, .none): + return self + case (.none, _): + return other + case (.publisher, .publisher), (.run, .publisher), (.publisher, .run): + return Self( + operation: .publisher( + Publishers.Merge( + _EffectPublisher(self), + _EffectPublisher(other) + ) + .eraseToAnyPublisher() + ) + ) + case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): + return Self( + operation: .run { send in + await withTaskGroup(of: Void.self) { group in + group.addTask(priority: lhsPriority) { + await lhsOperation(send) + } + group.addTask(priority: rhsPriority) { + await rhsOperation(send) + } + } + } + ) + } + } + + /// Concatenates a variadic list of effects together into a single effect, which runs the effects + /// one after the other. + /// + /// - Parameter effects: A variadic list of effects. + /// - Returns: A new effect + @inlinable + public static func concatenate(_ effects: Self...) -> Self { + Self.concatenate(effects) + } + + /// Concatenates a collection of effects together into a single effect, which runs the effects one + /// after the other. + /// + /// - Parameter effects: A collection of effects. + /// - Returns: A new effect + @inlinable + public static func concatenate(_ effects: some Collection) -> Self { + effects.reduce(.none) { $0.concatenate(with: $1) } + } + + /// Concatenates this effect and another into a single effect that first runs this effect, and + /// after it completes or is cancelled, runs the other. + /// + /// - Parameter other: Another effect. + /// - Returns: An effect that runs this effect, and after it completes or is cancelled, runs the + /// other. + @inlinable + @_disfavoredOverload + public func concatenate(with other: Self) -> Self { + switch (self.operation, other.operation) { + case (_, .none): + return self + case (.none, _): + return other + case (.publisher, .publisher), (.run, .publisher), (.publisher, .run): + return Self( + operation: .publisher( + Publishers.Concatenate( + prefix: _EffectPublisher(self), + suffix: _EffectPublisher(other) + ) + .eraseToAnyPublisher() + ) + ) + case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): + return Self( + operation: .run { send in + if let lhsPriority { + await Task(priority: lhsPriority) { await lhsOperation(send) }.cancellableValue + } else { + await lhsOperation(send) + } + if let rhsPriority { + await Task(priority: rhsPriority) { await rhsOperation(send) }.cancellableValue + } else { + await rhsOperation(send) + } + } + ) + } + } + + /// Transforms all elements from the upstream effect with a provided closure. + /// + /// - Parameter transform: A closure that transforms the upstream effect's action to a new action. + /// - Returns: A publisher that uses the provided closure to map elements from the upstream effect + /// to new elements that it then publishes. + @inlinable + public func map(_ transform: @escaping @Sendable (Action) -> T) -> Effect { + switch self.operation { + case .none: + return .none + case let .publisher(publisher): + return .init( + operation: .publisher( + publisher + .map({ action in + transform(action) + } + ) + .eraseToAnyPublisher() + ) + ) + case let .run(priority, operation): + return + .init( + operation: .run(priority) { send in + await operation( + Send { action in + send(transform(action)) + } + ) + } + ) + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Cancellation.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Cancellation.swift new file mode 100644 index 00000000..f63b8195 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Cancellation.swift @@ -0,0 +1,318 @@ +/// Adapted from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: +/// remove dependencies and avoid redefinition of issue reporting stuff. Also remove NavigationPathID (since we don't use that). + +@preconcurrency import Combine +import Foundation + +extension Effect { + /// Turns an effect into one that is capable of being canceled. + /// + /// To turn an effect into a cancellable one you must provide an identifier, which is used in + /// ``Effect/cancel(id:)`` to identify which in-flight effect should be canceled. + /// Any hashable value can be used for the identifier, such as a string, but you can add a bit of + /// protection against typos by defining a new type for the identifier: + /// + /// ```swift + /// enum CancelID { case loadUser } + /// + /// case .reloadButtonTapped: + /// // Start a new effect to load the user + /// return .run { send in + /// await send( + /// .userResponse( + /// TaskResult { try await self.apiClient.loadUser() } + /// ) + /// ) + /// } + /// .cancellable(id: CancelID.loadUser, cancelInFlight: true) + /// + /// case .cancelButtonTapped: + /// // Cancel any in-flight requests to load the user + /// return .cancel(id: CancelID.loadUser) + /// ``` + /// + /// - Parameters: + /// - id: The effect's identifier. + /// - cancelInFlight: Determines if any in-flight effect with the same identifier should be + /// canceled before starting this new one. + /// - Returns: A new effect that is capable of being canceled by an identifier. + public func cancellable(id: some Hashable & Sendable, cancelInFlight: Bool = false) -> Self { + + switch self.operation { + case .none: + return .none + case let .publisher(publisher): + return Self( + operation: .publisher( + Deferred { + () + -> Publishers.HandleEvents< + Publishers.PrefixUntilOutput< + AnyPublisher, PassthroughSubject + > + > in + _cancellationCancellables.withValue { + if cancelInFlight { + $0.cancel(id: id) + } + + let cancellationSubject = PassthroughSubject() + + let cancellable = LockIsolated(nil) + cancellable.setValue( + AnyCancellable { + _cancellationCancellables.withValue { + cancellationSubject.send(()) + cancellationSubject.send(completion: .finished) + $0.remove(cancellable.value!, at: id) + } + } + ) + + return publisher.prefix(untilOutputFrom: cancellationSubject) + .handleEvents( + receiveSubscription: { _ in + _cancellationCancellables.withValue { + $0.insert(cancellable.value!, at: id) + } + }, + receiveCompletion: { _ in cancellable.value!.cancel() }, + receiveCancel: cancellable.value!.cancel + ) + } + } + .eraseToAnyPublisher() + ) + ) + case let .run(priority, operation): + return Self( + operation: .run(priority) { send in + await withTaskCancellation(id: id, cancelInFlight: cancelInFlight) { + await operation(send) + } + } + ) + + } + } + + /// An effect that will cancel any currently in-flight effect with the given identifier. + /// + /// - Parameter id: An effect identifier. + /// - Returns: A new effect that will cancel any currently in-flight effect with the given + /// identifier. + public static func cancel(id: some Hashable & Sendable) -> Self { + // NB: Ideally we'd return a `Deferred` wrapping an `Empty(completeImmediately: true)`, but + // due to a bug in iOS 13.2 that publisher will never complete. The bug was fixed in + // iOS 13.3, but to remain compatible with iOS 13.2 and higher we need to do a little + // trickery to make sure the deferred publisher completes. + return .publisher { () -> Publishers.CompactMap, Action> in + _cancellationCancellables.withValue { + $0.cancel(id: id) + } + return Just(nil).compactMap { $0 } + } + } +} + +#if compiler(>=6) + /// Execute an operation with a cancellation identifier. + /// + /// If the operation is in-flight when `Task.cancel(id:)` is called with the same identifier, the + /// operation will be cancelled. + /// + /// ```swift + /// enum CancelID { case timer } + /// + /// await withTaskCancellation(id: CancelID.timer) { + /// // Start cancellable timer... + /// } + /// ``` + /// + /// ### Debouncing tasks + /// + /// When paired with a clock, this function can be used to debounce a unit of async work by + /// specifying the `cancelInFlight`, which will automatically cancel any in-flight work with the + /// same identifier: + /// + /// ```swift + /// @Dependency(\.continuousClock) var clock + /// enum CancelID { case response } + /// + /// // ... + /// + /// return .run { send in + /// try await withTaskCancellation(id: CancelID.response, cancelInFlight: true) { + /// try await self.clock.sleep(for: .seconds(0.3)) + /// await send( + /// .debouncedResponse(TaskResult { try await environment.request() }) + /// ) + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - id: A unique identifier for the operation. + /// - cancelInFlight: Determines if any in-flight operation with the same identifier should be + /// canceled before starting this new one. + /// - isolation: The isolation of the operation. + /// - operation: An async operation. + /// - Throws: An error thrown by the operation. + /// - Returns: A value produced by operation. + public func withTaskCancellation( + id: some Hashable & Sendable, + cancelInFlight: Bool = false, + isolation: isolated (any Actor)? = #isolation, + operation: @escaping @Sendable () async throws -> T + ) async rethrows -> T { + + let (cancellable, task): (AnyCancellable, Task) = + _cancellationCancellables + .withValue { + if cancelInFlight { + $0.cancel(id: id) + } + let task = Task { try await operation() } + let cancellable = AnyCancellable { task.cancel() } + $0.insert(cancellable, at: id) + return (cancellable, task) + } + defer { + _cancellationCancellables.withValue { + $0.remove(cancellable, at: id) + } + } + do { + return try await task.cancellableValue + } catch { + return try Result.failure(error)._rethrowGet() + } + } +#else + @_unsafeInheritExecutor + public func withTaskCancellation( + id: some Hashable, + cancelInFlight: Bool = false, + operation: @Sendable @escaping () async throws -> T + ) async rethrows -> T { + let (cancellable, task): (AnyCancellable, Task) = + _cancellationCancellables + .withValue { + if cancelInFlight { + $0.cancel(id: id) + } + let task = Task { try await operation() } + let cancellable = AnyCancellable { task.cancel() } + $0.insert(cancellable, at: id) + return (cancellable, task) + } + defer { + _cancellationCancellables.withValue { + $0.remove(cancellable, at: id) + } + } + do { + return try await task.cancellableValue + } catch { + return try Result.failure(error)._rethrowGet() + } + } +#endif + +extension Task { + /// Cancel any currently in-flight operation with the given identifier. + /// + /// - Parameter id: An identifier. + public static func cancel(id: some Hashable & Sendable) { + + return _cancellationCancellables.withValue { + $0.cancel(id: id) + } + } +} + +@_spi(Internals) public struct _CancelID: Hashable { + let discriminator: ObjectIdentifier + let id: AnyHashable + let testIdentifier: TestContext.Testing.Test.ID? + + init(id: some Hashable) { + self.discriminator = ObjectIdentifier(type(of: id)) + self.id = id + switch TestContext.current { + case let .swiftTesting(.some(testing)): + self.testIdentifier = testing.test.id + default: + self.testIdentifier = nil + } + } +} + +@_spi(Internals) public let _cancellationCancellables = LockIsolated(CancellablesCollection()) + +//@rethrows +//private protocol _ErrorMechanism { +// associatedtype Output +// func get() throws -> Output +//} + +//extension _ErrorMechanism { +// func _rethrowError() rethrows -> Never { +// _ = try _rethrowGet() +// fatalError() +// } +// +// func _rethrowGet() rethrows -> Output { +// return try get() +// } +//} +// +//extension Result: _ErrorMechanism {} + +@_spi(Internals) +public class CancellablesCollection { + var storage: [_CancelID: Set] = [:] + + func insert( + _ cancellable: AnyCancellable, + at id: some Hashable + ) { + let cancelID = _CancelID(id: id) + self.storage[cancelID, default: []].insert(cancellable) + } + + func remove( + _ cancellable: AnyCancellable, + at id: some Hashable + ) { + let cancelID = _CancelID(id: id) + self.storage[cancelID]?.remove(cancellable) + if self.storage[cancelID]?.isEmpty == true { + self.storage[cancelID] = nil + } + } + + func cancel( + id: some Hashable + ) { + let cancelID = _CancelID(id: id) + self.storage[cancelID]?.forEach { $0.cancel() } + self.storage[cancelID] = nil + } + + func exists( + at id: some Hashable + ) -> Bool { + self.storage[_CancelID(id: id)] != nil + } + + public var count: Int { + self.storage.count + } + + public func removeAll() { + self.storage.removeAll() + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Publisher.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Publisher.swift new file mode 100644 index 00000000..3ba7965c --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Effects/Publisher.swift @@ -0,0 +1,49 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + + +import Combine + +extension Effect { + /// Creates an effect from a Combine publisher. + /// + /// - Parameter createPublisher: The closure to execute when the effect is performed. + /// - Returns: An effect wrapping a Combine publisher. + public static func publisher(_ createPublisher: () -> some Publisher) -> Self { + Self(operation: .publisher(createPublisher().eraseToAnyPublisher())) + } +} + +public struct _EffectPublisher: Publisher { + public typealias Output = Action + public typealias Failure = Never + + let effect: Effect + + public init(_ effect: Effect) { + self.effect = effect + } + + public func receive(subscriber: some Combine.Subscriber) { + publisher.subscribe(subscriber) + } + + private var publisher: AnyPublisher { + switch effect.operation { + case .none: + return Empty().eraseToAnyPublisher() + case let .publisher(publisher): + return publisher + case let .run(priority, operation): + return .create { subscriber in + let task = Task(priority: priority) { @MainActor in + defer { subscriber.send(completion: .finished) } + await operation(Send { subscriber.send($0) }) + } + return AnyCancellable { + task.cancel() + } + } + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/AssumeIsolated.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/AssumeIsolated.swift new file mode 100644 index 00000000..b63f6ab8 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/AssumeIsolated.swift @@ -0,0 +1,33 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +import Foundation + +extension MainActor { + // NB: This functionality was not back-deployed in Swift 5.9 + static func _assumeIsolated( + _ operation: @MainActor () throws -> T, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows -> T { + #if swift(<5.10) + typealias YesActor = @MainActor () throws -> T + typealias NoActor = () throws -> T + + guard Thread.isMainThread else { + fatalError( + "Incorrect actor executor assumption; Expected same executor as \(self).", + file: file, + line: line + ) + } + + return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + #else + return try assumeIsolated(operation, file: file, line: line) + #endif + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Box.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Box.swift new file mode 100644 index 00000000..063927e8 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Box.swift @@ -0,0 +1,15 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +final class Box { + var wrappedValue: Wrapped + + init(wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue + } + + var boxedValue: Wrapped { + _read { yield self.wrappedValue } + _modify { yield &self.wrappedValue } + } +} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Create.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Create.swift similarity index 71% rename from Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Create.swift rename to Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Create.swift index 006dc4b7..5b07ab92 100644 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Create.swift +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Create.swift @@ -1,11 +1,5 @@ -// -// Create.swift -// -// -// Created by Noah Durell on 12/7/22. -// - -import Foundation +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 // https://github.com/CombineCommunity/CombineExt/blob/master/Sources/Operators/Create.swift @@ -29,28 +23,24 @@ import Foundation // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Combine -import Darwin +@preconcurrency import Combine +import Foundation final class DemandBuffer: @unchecked Sendable { private var buffer = [S.Input]() private let subscriber: S private var completion: Subscribers.Completion? private var demandState = Demand() - private let lock: os_unfair_lock_t + private let lock = NSRecursiveLock() init(subscriber: S) { self.subscriber = subscriber - self.lock = os_unfair_lock_t.allocate(capacity: 1) - self.lock.initialize(to: os_unfair_lock()) - } - - deinit { - self.lock.deinitialize(count: 1) - self.lock.deallocate() } func buffer(value: S.Input) -> Subscribers.Demand { + lock.lock() + defer { lock.unlock() } + precondition( self.completion == nil, "How could a completed publisher sent values?! Beats me 🤷‍♂️") @@ -64,6 +54,9 @@ final class DemandBuffer: @unchecked Sendable { } func complete(completion: Subscribers.Completion) { + lock.lock() + defer { lock.unlock() } + precondition( self.completion == nil, "Completion have already occurred, which is quite awkward 🥺") @@ -112,25 +105,27 @@ final class DemandBuffer: @unchecked Sendable { } } -extension AnyPublisher { +extension AnyPublisher where Failure == Never { private init( - _ callback: @escaping (EffectPublisher.Subscriber) -> Cancellable + _ callback: @escaping @Sendable (Effect.Subscriber) -> any Cancellable ) { self = Publishers.Create(callback: callback).eraseToAnyPublisher() } static func create( - _ factory: @escaping (EffectPublisher.Subscriber) -> Cancellable + _ factory: @escaping @Sendable (Effect.Subscriber) -> any Cancellable ) -> AnyPublisher { AnyPublisher(factory) } } extension Publishers { - fileprivate class Create: Publisher { - private let callback: (EffectPublisher.Subscriber) -> Cancellable + fileprivate final class Create: Publisher, Sendable { + typealias Failure = Never + + private let callback: @Sendable (Effect.Subscriber) -> any Cancellable - init(callback: @escaping (EffectPublisher.Subscriber) -> Cancellable) { + init(callback: @escaping @Sendable (Effect.Subscriber) -> any Cancellable) { self.callback = callback } @@ -141,25 +136,25 @@ extension Publishers { } extension Publishers.Create { - fileprivate final class Subscription: Combine.Subscription - where Downstream.Input == Output, Downstream.Failure == Failure { + fileprivate final class Subscription: Combine.Subscription, Sendable + where Downstream.Input == Output, Downstream.Failure == Never { private let buffer: DemandBuffer - private var cancellable: Cancellable? + private let cancellable = LockIsolated<(any Cancellable)?>(nil) init( - callback: @escaping (EffectPublisher.Subscriber) -> Cancellable, + callback: @escaping @Sendable (Effect.Subscriber) -> any Cancellable, downstream: Downstream ) { self.buffer = DemandBuffer(subscriber: downstream) - let cancellable = callback( - .init( - send: { [weak self] in _ = self?.buffer.buffer(value: $0) }, - complete: { [weak self] in self?.buffer.complete(completion: $0) } + self.cancellable.setValue( + callback( + .init( + send: { [weak self] in _ = self?.buffer.buffer(value: $0) }, + complete: { [weak self] in self?.buffer.complete(completion: $0) } + ) ) ) - - self.cancellable = cancellable } func request(_ demand: Subscribers.Demand) { @@ -167,35 +162,35 @@ extension Publishers.Create { } func cancel() { - self.cancellable?.cancel() + self.cancellable.value?.cancel() } } } extension Publishers.Create.Subscription: CustomStringConvertible { var description: String { - return "Create.Subscription<\(Output.self), \(Failure.self)>" + return "Create.Subscription<\(Output.self)>" } } -extension EffectPublisher { - struct Subscriber { - private let _send: (Action) -> Void - private let _complete: (Subscribers.Completion) -> Void +extension Effect { + struct Subscriber: Sendable { + private let _send: @Sendable (Action) -> Void + private let _complete: @Sendable (Subscribers.Completion) -> Void init( - send: @escaping (Action) -> Void, - complete: @escaping (Subscribers.Completion) -> Void + send: @escaping @Sendable (Action) -> Void, + complete: @escaping @Sendable (Subscribers.Completion) -> Void ) { self._send = send self._complete = complete } - func send(_ value: Action) { + public func send(_ value: Action) { self._send(value) } - func send(completion: Subscribers.Completion) { + public func send(completion: Subscribers.Completion) { self._complete(completion) } } diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/CurrentValueRelay.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/CurrentValueRelay.swift new file mode 100644 index 00000000..bc9a9570 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/CurrentValueRelay.swift @@ -0,0 +1,156 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +import Combine +import Foundation + +final class CurrentValueRelay: Publisher { + typealias Failure = Never + + private var currentValue: Output + private let lock: os_unfair_lock_t + private var subscriptions = ContiguousArray() + + var value: Output { + get { self.lock.sync { self.currentValue } } + set { self.send(newValue) } + } + + init(_ value: Output) { + self.currentValue = value + self.lock = os_unfair_lock_t.allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + } + + deinit { + self.lock.deinitialize(count: 1) + self.lock.deallocate() + } + + func receive(subscriber: some Subscriber) { + let subscription = Subscription(upstream: self, downstream: subscriber) + self.lock.sync { + self.subscriptions.append(subscription) + } + subscriber.receive(subscription: subscription) + } + + func send(_ value: Output) { + let subscriptions = self.lock.sync { + self.currentValue = value + return self.subscriptions + } + for subscription in subscriptions { + subscription.receive(value) + } + } + + private func remove(_ subscription: Subscription) { + self.lock.sync { + guard let index = self.subscriptions.firstIndex(of: subscription) + else { return } + self.subscriptions.remove(at: index) + } + } +} + +extension CurrentValueRelay { + fileprivate final class Subscription: Combine.Subscription, Equatable { + private var demand = Subscribers.Demand.none + private var downstream: (any Subscriber)? + private let lock: os_unfair_lock_t + private var receivedLastValue = false + private var upstream: CurrentValueRelay? + + init(upstream: CurrentValueRelay, downstream: any Subscriber) { + self.upstream = upstream + self.downstream = downstream + self.lock = os_unfair_lock_t.allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + } + + deinit { + self.lock.deinitialize(count: 1) + self.lock.deallocate() + } + + func cancel() { + self.lock.sync { + self.downstream = nil + self.upstream?.remove(self) + self.upstream = nil + } + } + + func receive(_ value: Output) { + self.lock.lock() + + guard let downstream else { + self.lock.unlock() + return + } + + switch self.demand { + case .unlimited: + self.lock.unlock() + // NB: Adding to unlimited demand has no effect and can be ignored. + _ = downstream.receive(value) + + case .none: + self.receivedLastValue = false + self.lock.unlock() + + default: + self.receivedLastValue = true + self.demand -= 1 + self.lock.unlock() + let moreDemand = downstream.receive(value) + self.lock.sync { + self.demand += moreDemand + } + } + } + + func request(_ demand: Subscribers.Demand) { + precondition(demand > 0, "Demand must be greater than zero") + + self.lock.lock() + + guard let downstream else { + self.lock.unlock() + return + } + + self.demand += demand + + guard + !self.receivedLastValue, + let value = self.upstream?.value + else { + self.lock.unlock() + return + } + + self.receivedLastValue = true + + switch self.demand { + case .unlimited: + self.lock.unlock() + // NB: Adding to unlimited demand has no effect and can be ignored. + _ = downstream.receive(value) + + default: + self.demand -= 1 + self.lock.unlock() + let moreDemand = downstream.receive(value) + self.lock.lock() + self.demand += moreDemand + self.lock.unlock() + } + } + + static func == (lhs: Subscription, rhs: Subscription) -> Bool { + lhs === rhs + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Debug.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Debug.swift new file mode 100644 index 00000000..12b06ef0 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Debug.swift @@ -0,0 +1,13 @@ +/// Adapted from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Comments - removed import statement for CustomDump + +import Foundation + +extension String { + @usableFromInline + func indent(by indent: Int) -> String { + let indentation = String(repeating: " ", count: indent) + return indentation + self.replacingOccurrences(of: "\n", with: "\n\(indentation)") + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Locking.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Locking.swift new file mode 100644 index 00000000..5b4269d8 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Locking.swift @@ -0,0 +1,30 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +import Foundation + +extension UnsafeMutablePointer { + @inlinable @discardableResult + func sync(_ work: () -> R) -> R { + os_unfair_lock_lock(self) + defer { os_unfair_lock_unlock(self) } + return work() + } + + func lock() { + os_unfair_lock_lock(self) + } + + func unlock() { + os_unfair_lock_unlock(self) + } +} + +extension NSRecursiveLock { + @inlinable @discardableResult + @_spi(Internals) public func sync(work: () -> R) -> R { + self.lock() + defer { self.unlock() } + return work() + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Logger.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Logger.swift new file mode 100644 index 00000000..2f94f83d --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Internal/Logger.swift @@ -0,0 +1,47 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +import OSLog + +@_spi(Logging) +#if swift(<5.10) + @MainActor(unsafe) +#else + @preconcurrency@MainActor +#endif +public final class Logger { + public static let shared = Logger() + public var isEnabled = false + @Published public var logs: [String] = [] + #if DEBUG + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + var logger: os.Logger { + os.Logger(subsystem: "composable-architecture", category: "store-events") + } + public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { + guard self.isEnabled else { return } + let string = string() + if isRunningForPreviews { + print("\(string)") + } else { + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { + self.logger.log(level: level, "\(string)") + } + } + self.logs.append(string) + } + public func clear() { + self.logs = [] + } + #else + @inlinable @inline(__always) + public func log(level: OSLogType = .default, _ string: @autoclosure () -> String) { + } + @inlinable @inline(__always) + public func clear() { + } + #endif +} + +private let isRunningForPreviews = + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer.swift new file mode 100644 index 00000000..16291b43 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer.swift @@ -0,0 +1,112 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +/// A protocol that describes how to evolve the current state of an application to the next state, +/// given an action, and describes what ``Effect``s should be executed later by the store, if any. +public protocol Reducer { + /// A type that holds the current state of the reducer. + associatedtype State + + /// A type that holds all possible actions that cause the ``State`` of the reducer to change + /// and/or kick off a side ``Effect`` that can communicate with the outside world. + associatedtype Action + + /// A type representing the body of this reducer. + /// + /// When you create a custom reducer by implementing the ``body-swift.property``, Swift infers + /// this type from the value returned. + /// + /// If you create a custom reducer by implementing the ``reduce(into:action:)-1t2ri``, Swift + /// infers this type to be `Never`. + associatedtype Body + + /// Evolves the current state of the reducer to the next state. + /// + /// Implement this requirement for "primitive" reducers, or reducers that work on leaf node + /// features. To define a reducer by combining the logic of other reducers together, implement the + /// ``body-swift.property`` requirement instead. + /// + /// - Parameters: + /// - state: The current state of the reducer. + /// - action: An action that can cause the state of the reducer to change, and/or kick off a + /// side effect that can communicate with the outside world. + /// - Returns: An effect that can communicate with the outside world and feed actions back into + /// the system. + func reduce(into state: inout State, action: Action) -> Effect + + /// The content and behavior of a reducer that is composed from other reducers. + /// + /// In the body of a reducer one can compose many reducers together, which will be run in order, + /// from top to bottom, and usually involves some reducer operations for integrating, such as + /// `ifLet`, `forEach`, `_printChanges`, etc.: + /// + /// ```swift + /// var body: some ReducerOf { + /// Reduce { state, action in + /// … + /// } + /// .ifLet(\.child, action: \.child) { + /// ChildFeature() + /// } + /// ._printChanges() + /// + /// Analytics() + /// } + /// ``` + /// + /// Do not invoke this property directly. + /// + /// > Important: if your reducer implements the ``reduce(into:action:)-1t2ri`` method, it will + /// > take precedence over this property, and only ``reduce(into:action:)-1t2ri`` will be called + /// > by the ``Store``. If your reducer assembles a body from other reducers and has additional + /// > business logic it needs to layer into the system, introduce this logic into the body + /// > instead, either with ``Reduce``, or with a separate, dedicated conformance. + @ReducerBuilder + var body: Body { get } +} + +extension Reducer where Body == Never { + /// A non-existent body. + /// + /// > Warning: Do not invoke this property directly. It will trigger a fatal error at runtime. + @_transparent + public var body: Body { + fatalError( + """ + '\(Self.self)' has no body. … + + Do not access a reducer's 'body' property directly, as it may not exist. To run a reducer, \ + call 'Reducer.reduce(into:action:)', instead. + """ + ) + } +} + +extension Reducer where Body: Reducer { + /// Invokes the ``Body-40qdd``'s implementation of ``reduce(into:action:)-1t2ri``. + @inlinable + public func reduce( + into state: inout Body.State, action: Body.Action + ) -> Effect { + self.body.reduce(into: &state, action: action) + } +} + +/// A convenience for constraining a ``Reducer`` conformance. +/// +/// This allows you to specify the `body` of a ``Reducer`` conformance like so: +/// +/// ```swift +/// var body: some ReducerOf { +/// // ... +/// } +/// ``` +/// +/// …instead of the more verbose: +/// +/// ```swift +/// var body: some Reducer { +/// // ... +/// } +/// ``` +public typealias ReducerOf = Reducer diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/ReducerBuilder.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/ReducerBuilder.swift new file mode 100644 index 00000000..4a9df4e0 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/ReducerBuilder.swift @@ -0,0 +1,140 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +/// A result builder for combining reducers into a single reducer by running each, one after the +/// other, and merging their effects. +/// +/// It is most common to encounter a reducer builder context when conforming a type to ``Reducer`` +/// and implementing its ``Reducer/body-swift.property`` property. +/// +/// See ``CombineReducers`` for an entry point into a reducer builder context. +@resultBuilder +public enum ReducerBuilder { + @inlinable + public static func buildArray( + _ reducers: [some Reducer] + ) -> some Reducer { + _SequenceMany(reducers: reducers) + } + + @inlinable + public static func buildBlock() -> some Reducer { + EmptyReducer() + } + + @inlinable + public static func buildBlock>(_ reducer: R) -> R { + reducer + } + + @inlinable + public static func buildEither, R1: Reducer>( + first reducer: R0 + ) -> _Conditional { + .first(reducer) + } + + @inlinable + public static func buildEither, R1: Reducer>( + second reducer: R1 + ) -> _Conditional { + .second(reducer) + } + + @inlinable + public static func buildExpression>(_ expression: R) -> R { + expression + } + + @inlinable + @_disfavoredOverload + public static func buildExpression( + _ expression: any Reducer + ) -> Reduce { + Reduce(expression) + } + + @inlinable + public static func buildFinalResult>(_ reducer: R) -> R { + reducer + } + + @inlinable + public static func buildLimitedAvailability( + _ wrapped: some Reducer + ) -> Reduce { + Reduce(wrapped) + } + + @inlinable + public static func buildOptional>(_ wrapped: R?) -> R? { + wrapped + } + + @inlinable + public static func buildPartialBlock>(first: R) -> R { + first + } + + @inlinable + public static func buildPartialBlock, R1: Reducer>( + accumulated: R0, next: R1 + ) -> _Sequence { + _Sequence(accumulated, next) + } + + public enum _Conditional>: Reducer { + case first(First) + case second(Second) + + @inlinable + public func reduce(into state: inout First.State, action: First.Action) -> Effect< + First.Action + > { + switch self { + case let .first(first): + return first.reduce(into: &state, action: action) + + case let .second(second): + return second.reduce(into: &state, action: action) + } + } + } + + public struct _Sequence>: Reducer { + @usableFromInline + let r0: R0 + + @usableFromInline + let r1: R1 + + @usableFromInline + init(_ r0: R0, _ r1: R1) { + self.r0 = r0 + self.r1 = r1 + } + + @inlinable + public func reduce(into state: inout R0.State, action: R0.Action) -> Effect { + self.r0.reduce(into: &state, action: action) + .merge(with: self.r1.reduce(into: &state, action: action)) + } + } + + public struct _SequenceMany: Reducer { + @usableFromInline + let reducers: [Element] + + @usableFromInline + init(reducers: [Element]) { + self.reducers = reducers + } + + @inlinable + public func reduce( + into state: inout Element.State, action: Element.Action + ) -> Effect { + self.reducers.reduce(.none) { $0.merge(with: $1.reduce(into: &state, action: action)) } + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/EmptyReducer.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/EmptyReducer.swift new file mode 100644 index 00000000..153779e6 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/EmptyReducer.swift @@ -0,0 +1,23 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + + +/// A reducer that does nothing. +/// +/// While not very useful on its own, `EmptyReducer` can be used as a placeholder in APIs that hold +/// reducers. +public struct EmptyReducer: Reducer { + /// Initializes a reducer that does nothing. + @inlinable + public init() { + self.init(internal: ()) + } + + @usableFromInline + init(internal: Void) {} + + @inlinable + public func reduce(into _: inout State, action _: Action) -> Effect { + .none + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/Reduce.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/Reduce.swift new file mode 100644 index 00000000..ad0b7e8f --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/Reduce.swift @@ -0,0 +1,39 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +/// A type-erased reducer that invokes the given `reduce` function. +/// +/// ``Reduce`` is useful for injecting logic into a reducer tree without the overhead of introducing +/// a new type that conforms to ``Reducer``. +public struct Reduce: Reducer { + @usableFromInline + let reduce: (inout State, Action) -> Effect + + @usableFromInline + init( + internal reduce: @escaping (inout State, Action) -> Effect + ) { + self.reduce = reduce + } + + /// Initializes a reducer with a `reduce` function. + /// + /// - Parameter reduce: A function that is called when ``reduce(into:action:)`` is invoked. + @inlinable + public init(_ reduce: @escaping (_ state: inout State, _ action: Action) -> Effect) { + self.init(internal: reduce) + } + + /// Type-erases a reducer. + /// + /// - Parameter reducer: A reducer that is called when ``reduce(into:action:)`` is invoked. + @inlinable + public init(_ reducer: some Reducer) { + self.init(internal: reducer.reduce) + } + + @inlinable + public func reduce(into state: inout State, action: Action) -> Effect { + self.reduce(&state, action) + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/SignPostReducer.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/SignPostReducer.swift new file mode 100644 index 00000000..6fc4beb2 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Reducer/Reducers/SignPostReducer.swift @@ -0,0 +1,178 @@ +/// Copied verbatim from TCA v1.16.1 on 11/15/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + +import OSLog + +extension Reducer { + /// Instruments a reducer with + /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). + /// + /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its + /// effects will be measured with interval and event signposts. + /// + /// To use, build your app for profiling, create a blank instrument, and add the signpost + /// instrument. Start recording your app you will see timing information for every action sent to + /// the store, as well as every effect executed. + /// + /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living + /// effects. For example, if you start an effect (_e.g._, a location manager) in `onAppear` and + /// forget to tear down the effect in `onDisappear`, the instrument will show that the effect + /// never completed. + /// + /// - Parameters: + /// - prefix: A string to print at the beginning of the formatted message for the signpost. + /// - log: An `OSLog` to use for signposts. + /// - Returns: A reducer that has been enhanced with instrumentation. + @inlinable + @warn_unqualified_access + public func signpost( + _ prefix: String = "", + log: OSLog = OSLog( + subsystem: "co.pointfree.ComposableArchitecture", + category: "Reducer Instrumentation" + ) + ) -> _SignpostReducer { + _SignpostReducer(base: self, prefix: prefix, log: log) + } +} + +public struct _SignpostReducer: Reducer { + @usableFromInline + let base: Base + + @usableFromInline + let prefix: String + + @usableFromInline + let log: OSLog + + @usableFromInline + init( + base: Base, + prefix: String, + log: OSLog + ) { + self.base = base + // NB: Prevent rendering as "N/A" in Instruments + let zeroWidthSpace = "\u{200B}" + self.prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " + self.log = log + } + + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> Effect { + var actionOutput: String! + if self.log.signpostsEnabled { + actionOutput = debugCaseOutput(action) + os_signpost(.begin, log: log, name: "Action", "%s%s", self.prefix, actionOutput) + } + let effects = self.base.reduce(into: &state, action: action) + if self.log.signpostsEnabled { + os_signpost(.end, log: self.log, name: "Action") + return + effects + .effectSignpost(self.prefix, log: self.log, actionOutput: actionOutput) + } + return effects + } +} + +extension Effect { + @usableFromInline + func effectSignpost( + _ prefix: String, + log: OSLog, + actionOutput: String + ) -> Self { + let sid = OSSignpostID(log: log) + + switch self.operation { + case .none: + return self + case let .publisher(publisher): + return .init( + operation: .publisher( + publisher.handleEvents( + receiveSubscription: { _ in + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, + actionOutput) + }, + receiveOutput: { value in + os_signpost( + .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput) + }, + receiveCompletion: { completion in + switch completion { + case .finished: + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) + } + }, + receiveCancel: { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) + } + ) + .eraseToAnyPublisher() + ) + ) + case let .run(priority, operation): + return .init( + operation: .run(priority) { send in + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, + actionOutput + ) + await operation( + Send { action in + os_signpost( + .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput + ) + send(action) + } + ) + if Task.isCancelled { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) + } + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) + } + ) + } + } +} + +@usableFromInline +func debugCaseOutput( + _ value: Any, + abbreviated: Bool = false +) -> String { + func debugCaseOutputHelp(_ value: Any) -> String { + let mirror = Mirror(reflecting: value) + switch mirror.displayStyle { + case .enum: + guard let child = mirror.children.first else { + let childOutput = "\(value)" + return childOutput == "\(typeName(type(of: value)))" ? "" : ".\(childOutput)" + } + let childOutput = debugCaseOutputHelp(child.value) + return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" + case .tuple: + return mirror.children.map { label, value in + let childOutput = debugCaseOutputHelp(value) + return + "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" + } + .joined(separator: ", ") + default: + return "" + } + } + + return (value as? any CustomDebugStringConvertible)?.debugDescription + ?? "\(abbreviated ? "" : typeName(type(of: value)))\(debugCaseOutputHelp(value))" +} + +private func isUnlabeledArgument(_ label: String) -> Bool { + label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/RootStore.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/RootStore.swift new file mode 100644 index 00000000..27257a13 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/RootStore.swift @@ -0,0 +1,150 @@ +/// Adapted from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: Modified to remove dependencies, perception + +import Combine +import Foundation + +@_spi(Internals) +@MainActor +public final class RootStore { + private var bufferedActions: [Any] = [] + let didSet = CurrentValueRelay(()) + @_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] = [:] + private var isSending = false + private let reducer: any Reducer + private(set) var state: Any { + didSet { + didSet.send(()) + } + } + + init( + initialState: State, + reducer: some Reducer + ) { + self.state = initialState + self.reducer = reducer + } + + func send(_ action: Any, originatingFrom originatingAction: Any? = nil) -> Task? { + func open(reducer: some Reducer) -> Task? { + self.bufferedActions.append(action) + guard !self.isSending else { return nil } + + self.isSending = true + var currentState = self.state as! State + let tasks = LockIsolated<[Task]>([]) + defer { + withExtendedLifetime(self.bufferedActions) { + self.bufferedActions.removeAll() + } + self.state = currentState + self.isSending = false + if !self.bufferedActions.isEmpty { + if let task = self.send( + self.bufferedActions.removeLast(), + originatingFrom: originatingAction + ) { + tasks.withValue { $0.append(task) } + } + } + } + + var index = self.bufferedActions.startIndex + while index < self.bufferedActions.endIndex { + defer { index += 1 } + let action = self.bufferedActions[index] as! Action + let effect = reducer.reduce(into: ¤tState, action: action) + + switch effect.operation { + case .none: + break + case let .publisher(publisher): + var didComplete = false + let boxedTask = Box?>(wrappedValue: nil) + let uuid = UUID() + let effectCancellable = + publisher + .receive(on: UIScheduler.shared) + .handleEvents(receiveCancel: { [weak self] in self?.effectCancellables[uuid] = nil }) + .sink( + receiveCompletion: { [weak self] _ in + boxedTask.wrappedValue?.cancel() + didComplete = true + self?.effectCancellables[uuid] = nil + }, + receiveValue: { [weak self] effectAction in + guard let self else { return } + if let task = self.send(effectAction, originatingFrom: action) { + tasks.withValue { $0.append(task) } + } + } + ) + + + if !didComplete { + let task = Task { @MainActor in + for await _ in AsyncStream.never {} + effectCancellable.cancel() + } + boxedTask.wrappedValue = task + tasks.withValue { $0.append(task) } + self.effectCancellables[uuid] = effectCancellable + } + case let .run(priority, operation): + let task = Task(priority: priority) { @MainActor in + let isCompleted = LockIsolated(false) + defer { isCompleted.setValue(true) } + await operation( + Send { effectAction in + if isCompleted.value { + reportIssue( + """ + An action was sent from a completed effect: + + Action: + \(debugCaseOutput(effectAction)) + + Effect returned from: + \(debugCaseOutput(action)) + + Avoid sending actions using the 'send' argument from 'Effect.run' after \ + the effect has completed. This can happen if you escape the 'send' \ + argument in an unstructured context. + + To fix this, make sure that your 'run' closure does not return until \ + you're done calling 'send'. + """ + ) + } + if let task = self.send(effectAction, originatingFrom: action) { + tasks.withValue { $0.append(task) } + } + } + ) + } + tasks.withValue { $0.append(task) } + } + } + + guard !tasks.isEmpty else { return nil } + return Task { @MainActor in + await withTaskCancellationHandler { + var index = tasks.startIndex + while index < tasks.endIndex { + defer { index += 1 } + await tasks[index].value + } + } onCancel: { + var index = tasks.startIndex + while index < tasks.endIndex { + defer { index += 1 } + tasks[index].cancel() + } + } + } + } + return open(reducer: self.reducer) + } +} diff --git a/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Store.swift b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Store.swift new file mode 100644 index 00000000..a8fc9e38 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ComposableArchitecture/Store.swift @@ -0,0 +1,622 @@ +/// Adapted from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: Modified to remove dependencies, perception, animation, swiftui + +import Combine +import Foundation + +/// A store represents the runtime that powers the application. It is the object that you will pass +/// around to views that need to interact with the application. +/// +/// You will typically construct a single one of these at the root of your application: +/// +/// ```swift +/// @main +/// struct MyApp: App { +/// var body: some Scene { +/// WindowGroup { +/// RootView( +/// store: Store(initialState: AppFeature.State()) { +/// AppFeature() +/// } +/// ) +/// } +/// } +/// } +/// ``` +/// +/// …and then use the ``scope(state:action:)-90255`` method to derive more focused stores that can be +/// passed to subviews. +/// +/// ### Scoping +/// +/// The most important operation defined on ``Store`` is the ``scope(state:action:)-90255`` method, +/// which allows you to transform a store into one that deals with child state and actions. This is +/// necessary for passing stores to subviews that only care about a small portion of the entire +/// application's domain. +/// +/// For example, if an application has a tab view at its root with tabs for activity, search, and +/// profile, then we can model the domain like this: +/// +/// ```swift +/// @Reducer +/// struct AppFeature { +/// struct State { +/// var activity: Activity.State +/// var profile: Profile.State +/// var search: Search.State +/// } +/// +/// enum Action { +/// case activity(Activity.Action) +/// case profile(Profile.Action) +/// case search(Search.Action) +/// } +/// +/// // ... +/// } +/// ``` +/// +/// We can construct a view for each of these domains by applying ``scope(state:action:)-90255`` to +/// a store that holds onto the full app domain in order to transform it into a store for each +/// subdomain: +/// +/// ```swift +/// struct AppView: View { +/// let store: StoreOf +/// +/// var body: some View { +/// TabView { +/// ActivityView( +/// store: store.scope(state: \.activity, action: \.activity) +/// ) +/// .tabItem { Text("Activity") } +/// +/// SearchView( +/// store: store.scope(state: \.search, action: \.search) +/// ) +/// .tabItem { Text("Search") } +/// +/// ProfileView( +/// store: store.scope(state: \.profile, action: \.profile) +/// ) +/// .tabItem { Text("Profile") } +/// } +/// } +/// } +/// ``` +/// +/// ### Thread safety +/// +/// The `Store` class is not thread-safe, and so all interactions with an instance of ``Store`` +/// (including all of its child stores) must be done on the same thread the store was created on. +/// Further, if the store is powering a SwiftUI or UIKit view, as is customary, then all +/// interactions must be done on the _main_ thread. +/// +/// The reason stores are not thread-safe is due to the fact that when an action is sent to a store, +/// a reducer is run on the current state, and this process cannot be done from multiple threads. +/// It is possible to make this process thread-safe by introducing locks or queues, but this +/// introduces new complications: +/// +/// * If done simply with `DispatchQueue.main.async` you will incur a thread hop even when you are +/// already on the main thread. This can lead to unexpected behavior in UIKit and SwiftUI, where +/// sometimes you are required to do work synchronously, such as in animation blocks. +/// +/// * It is possible to create a scheduler that performs its work immediately when on the main +/// thread and otherwise uses `DispatchQueue.main.async` (_e.g._, see Combine Schedulers' +/// [UIScheduler][uischeduler]). +/// +/// This introduces a lot more complexity, and should probably not be adopted without having a very +/// good reason. +/// +/// This is why we require all actions be sent from the same thread. This requirement is in the same +/// spirit of how `URLSession` and other Apple APIs are designed. Those APIs tend to deliver their +/// outputs on whatever thread is most convenient for them, and then it is your responsibility to +/// dispatch back to the main queue if that's what you need. The Composable Architecture makes you +/// responsible for making sure to send actions on the main thread. If you are using an effect that +/// may deliver its output on a non-main thread, you must explicitly perform `.receive(on:)` in +/// order to force it back on the main thread. +/// +/// This approach makes the fewest number of assumptions about how effects are created and +/// transformed, and prevents unnecessary thread hops and re-dispatching. It also provides some +/// testing benefits. If your effects are not responsible for their own scheduling, then in tests +/// all of the effects would run synchronously and immediately. You would not be able to test how +/// multiple in-flight effects interleave with each other and affect the state of your application. +/// However, by leaving scheduling out of the ``Store`` we get to test these aspects of our effects +/// if we so desire, or we can ignore if we prefer. We have that flexibility. +/// +/// [uischeduler]: https://github.com/pointfreeco/combine-schedulers/blob/main/Sources/CombineSchedulers/UIScheduler.swift +/// +/// #### Thread safety checks +/// +/// The store performs some basic thread safety checks in order to help catch mistakes. Stores +/// constructed via the initializer ``init(initialState:reducer:withDependencies:)`` are assumed +/// to run only on the main thread, and so a check is executed immediately to make sure that is the +/// case. Further, all actions sent to the store and all scopes (see ``scope(state:action:)-90255``) +/// of the store are also checked to make sure that work is performed on the main thread. +#if swift(<5.10) + @MainActor(unsafe) +#else + @preconcurrency@MainActor +#endif +public final class Store { + var canCacheChildren = true + private var children: [ScopeID: AnyObject] = [:] + var _isInvalidated: @MainActor @Sendable () -> Bool = { false } + + @_spi(Internals) public let rootStore: RootStore + private let toState: PartialToState + private let fromAction: (Action) -> Any + private var parentCancellable: AnyCancellable? + + /// Initializes a store from an initial state and a reducer. + /// + /// - Parameters: + /// - initialState: The state to start the application in. + /// - reducer: The reducer that powers the business logic of the application. + /// - prepareDependencies: A closure that can be used to override dependencies that will be accessed + /// by the reducer. + public convenience init>( + initialState: @autoclosure () -> R.State, + @ReducerBuilder reducer: () -> R + ) { + + self.init( + initialState: initialState(), + reducer: reducer() + ) + } + + init() { + self._isInvalidated = { true } + self.rootStore = RootStore(initialState: (), reducer: EmptyReducer()) + self.toState = .keyPath(\State.self) + self.fromAction = { $0 } + } + + deinit { + guard Thread.isMainThread else { return } + MainActor._assumeIsolated { + Logger.shared.log("\(storeTypeName(of: self)).deinit") + } + } + + /// Calls the given closure with a snapshot of the current state of the store. + /// + /// A lightweight way of accessing store state when state is not observable and ``state-1qxwl`` is + /// unavailable. + /// + /// - Parameter body: A closure that takes the current state of the store as its sole argument. If + /// the closure has a return value, that value is also used as the return value of the + /// `withState` method. The state argument reflects the current state of the store only for the + /// duration of the closure's execution, and is only observable over time, _e.g._ by SwiftUI, if + /// it conforms to ``ObservableState``. + /// - Returns: The return value, if any, of the `body` closure. + public func withState(_ body: (_ state: State) -> R) -> R { + body(self.currentState) + } + + /// Sends an action to the store. + /// + /// This method returns a ``StoreTask``, which represents the lifecycle of the effect started from + /// sending an action. You can use this value to tie the effect's lifecycle _and_ cancellation to + /// an asynchronous context, such as SwiftUI's `task` view modifier: + /// + /// ```swift + /// .task { await store.send(.task).finish() } + /// ``` + /// + /// > Important: The ``Store`` is not thread safe and you should only send actions to it from the + /// > main thread. If you want to send actions on background threads due to the fact that the + /// > reducer is performing computationally expensive work, then a better way to handle this is to + /// > wrap that work in an ``Effect`` that is performed on a background thread so that the + /// > result can be fed back into the store. + /// + /// - Parameter action: An action. + /// - Returns: A ``StoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @discardableResult + public func send(_ action: Action) -> StoreTask { + .init(rawValue: self.send(action, originatingFrom: nil)) + } + + /// Scopes the store to one that exposes child state and actions. + /// + /// This can be useful for deriving new stores to hand to child views in an application. For + /// example: + /// + /// ```swift + /// @Reducer + /// struct AppFeature { + /// @ObservableState + /// struct State { + /// var login: Login.State + /// // ... + /// } + /// enum Action { + /// case login(Login.Action) + /// // ... + /// } + /// // ... + /// } + /// + /// // A store that runs the entire application. + /// let store = Store(initialState: AppFeature.State()) { + /// AppFeature() + /// } + /// + /// // Construct a login view by scoping the store + /// // to one that works with only login domain. + /// LoginView( + /// store: store.scope(state: \.login, action: \.login) + /// ) + /// ``` + /// + /// Scoping in this fashion allows you to better modularize your application. In this case, + /// `LoginView` could be extracted to a module that has no access to `AppFeature.State` or + /// `AppFeature.Action`. + /// + /// - Parameters: + /// - state: A key path from `State` to `ChildState`. + /// - action: A case key path from `Action` to `ChildAction`. + /// - Returns: A new store with its domain (state and action) transformed. + public func scope( + state: KeyPath, + action: CaseKeyPath + ) -> Store { + self.scope( + id: self.id(state: state, action: action), + state: ToState(state), + action: { action($0) }, + isInvalid: nil + ) + } + + @available( + *, deprecated, + message: + "Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths" + ) + public func scope( + state toChildState: @escaping (_ state: State) -> ChildState, + action fromChildAction: @escaping (_ childAction: ChildAction) -> Action + ) -> Store { + self.scope( + id: nil, + state: ToState(toChildState), + action: fromChildAction, + isInvalid: nil + ) + } + + @_spi(Internals) + public var currentState: State { + self.toState(self.rootStore.state) + } + + @_spi(Internals) + public + func scope( + id: ScopeID?, + state: ToState, + action fromChildAction: @escaping (ChildAction) -> Action, + isInvalid: ((State) -> Bool)? + ) -> Store + { + if self.canCacheChildren, + let id = id, + let childStore = self.children[id] as? Store + { + return childStore + } + let childStore = Store( + rootStore: self.rootStore, + toState: self.toState.appending(state.base), + fromAction: { [fromAction] in fromAction(fromChildAction($0)) } + ) + childStore._isInvalidated = + id == nil || !self.canCacheChildren + ? { @MainActor @Sendable in + isInvalid?(self.currentState) == true || self._isInvalidated() + } + : { @MainActor @Sendable [weak self] in + guard let self else { return true } + return isInvalid?(self.currentState) == true || self._isInvalidated() + } + childStore.canCacheChildren = self.canCacheChildren && id != nil + if let id = id, self.canCacheChildren { + self.children[id] = childStore + } + return childStore + } + + @_spi(Internals) + public func send( + _ action: Action, + originatingFrom originatingAction: Action? + ) -> Task? { + #if DEBUG + if self._isInvalidated() { + return .none + } + #endif + return self.rootStore.send(self.fromAction(action)) + } + + private init( + rootStore: RootStore, + toState: PartialToState, + fromAction: @escaping (Action) -> Any + ) { + defer { Logger.shared.log("\(storeTypeName(of: self)).init") } + self.rootStore = rootStore + self.toState = toState + self.fromAction = fromAction + } + + convenience init( + initialState: R.State, + reducer: R + ) + where + R.State == State, + R.Action == Action + { + self.init( + rootStore: RootStore(initialState: initialState, reducer: reducer), + toState: .keyPath(\State.self), + fromAction: { $0 } + ) + } + + /// A publisher that emits when state changes. + /// + /// This publisher supports dynamic member lookup so that you can pluck out a specific field in + /// the state: + /// + /// ```swift + /// store.publisher.alert + /// .sink { ... } + /// ``` + public var publisher: StorePublisher { + StorePublisher( + store: self, + upstream: self.rootStore.didSet.map { self.currentState } + ) + } + + @_spi(Internals) public func id( + state: KeyPath, + action: CaseKeyPath + ) -> ScopeID { + ScopeID(state: state, action: action) + } +} + +@_spi(Internals) public struct ScopeID: Hashable { + let state: PartialKeyPath + let action: PartialCaseKeyPath +} + +extension Store: CustomDebugStringConvertible { + public nonisolated var debugDescription: String { + storeTypeName(of: self) + } +} + +/// A convenience type alias for referring to a store of a given reducer's domain. +/// +/// Instead of specifying two generics: +/// +/// ```swift +/// let store: Store +/// ``` +/// +/// You can specify a single generic: +/// +/// ```swift +/// let store: StoreOf +/// ``` +public typealias StoreOf = Store + +/// A publisher of store state. +@dynamicMemberLookup +public struct StorePublisher: Publisher { + public typealias Output = State + public typealias Failure = Never + + let store: Any + let upstream: AnyPublisher + + init(store: Any, upstream: some Publisher) { + self.store = store + self.upstream = upstream.eraseToAnyPublisher() + } + + public func receive(subscriber: some Subscriber) { + self.upstream.subscribe( + AnySubscriber( + receiveSubscription: subscriber.receive(subscription:), + receiveValue: subscriber.receive(_:), + receiveCompletion: { [store = self.store] in + subscriber.receive(completion: $0) + _ = store + } + ) + ) + } + + /// Returns the resulting publisher of a given key path. + public subscript( + dynamicMember keyPath: KeyPath + ) -> StorePublisher { + .init(store: self.store, upstream: self.upstream.map(keyPath).removeDuplicates()) + } +} + +/// The type returned from ``Store/send(_:)`` that represents the lifecycle of the effect +/// started from sending an action. +/// +/// You can use this value to tie the effect's lifecycle _and_ cancellation to an asynchronous +/// context, such as the `task` view modifier. +/// +/// ```swift +/// .task { await store.send(.task).finish() } +/// ``` +/// +/// > Note: Unlike Swift's `Task` type, ``StoreTask`` automatically sets up a cancellation +/// > handler between the current async context and the task. +/// +/// See ``TestStoreTask`` for the analog returned from ``TestStore``. +public struct StoreTask: Hashable, Sendable { + internal let rawValue: Task? + + internal init(rawValue: Task?) { + self.rawValue = rawValue + } + + /// Cancels the underlying task. + public func cancel() { + self.rawValue?.cancel() + } + + /// Waits for the task to finish. + public func finish() async { + await self.rawValue?.cancellableValue + } + + /// A Boolean value that indicates whether the task should stop executing. + /// + /// After the value of this property becomes `true`, it remains `true` indefinitely. There is no + /// way to uncancel a task. + public var isCancelled: Bool { + self.rawValue?.isCancelled ?? true + } +} + +private protocol _OptionalProtocol {} +extension Optional: _OptionalProtocol {} + +func storeTypeName(of store: Store) -> String { + let stateType = typeName(State.self, genericsAbbreviated: false) + let actionType = typeName(Action.self, genericsAbbreviated: false) + if stateType.hasSuffix(".State"), + actionType.hasSuffix(".Action"), + stateType.dropLast(6) == actionType.dropLast(7) + { + return "StoreOf<\(stateType.dropLast(6))>" + } else if stateType.hasSuffix(".State?"), + actionType.hasSuffix(".Action"), + stateType.dropLast(7) == actionType.dropLast(7) + { + return "StoreOf<\(stateType.dropLast(7))?>" + } else if stateType.hasPrefix("IdentifiedArray<"), + actionType.hasPrefix("IdentifiedAction<"), + stateType.dropFirst(16).dropLast(7) == actionType.dropFirst(17).dropLast(8) + { + return "IdentifiedStoreOf<\(stateType.drop(while: { $0 != "," }).dropFirst(2).dropLast(7))>" + } else if stateType.hasPrefix("PresentationState<"), + actionType.hasPrefix("PresentationAction<"), + stateType.dropFirst(18).dropLast(7) == actionType.dropFirst(19).dropLast(8) + { + return "PresentationStoreOf<\(stateType.dropFirst(18).dropLast(7))>" + } else if stateType.hasPrefix("StackState<"), + actionType.hasPrefix("StackAction<"), + stateType.dropFirst(11).dropLast(7) + == actionType.dropFirst(12).prefix(while: { $0 != "," }).dropLast(6) + { + return "StackStoreOf<\(stateType.dropFirst(11).dropLast(7))>" + } else { + return "Store<\(stateType), \(actionType)>" + } +} + +// NB: From swift-custom-dump. Consider publicizing interface in some way to keep things in sync. +@usableFromInline +func storeTypeName( + _ type: Any.Type, + qualified: Bool = true, + genericsAbbreviated: Bool = true +) -> String { + var name = _typeName(type, qualified: qualified) + .replacingOccurrences( + of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, + with: "", + options: .regularExpression + ) + for _ in 1...10 { // NB: Only handle so much nesting + let abbreviated = + name + .replacingOccurrences( + of: #"\bSwift.Optional<([^><]+)>"#, + with: "$1?", + options: .regularExpression + ) + .replacingOccurrences( + of: #"\bSwift.Array<([^><]+)>"#, + with: "[$1]", + options: .regularExpression + ) + .replacingOccurrences( + of: #"\bSwift.Dictionary<([^,<]+), ([^><]+)>"#, + with: "[$1: $2]", + options: .regularExpression + ) + if abbreviated == name { break } + name = abbreviated + } + name = name.replacingOccurrences( + of: #"\w+\.([\w.]+)"#, + with: "$1", + options: .regularExpression + ) + if genericsAbbreviated { + name = name.replacingOccurrences( + of: #"<.+>"#, + with: "", + options: .regularExpression + ) + } + return name +} + +@_spi(Internals) +public struct ToState { + fileprivate let base: PartialToState + @_spi(Internals) + public init(_ closure: @escaping (State) -> ChildState) { + self.base = .closure { closure($0 as! State) } + } + @_spi(Internals) + public init(_ keyPath: KeyPath) { + self.base = .keyPath(keyPath) + } +} + +private enum PartialToState { + case closure((Any) -> State) + case keyPath(AnyKeyPath) + case appended((Any) -> Any, AnyKeyPath) + func callAsFunction(_ state: Any) -> State { + switch self { + case let .closure(closure): + return closure(state) + case let .keyPath(keyPath): + return state[keyPath: keyPath] as! State + case let .appended(closure, keyPath): + return closure(state)[keyPath: keyPath] as! State + } + } + func appending(_ state: PartialToState) -> PartialToState { + switch (self, state) { + case let (.keyPath(lhs), .keyPath(rhs)): + return .keyPath(lhs.appending(path: rhs)!) + case let (.closure(lhs), .keyPath(rhs)): + return .appended(lhs, rhs) + case let (.appended(lhsClosure, lhsKeyPath), .keyPath(rhs)): + return .appended(lhsClosure, lhsKeyPath.appending(path: rhs)!) + default: + return .closure { state(self($0)) } + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/ActorIsolated.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/ActorIsolated.swift new file mode 100644 index 00000000..abecbabe --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/ActorIsolated.swift @@ -0,0 +1,116 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +/// A generic wrapper for isolating a mutable value to an actor. +/// +/// This type is most useful when writing tests for when you want to inspect what happens inside an +/// async operation. +/// +/// For example, suppose you have a feature such that when a button is tapped you track some +/// analytics: +/// +/// ```swift +/// struct AnalyticsClient { +/// var track: (String) async -> Void +/// } +/// +/// class FeatureModel: ObservableObject { +/// let analytics: AnalyticsClient +/// // ... +/// func buttonTapped() { +/// // ... +/// await self.analytics.track("Button tapped") +/// } +/// } +/// ``` +/// +/// Then, in tests we can construct an analytics client that appends events to a mutable array +/// rather than actually sending events to an analytics server. However, in order to do this in a +/// safe way we should use an actor, and `ActorIsolated` makes this easy: +/// +/// ```swift +/// func testAnalytics() async { +/// let events = ActorIsolated<[String]>([]) +/// let analytics = AnalyticsClient( +/// track: { event in await events.withValue { $0.append(event) } } +/// ) +/// let model = FeatureModel(analytics: analytics) +/// model.buttonTapped() +/// await events.withValue { +/// XCTAssertEqual($0, ["Button tapped"]) +/// } +/// } +/// ``` +/// +/// To synchronously isolate a value, see ``LockIsolated``. +@available(*, deprecated, message: "Use 'LockIsolated' instead.") +public final actor ActorIsolated { + /// The actor-isolated value. + public var value: Value + + /// Initializes actor-isolated state around a value. + /// + /// - Parameter value: A value to isolate in an actor. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try value() + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// let count = ActorIsolated(0) + /// + /// func increment() async { + /// // Safely increment it: + /// await self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// > Tip: Because XCTest assertions don't play nicely with Swift concurrency, `withValue` also + /// > provides a handy interface to peek at an actor-isolated value and assert against it: + /// > + /// > ```swift + /// > let didOpenSettings = ActorIsolated(false) + /// > let model = withDependencies { + /// > $0.openSettings = { await didOpenSettings.setValue(true) } + /// > } operation: { + /// > FeatureModel() + /// > } + /// > await model.settingsButtonTapped() + /// > await didOpenSettings.withValue { XCTAssertTrue($0) } + /// > ``` + /// + /// - Parameter operation: An operation to be performed on the actor with the underlying value. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + var value = self.value + defer { self.value = value } + return try operation(&value) + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// let count = ActorIsolated(0) + /// + /// func reset() async { + /// // Reset it: + /// await self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of `setValue` if the value being set is derived from the + /// > current value. This isolates the entire transaction and avoids data races between reading + /// > and writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try newValue() + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AnyHashableSendable.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AnyHashableSendable.swift new file mode 100644 index 00000000..64814760 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AnyHashableSendable.swift @@ -0,0 +1,45 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +/// A type-erased hashable, sendable value. +/// +/// A sendable version of `AnyHashable` that is useful in working around the limitation that an +/// existential `any Hashable` does not conform to `Hashable`. +public struct AnyHashableSendable: Hashable, Sendable { + public let base: any Hashable & Sendable + + /// Creates a type-erased hashable, sendable value that wraps the given instance. + public init(_ base: some Hashable & Sendable) { + if let base = base as? AnyHashableSendable { + self = base + } else { + self.base = base + } + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + AnyHashable(lhs.base) == AnyHashable(rhs.base) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(base) + } +} + +extension AnyHashableSendable: CustomDebugStringConvertible { + public var debugDescription: String { + "AnyHashableSendable(" + String(reflecting: base) + ")" + } +} + +extension AnyHashableSendable: CustomReflectable { + public var customMirror: Mirror { + Mirror(self, children: ["value": base]) + } +} + +extension AnyHashableSendable: CustomStringConvertible { + public var description: String { + String(describing: base) + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncStream.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncStream.swift new file mode 100644 index 00000000..28480644 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncStream.swift @@ -0,0 +1,88 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +import Foundation + +extension AsyncStream { + /// Produces an `AsyncStream` from an `AsyncSequence` by consuming the sequence till it + /// terminates, ignoring any failure. + /// + /// Useful as a kind of type eraser for live `AsyncSequence`-based dependencies. + /// + /// For example, your feature may want to subscribe to screenshot notifications. You can model + /// this as a dependency client that returns an `AsyncStream`: + /// + /// ```swift + /// struct ScreenshotsClient { + /// var screenshots: () -> AsyncStream + /// func callAsFunction() -> AsyncStream { self.screenshots() } + /// } + /// ``` + /// + /// The "live" implementation of the dependency can supply a stream by erasing the appropriate + /// `NotificationCenter.Notifications` async sequence: + /// + /// ```swift + /// extension ScreenshotsClient { + /// static let live = Self( + /// screenshots: { + /// AsyncStream( + /// NotificationCenter.default + /// .notifications(named: UIApplication.userDidTakeScreenshotNotification) + /// .map { _ in } + /// ) + /// } + /// ) + /// } + /// ``` + /// + /// While your tests can use `AsyncStream.makeStream` to spin up a controllable stream for tests: + /// + /// ```swift + /// func testScreenshots() { + /// let screenshots = AsyncStream.makeStream(of: Void.self) + /// + /// let model = withDependencies { + /// $0.screenshots = { screenshots.stream } + /// } operation: { + /// FeatureModel() + /// } + /// + /// XCTAssertEqual(model.screenshotCount, 0) + /// screenshots.continuation.yield() // Simulate a screenshot being taken. + /// XCTAssertEqual(model.screenshotCount, 1) + /// } + /// ``` + /// + /// - Parameter sequence: An async sequence. + public init(_ sequence: S) where S.Element == Element, S: Sendable { + let lock = NSLock() + let iterator = UncheckedBox(wrappedValue: nil) + self.init { + lock.withLock { + if iterator.wrappedValue == nil { + iterator.wrappedValue = sequence.makeAsyncIterator() + } + } + return try? await iterator.wrappedValue?.next() + } + } + + /// An `AsyncStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + /// An `AsyncStream` that never emits and completes immediately. + public static var finished: Self { + Self { $0.finish() } + } +} + +extension AsyncSequence { + /// Erases this async sequence to an async stream that produces elements till this sequence + /// terminates (or fails). + public func eraseToStream() -> AsyncStream where Self: Sendable { + AsyncStream(self) + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncThrowingStream.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncThrowingStream.swift new file mode 100644 index 00000000..ea238d17 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/AsyncThrowingStream.swift @@ -0,0 +1,43 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +import Foundation + +extension AsyncThrowingStream where Failure == Error { + /// Produces an `AsyncThrowingStream` from an `AsyncSequence` by consuming the sequence till it + /// terminates, rethrowing any failure. + /// + /// - Parameter sequence: An async sequence. + public init(_ sequence: S) where S.Element == Element, S: Sendable { + let lock = NSLock() + let iterator = UncheckedBox(wrappedValue: nil) + self.init { + lock.withLock { + if iterator.wrappedValue == nil { + iterator.wrappedValue = sequence.makeAsyncIterator() + } + } + return try await iterator.wrappedValue?.next() + } + } + + /// An `AsyncThrowingStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + /// An `AsyncThrowingStream` that completes immediately. + /// + /// - Parameter error: An optional error the stream completes with. + public static func finished(throwing error: Failure? = nil) -> Self { + Self { $0.finish(throwing: error) } + } +} + +extension AsyncSequence { + /// Erases this async sequence to an async throwing stream that produces elements till this + /// sequence terminates, rethrowing any error on failure. + public func eraseToThrowingStream() -> AsyncThrowingStream where Self: Sendable { + AsyncThrowingStream(self) + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/ConcurrencyExtrasLocking.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/ConcurrencyExtrasLocking.swift new file mode 100644 index 00000000..17330ff9 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/ConcurrencyExtrasLocking.swift @@ -0,0 +1,14 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +import Foundation + +#if !(os(iOS) || os(macOS) || os(tvOS) || os(watchOS)) + extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try body() + } + } +#endif diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/UncheckedBox.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/UncheckedBox.swift new file mode 100644 index 00000000..05706647 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Internal/UncheckedBox.swift @@ -0,0 +1,9 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +final class UncheckedBox: @unchecked Sendable { + var wrappedValue: Value + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/LockIsolated.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/LockIsolated.swift new file mode 100644 index 00000000..432daad0 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/LockIsolated.swift @@ -0,0 +1,122 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +import Foundation + +/// A generic wrapper for isolating a mutable value with a lock. +/// +/// If you trust the sendability of the underlying value, consider using ``UncheckedSendable``, +/// instead. +@dynamicMemberLookup +public final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + + /// Initializes lock-isolated state around a value. + /// + /// - Parameter value: A value to isolate with a lock. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.lock.sync { + self._value[keyPath: keyPath] + } + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func increment() { + /// // Safely increment it: + /// self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// - Parameter operation: An operation to be performed on the the underlying value with a lock. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + try self.lock.sync { + var value = self._value + defer { self._value = value } + return try operation(&value) + } + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func reset() { + /// // Reset it: + /// self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of ``setValue(_:)`` if the value being set is derived + /// > from the current value. That is, do this: + /// > + /// > ```swift + /// > self.count.withValue { $0 += 1 } + /// > ``` + /// > + /// > ...and not this: + /// > + /// > ```swift + /// > self.count.setValue(self.count + 1) + /// > ``` + /// > + /// > ``withValue(_:)`` isolates the entire transaction and avoids data races between reading and + /// > writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + try self.lock.sync { + self._value = try newValue() + } + } +} + +extension LockIsolated where Value: Sendable { + /// The lock-isolated value. + public var value: Value { + self.lock.sync { + self._value + } + } +} + +#if swift(<6) + @available(*, deprecated, message: "Lock isolated values should not be equatable") + extension LockIsolated: Equatable where Value: Equatable { + public static func == (lhs: LockIsolated, rhs: LockIsolated) -> Bool { + lhs.value == rhs.value + } + } + + @available(*, deprecated, message: "Lock isolated values should not be hashable") + extension LockIsolated: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.value) + } + } +#endif + +extension NSRecursiveLock { + @inlinable @discardableResult + @_spi(Internals) public func sync(work: () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try work() + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/MainSerialExecutor.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/MainSerialExecutor.swift new file mode 100644 index 00000000..564dd42c --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/MainSerialExecutor.swift @@ -0,0 +1,93 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +#if !os(WASI) && !os(Windows) + import Foundation + + /// Perform an operation on the main serial executor. + /// + /// Some asynchronous code is [notoriously + /// difficult](https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304) + /// to test in Swift due to how suspension points are processed by the runtime. This function + /// attempts to run all tasks spawned in the given operation serially and deterministically. It + /// makes asynchronous tests faster and less flakey. + /// + /// ```swift + /// await withMainSerialExecutor { + /// // Everything performed in this scope is performed serially... + /// } + /// ``` + /// + /// See for more information on why this tool is needed to test + /// async code and how to use it. + /// + /// > Warning: This API is only intended to be used from tests to make them more reliable. Please do + /// > not use it from application code. + /// > + /// > We say that it "_attempts_ to run all tasks spawned in an operation serially and + /// > deterministically" because under the hood it relies on a global, mutable variable in the Swift + /// > runtime to do its job, and there are no scoping _guarantees_ should this mutable variable change + /// > during the operation. + /// + /// - Parameter operation: An operation to be performed on the main serial executor. + @MainActor + public func withMainSerialExecutor( + @_implicitSelfCapture operation: @Sendable () async throws -> Void + ) async rethrows { + let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor + defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor } + uncheckedUseMainSerialExecutor = true + try await operation() + } + + /// Perform an operation on the main serial executor. + /// + /// A synchronous version of ``withMainSerialExecutor(operation:)-79jpc`` that can be used in + /// `XCTestCase.invokeTest` to ensure all async tests are performed serially: + /// + /// ```swift + /// class BaseTestCase: XCTestCase { + /// override func invokeTest() { + /// withMainSerialExecutor { + /// super.invokeTest() + /// } + /// } + /// } + /// ``` + /// + /// - Parameter operation: An operation to be performed on the main serial executor. + public func withMainSerialExecutor( + @_implicitSelfCapture operation: () throws -> Void + ) rethrows { + let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor + defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor } + uncheckedUseMainSerialExecutor = true + try operation() + } + + /// Overrides Swift's global executor with the main serial executor in an unchecked fashion. + /// + /// > Warning: When set to `true`, all tasks will be enqueued on the main serial executor till set + /// > back to `false`. Consider using ``withMainSerialExecutor(operation:)-79jpc``, instead, which + /// > scopes this work to the duration of a given operation. + public var uncheckedUseMainSerialExecutor: Bool { + get { swift_task_enqueueGlobal_hook != nil } + set { + swift_task_enqueueGlobal_hook = + newValue + ? { job, _ in MainActor.shared.enqueue(job) } + : nil + } + } + + private typealias Original = @convention(thin) (UnownedJob) -> Void + private typealias Hook = @convention(thin) (UnownedJob, Original) -> Void + + private var swift_task_enqueueGlobal_hook: Hook? { + get { _swift_task_enqueueGlobal_hook.wrappedValue.pointee } + set { _swift_task_enqueueGlobal_hook.wrappedValue.pointee = newValue } + } + private let _swift_task_enqueueGlobal_hook = UncheckedSendable( + dlsym(dlopen(nil, 0), "swift_task_enqueueGlobal_hook").assumingMemoryBound(to: Hook?.self) + ) +#endif diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Result.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Result.swift new file mode 100644 index 00000000..22681f36 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Result.swift @@ -0,0 +1,17 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +extension Result where Failure == Swift.Error { + /// Creates a new result by evaluating an async throwing closure, capturing the returned value as + /// a success, or any thrown error as a failure. + /// + /// - Parameter body: A throwing closure to evaluate. + @_transparent + public init(catching body: () async throws -> Success) async { + do { + self = .success(try await body()) + } catch { + self = .failure(error) + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Task.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Task.swift new file mode 100644 index 00000000..5014a707 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/Task.swift @@ -0,0 +1,87 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +import Foundation + +extension Task where Success == Never, Failure == Never { + /// Suspends the current task a number of times before resuming with the goal of allowing other + /// tasks to start their work. + /// + /// This function can be used to make flakey async tests less flakey, as described in + /// [this Swift Forums post](https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304). + /// You may, however, prefer to use ``withMainSerialExecutor(operation:)-79jpc`` to improve the + /// reliability of async tests, and to make their execution deterministic. + /// + /// > Note: When invoked from ``withMainSerialExecutor(operation:)-79jpc``, or when + /// > ``uncheckedUseMainSerialExecutor`` is set to `true`, `Task.megaYield()` is equivalent to + /// > a single `Task.yield()`. + public static func megaYield(count: Int = _defaultMegaYieldCount) async { + // TODO: Investigate why mega yields are still necessary in TCA's test suite. + // guard !uncheckedUseMainSerialExecutor else { + // await Task.yield() + // return + // } + for _ in 0...detached(priority: .background) { await Task.yield() }.value + } + } +} + +/// The number of yields `Task.megaYield()` invokes by default. +/// +/// Can be overridden by setting the `TASK_MEGA_YIELD_COUNT` environment variable. +public let _defaultMegaYieldCount = max( + 0, + min( + ProcessInfo.processInfo.environment["TASK_MEGA_YIELD_COUNT"].flatMap(Int.init) ?? 20, + 10_000 + ) +) + +extension Task where Failure == Never { + /// An async function that never returns. + public static func never() async throws -> Success { + for await element in AsyncStream.never { + return element + } + throw _Concurrency.CancellationError() + } +} + +extension Task where Success == Never, Failure == Never { + /// An async function that never returns. + public static func never() async throws { + for await _ in AsyncStream.never {} + throw _Concurrency.CancellationError() + } +} + +extension Task where Failure == Never { + /// Waits for the result of the task, propagating cancellation to the task. + /// + /// Equivalent to wrapping a call to `Task.value` in `withTaskCancellationHandler`. + public var cancellableValue: Success { + get async { + await withTaskCancellationHandler { + await self.value + } onCancel: { + self.cancel() + } + } + } +} + +extension Task where Failure == Error { + /// Waits for the result of the task, propagating cancellation to the task. + /// + /// Equivalent to wrapping a call to `Task.value` in `withTaskCancellationHandler`. + public var cancellableValue: Success { + get async throws { + try await withTaskCancellationHandler { + try await self.value + } onCancel: { + self.cancel() + } + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/UncheckedSendable.swift b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/UncheckedSendable.swift new file mode 100644 index 00000000..200c8530 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/ConcurrencyExtras/UncheckedSendable.swift @@ -0,0 +1,114 @@ +/// Copied verbatim from swift-concurrency-extras v1.2.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-concurrency-extras/tree/1.2.0 + +/// A generic wrapper for turning any non-`Sendable` type into a `Sendable` one, in an unchecked +/// manner. +/// +/// Sometimes we need to use types that should be sendable but have not yet been audited for +/// sendability. If we feel confident that the type is truly sendable, and we don't want to blanket +/// disable concurrency warnings for a module via `@preconcurrency import`, then we can selectively +/// make that single type sendable by wrapping it in `UncheckedSendable`. +/// +/// > Note: By wrapping something in `UncheckedSendable` you are asking the compiler to trust you +/// > that the type is safe to use from multiple threads, and the compiler cannot help you find +/// > potential race conditions in your code. +/// +/// To synchronously isolate a value with a lock, see ``LockIsolated``. +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif + +@dynamicMemberLookup +@propertyWrapper +public struct UncheckedSendable: @unchecked Sendable { + /// The unchecked value. + public var value: Value + + /// Initializes unchecked sendability around a value. + /// + /// - Parameter value: A value to make sendable in an unchecked way. + public init(_ value: Value) { + self.value = value + } + + public init(wrappedValue: Value) { + self.value = wrappedValue + } + + public var wrappedValue: Value { + _read { yield self.value } + _modify { yield &self.value } + } + + public var projectedValue: Self { + get { self } + set { self = newValue } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.value[keyPath: keyPath] + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { + _read { yield self.value[keyPath: keyPath] } + _modify { yield &self.value[keyPath: keyPath] } + } +} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Equatable where Value: Equatable {} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Hashable where Value: Hashable {} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(wrappedValue: try container.decode(Value.self)) + } catch { + self.init(wrappedValue: try Value(from: decoder)) + } + } +} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpReflectable.swift b/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpReflectable.swift new file mode 100644 index 00000000..56dee3bc --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpReflectable.swift @@ -0,0 +1,134 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// A type that explicitly supplies its own mirror for ``customDump(_:to:name:indent:maxDepth:)`` +/// and ``diff(_:_:format:)``. +/// +/// Types that want to customize their dump output can conform to this protocol, especially those +/// with a complex or nested internal structure. Providing a custom mirror allows you to reorder, +/// transform, or omit fields on a base structure, or even change the representation of the base +/// structure itself. +/// +/// For unstructured data types, or data types that are represented by single values, see the +/// ``CustomDumpStringConvertible`` protocol. +/// +/// ## Customizing the dump of a structure's fields +/// +/// For example, let's say you have a struct representing login state, which holds a secure token in +/// memory that should never be written to your logs. You can omit the token from `customDump` by +/// providing a mirror that omits this field: +/// +/// ```swift +/// struct LoginState: CustomDumpReflectable { +/// var email = "" +/// var password = "" +/// var token: String +/// +/// var customDumpMirror: Mirror { +/// .init( +/// self, +/// children: [ +/// "email": self.email, +/// "password": self.password +/// // omit token from logs +/// ], +/// displayStyle: .struct +/// ) +/// } +/// } +/// +/// customDump( +/// LoginState( +/// email: "blob@pointfree.co", +/// password: "bl0bisawesome!", +/// token: "secret" +/// ) +/// ) +/// ``` +/// ```text +/// LoginState( +/// email: "blob@pointfree.co", +/// password: "bl0bisawesome!" +/// ) +/// ``` +/// +/// There! No token data is being written to the dump. However, the dump still contains the user's +/// password, which is sensitive. Rather than omit it entirely, we could redact this information +/// using a `Redacted` wrapper type that redacts its contents from custom dumps via the +/// ``CustomDumpStringConvertible`` protocol: +/// +/// ```swift +/// struct Redacted: CustomDumpStringConvertible { +/// let rawValue: RawValue +/// +/// var customDumpDescription: String { +/// "" +/// } +/// } +/// +/// struct LoginState: CustomDumpReflectable { +/// ... +/// var customDumpMirror: Mirror { +/// .init( +/// self, +/// children: [ +/// "email": self.email, +/// // redact password! +/// "password": Redacted(rawValue: self.password) +/// // omit token from logs +/// ], +/// displayStyle: .struct +/// ) +/// } +/// } +/// +/// customDump( +/// LoginState( +/// email: "blob@pointfree.co", +/// password: "bl0bisawesome!", +/// token: "secret" +/// ) +/// ) +/// ``` +/// ```text +/// LoginState( +/// email: "blob@pointfree.co", +/// password: +/// ) +/// ``` +/// +/// Now the dump retains the fact that a password field exists, but it prevents the underlying value +/// from being logged. +/// +/// ## Overriding a structure's dump representation +/// +/// Massaging the data inside a structure is just one way to use a custom mirror. A mirror can also +/// let you completely transform the _way_ a structure is dumped. +/// +/// For example, a wrapper type can be flattened to dump the wrapped value by providing the wrapped +/// value's mirror: +/// +/// ```swift +/// struct Todos: CustomDumpReflectable { +/// var rawValue: [Todo] = [] +/// +/// var customDumpMirror: Mirror { +/// .init(reflecting: self.rawValue) +/// } +/// } +/// +/// customDump(Todos()) +/// ``` +/// ```text +/// [] +/// ``` +public protocol CustomDumpReflectable { + /// The custom dump mirror for this instance. + var customDumpMirror: Mirror { get } +} + +extension Mirror { + init(customDumpReflecting subject: Any) { + self = (subject as? CustomDumpReflectable)?.customDumpMirror ?? Mirror(reflecting: subject) + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpRepresentable.swift b/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpRepresentable.swift new file mode 100644 index 00000000..6d2c63c8 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpRepresentable.swift @@ -0,0 +1,29 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// A type that can be converted to a value for the purpose of dumping. +/// +/// The `CustomDumpRepresentable` protocol allows you to return _any_ value for the purpose of +/// dumping. This can be used to flatten the dump representation of wrapper types. For example, a +/// type-safe identifier may want to dump its raw value directly: +/// +/// ```swift +/// struct ID: RawRepresentable { +/// var rawValue: String +/// } +/// +/// extension ID: CustomDumpRepresentable { +/// var customDumpValue: Any { +/// self.rawValue +/// } +/// } +/// +/// customDump(ID(rawValue: "deadbeef") +/// ``` +/// ```text +/// "deadbeef" +/// ``` +public protocol CustomDumpRepresentable { + /// The custom dump value for this instance. + var customDumpValue: Any { get } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpStringConvertible.swift b/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpStringConvertible.swift new file mode 100644 index 00000000..83f5fd2e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/CustomDumpStringConvertible.swift @@ -0,0 +1,69 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// A type with a customized textual representation for ``customDump(_:to:name:indent:maxDepth:)`` +/// and ``diff(_:_:format:)``. +/// +/// Types that want to customize their dump output can conform to this protocol. It is most +/// appropriate for types that have a simple, un-nested internal representation, and typically its +/// output fits on a single line, for example dates, UUIDs, URLs, etc. +/// +/// For data types with more structure, for example those with nesting and multiple fields, see the +/// ``CustomDumpReflectable`` protocol. +/// +/// The library conforms a bunch of Foundation types to this protocol to simplify their dump output: +/// +/// ```swift +/// extension URL: CustomDumpStringConvertible { +/// public var customDumpDescription: String { +/// "URL(\(self.absoluteString))" +/// } +/// } +/// +/// customDump(URL(string: "https://www.pointfree.co/")!) +/// ``` +/// ```text +/// URL(https://www.pointfree.co/) +/// ``` +/// +/// Custom Dump also uses this protocol internally to provide more useful output for enums imported +/// from Objective-C: +/// +/// ```swift +/// import UserNotifications +/// +/// print("dump:") +/// dump(UNNotificationSetting.disabled) +/// print("customDump:") +/// customDump(UNNotificationSetting.disabled) +/// ``` +/// ```text +/// dump: +/// - __C.UNNotificationSetting +/// customDump: +/// UNNotificationSettings.disabled +/// ``` +/// +/// Any time you want to override the dump representation with some other string, you can use this +/// protocol. +/// +/// For example, you could introduce a wrapper type that "redacts" a portion of a dump: +/// +/// ```swift +/// struct Redacted: CustomDumpStringConvertible { +/// let rawValue: RawValue +/// +/// var customDumpDescription: String { +/// "" +/// } +/// } +/// +/// customDump(Redacted(rawValue: "my super secret password")) +/// ``` +/// ```text +/// +/// ``` +public protocol CustomDumpStringConvertible { + /// The custom dump description for this instance. + var customDumpDescription: String { get } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Diff.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Diff.swift new file mode 100644 index 00000000..b08017f9 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Diff.swift @@ -0,0 +1,819 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// Detects differences between two given values by comparing their mirrors and optionally returns +/// a formatted string describing it. +/// +/// This can be a great tool to use for building debug tools for applications and libraries. For +/// example, this library uses ``diff(_:_:format:)`` to implement +/// ``XCTAssertNoDifference(_:_:_:file:line:)``, which asserts that two values are equal, and +/// if they are not the failure message is a nicely formatted diff showing exactly what part of the +/// values are not equal. +/// +/// Further, the +/// [Composable Architecture](https://www.github.com/pointfreeco/swift-composable-architecture) uses +/// ``diff(_:_:format:)`` in a couple different ways: +/// +/// * It is used to implement a tool that prints changes to application state over time as diffs +/// between the previous state and the current state whenever an action is sent to the store. +/// * It is also used in a testing tool so that when one fails to assert for how state may have +/// changed after sending an action, it can display a concise message showing the exact difference +/// in state. +/// +/// - Parameters: +/// - lhs: An expression of type `T`. +/// - rhs: A second expression of type `T`. +/// - format: A format to use for the diff. By default it uses ASCII characters typically +/// associated with the "diff" format: "-" for removals, "+" for additions, and " " for +/// unchanged lines. +/// - Returns: A string describing any difference detected between values, or `nil` if no difference +/// is detected. +public func diff(_ lhs: T, _ rhs: T, format: DiffFormat = .default) -> String? { + var tracker = ObjectTracker() + + func diffHelp( + _ lhs: Any, + _ rhs: Any, + lhsName: String?, + rhsName: String?, + separator: String, + indent: Int, + isRoot: Bool + ) -> String { + let rhsName = rhsName ?? lhsName + guard lhsName != rhsName || !isMirrorEqual(lhs, rhs) else { + return _customDump( + lhs, + name: rhsName, + indent: indent, + isRoot: isRoot, + maxDepth: 0, + tracker: &tracker + ) + .appending(separator) + .indenting(with: format.both + " ") + } + + let lhsMirror = Mirror(customDumpReflecting: lhs) + let rhsMirror = Mirror(customDumpReflecting: rhs) + var out = "" + + func diffEverything() { + var lhs = _customDump( + lhs, + name: lhsName, + indent: indent, + isRoot: isRoot, + maxDepth: .max, + tracker: &tracker + ) + var rhs = _customDump( + rhs, + name: rhsName, + indent: indent, + isRoot: isRoot, + maxDepth: .max, + tracker: &tracker + ) + if lhs == rhs { + if lhsMirror.subjectType != rhsMirror.subjectType { + lhs.append(" as \(customDumptypeName(lhsMirror.subjectType))") + rhs.append(" as \(customDumptypeName(rhsMirror.subjectType))") + } + } + lhs.append(separator) + rhs.append(separator) + + print( + lhs.indenting(with: format.first + " "), + to: &out + ) + print( + rhs.indenting(with: format.second + " "), + terminator: "", + to: &out + ) + } + + guard lhsMirror.subjectType == rhsMirror.subjectType + else { + diffEverything() + return out + } + + func diffChildren( + lhs: Any = lhs, + rhs: Any = rhs, + _ lhsMirror: Mirror, + _ rhsMirror: Mirror, + lhsName: String? = lhsName, + rhsName: String? = rhsName, + nameSuffix: String = ":", + prefix: String, + suffix: String, + elementIndent: Int, + elementSeparator: String, + collapseUnchanged: Bool, + filter isIncluded: (Mirror.Child) -> Bool = { _ in true }, + areEquivalent: (Mirror.Child, Mirror.Child) -> Bool = { $0.label == $1.label }, + areInIncreasingOrder: ((Mirror.Child, Mirror.Child) -> Bool)? = nil, + map transform: (inout Mirror.Child, Int) -> Void = { _, _ in } + ) { + var lhsChildren = Array(lhsMirror.children) + var rhsChildren = Array(rhsMirror.children) + + if isMirrorEqual(lhsChildren, rhsChildren), + !(lhs is _CustomDiffObject), + !(rhs is _CustomDiffObject) + { + let lhsDump = + _customDump( + lhs, + name: lhsName, + nameSuffix: nameSuffix, + indent: indent, + isRoot: false, + maxDepth: 0, + tracker: &tracker + ) + separator + let rhsDump = + _customDump( + rhs, + name: rhsName, + nameSuffix: nameSuffix, + indent: indent, + isRoot: false, + maxDepth: 0, + tracker: &tracker + ) + separator + if lhsDump == rhsDump { + print( + "// Not equal but no difference detected:" + .indenting(by: indent) + .indenting(with: format.both + " "), + to: &out + ) + } + print( + lhsDump.indenting(with: format.first + " "), + to: &out + ) + print( + rhsDump.indenting(with: format.second + " "), + terminator: "", + to: &out + ) + return + } + + guard !lhsMirror.isSingleValueContainer && !rhsMirror.isSingleValueContainer + else { + print( + _customDump( + lhs, + name: lhsName, + nameSuffix: nameSuffix, + indent: indent, + isRoot: isRoot, + maxDepth: .max, + tracker: &tracker + ) + .indenting(with: format.first + " "), + to: &out + ) + print( + _customDump( + rhs, + name: rhsName, + nameSuffix: nameSuffix, + indent: indent, + isRoot: isRoot, + maxDepth: .max, + tracker: &tracker + ) + .indenting(with: format.second + " "), + terminator: "", + to: &out + ) + return + } + + lhsChildren.removeAll(where: { !isIncluded($0) }) + rhsChildren.removeAll(where: { !isIncluded($0) }) + + let name = rhsName.map { "\($0)\(nameSuffix) " } ?? "" + print( + name + .appending(prefix) + .indenting(by: indent) + .indenting(with: format.both + " "), + to: &out + ) + + if let areInIncreasingOrder { + lhsChildren.sort(by: areInIncreasingOrder) + rhsChildren.sort(by: areInIncreasingOrder) + } + + let difference = rhsChildren.difference(from: lhsChildren, by: areEquivalent) + + var lhsOffset = 0 + var rhsOffset = 0 + var unchangedBuffer: [Mirror.Child] = [] + + func flushUnchanged() { + guard collapseUnchanged else { return } + if areInIncreasingOrder == nil && unchangedBuffer.count == 1 { + let child = unchangedBuffer[0] + print( + _customDump( + child.value, + name: child.label, + indent: indent + elementIndent, + isRoot: false, + maxDepth: 0, + tracker: &tracker + ) + .indenting(with: format.both + " "), + terminator: rhsOffset - 1 == rhsChildren.count - 1 ? "\n" : "\(elementSeparator)\n", + to: &out + ) + } else if areInIncreasingOrder != nil && unchangedBuffer.count == 1 + || unchangedBuffer.count > 1 + { + print( + "… (\(unchangedBuffer.count) unchanged)" + .indenting(by: indent + elementIndent) + .indenting(with: format.both + " "), + terminator: rhsOffset - 1 == rhsChildren.count - 1 ? "\n" : "\(elementSeparator)\n", + to: &out + ) + } + unchangedBuffer.removeAll() + } + + while lhsOffset < lhsChildren.count || rhsOffset < rhsChildren.count { + let isRemoval = difference.removals.contains(where: { $0.offset == lhsOffset }) + let isInsertion = difference.insertions.contains(where: { $0.offset == rhsOffset }) + + if collapseUnchanged, + !isRemoval, + !isInsertion, + isMirrorEqual(lhsChildren[lhsOffset], rhsChildren[rhsOffset]) + { + var child = rhsChildren[rhsOffset] + transform(&child, rhsOffset) + unchangedBuffer.append(child) + lhsOffset += 1 + rhsOffset += 1 + continue + } + + if areInIncreasingOrder == nil { + flushUnchanged() + } + + switch (isRemoval, isInsertion) { + case (true, true), (false, false): + var lhsChild = lhsChildren[lhsOffset] + var rhsChild = rhsChildren[rhsOffset] + transform(&lhsChild, isRemoval ? lhsOffset : rhsOffset) + transform(&rhsChild, rhsOffset) + print( + diffHelp( + lhsChild.value, + rhsChild.value, + lhsName: lhsChild.label, + rhsName: rhsChild.label, + separator: lhsOffset == lhsChildren.count - 1 && rhsOffset == rhsChildren.count - 1 + ? "" + : elementSeparator, + indent: indent + elementIndent, + isRoot: false + ), + to: &out + ) + lhsOffset += 1 + rhsOffset += 1 + continue + + case (true, false): + var lhsChild = lhsChildren[lhsOffset] + transform(&lhsChild, lhsOffset) + print( + _customDump( + lhsChild.value, + name: lhsChild.label, + indent: indent + elementIndent, + isRoot: false, + maxDepth: .max, + tracker: &tracker + ) + .indenting(with: format.first + " "), + terminator: lhsOffset == lhsChildren.count - 1 ? "\n" : "\(elementSeparator)\n", + to: &out + ) + lhsOffset += 1 + + case (false, true): + var rhsChild = rhsChildren[rhsOffset] + transform(&rhsChild, rhsOffset) + print( + _customDump( + rhsChild.value, + name: rhsChild.label, + indent: indent + elementIndent, + isRoot: false, + maxDepth: .max, + tracker: &tracker + ) + .indenting(with: format.second + " "), + terminator: rhsOffset == rhsChildren.count - 1 && unchangedBuffer.isEmpty + ? "\n" + : "\(elementSeparator)\n", + to: &out + ) + rhsOffset += 1 + } + } + + flushUnchanged() + + print( + suffix + .indenting(by: indent) + .indenting(with: format.both + " "), + terminator: separator, + to: &out + ) + } + + switch (lhs, lhsMirror.displayStyle, rhs, rhsMirror.displayStyle) { + case (is CustomDumpStringConvertible, _, is CustomDumpStringConvertible, _): + diffEverything() + + case let (lhs as _CustomDiffObject, _, rhs as _CustomDiffObject, _): + let lhsItem = lhs._objectIdentifier + let rhsItem = rhs._objectIdentifier + if lhsItem == rhsItem { + let (lhs, rhs) = lhs._customDiffValues + let subjectType = customDumptypeName(type(of: lhs)) + var occurrence = tracker.occurrencePerType[subjectType, default: 1] { + didSet { tracker.occurrencePerType[subjectType] = occurrence } + } + var id: UInt { + let id = tracker.idPerItem[lhsItem, default: occurrence] + tracker.idPerItem[lhsItem] = id + return id + } + if tracker.visitedItems.contains(lhsItem) { + print( + "\(lhsName.map { "\($0): " } ?? "")#\(id) \(subjectType)(↩︎)\(separator)" + .indenting(by: indent) + .indenting(with: format.first + " "), + to: &out + ) + print( + "\(rhsName.map { "\($0): " } ?? "")#\(id) \(subjectType)(↩︎)\(separator)" + .indenting(by: indent) + .indenting(with: format.second + " "), + terminator: "", + to: &out + ) + } else { + diffChildren( + lhs: lhs, + rhs: rhs, + Mirror(customDumpReflecting: lhs), + Mirror(customDumpReflecting: rhs), + lhsName: "\(lhsName.map { "\($0): " } ?? "")#\(id)", + rhsName: "\(rhsName.map { "\($0): " } ?? "")#\(id)", + nameSuffix: "", + prefix: "\(subjectType)(", + suffix: ")", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: false, + filter: macroPropertyFilter(for: lhs) + ) + tracker.visitedItems.insert(lhsItem) + occurrence += 1 + } + } else { + diffEverything() + } + + case let (lhs as CustomDumpRepresentable, _, rhs as CustomDumpRepresentable, _): + out.write( + diffHelp( + lhs.customDumpValue, + rhs.customDumpValue, + lhsName: lhsName, + rhsName: rhsName, + separator: separator, + indent: indent, + isRoot: isRoot + ) + ) + + case let (lhs as AnyObject, .class?, rhs as AnyObject, .class?): + let lhsItem = ObjectIdentifier(lhs) + let rhsItem = ObjectIdentifier(rhs) + let subjectType = customDumptypeName(lhsMirror.subjectType) + if !tracker.visitedItems.contains(lhsItem) && !tracker.visitedItems.contains(rhsItem) { + if lhsItem == rhsItem { + diffChildren( + lhsMirror, + rhsMirror, + prefix: "\(subjectType)(", + suffix: ")", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: false, + filter: macroPropertyFilter(for: lhs) + ) + } else { + diffEverything() + } + } else { + var occurrence: UInt { tracker.occurrencePerType[subjectType, default: 0] } + if tracker.visitedItems.contains(lhsItem) { + var lhsID: String { + let id = tracker.idPerItem[lhsItem, default: occurrence] + tracker.idPerItem[lhsItem] = id + return id > 0 ? "#\(id) " : "" + } + print( + "\(lhsName.map { "\($0): " } ?? "")\(lhsID)\(subjectType)(↩︎)" + .indenting(by: indent) + .indenting(with: format.first + " "), + to: &out + ) + } else { + print( + _customDump( + lhs, + name: lhsName, + indent: indent, + isRoot: isRoot, + maxDepth: .max, + tracker: &tracker + ) + .indenting(with: format.first + " "), + terminator: "", + to: &out + ) + } + if tracker.visitedItems.contains(rhsItem) { + var rhsID: String { + let id = tracker.idPerItem[rhsItem, default: occurrence] + tracker.idPerItem[rhsItem] = id + return id > 0 ? "#\(id) " : "" + } + print( + "\(rhsName.map { "\($0): " } ?? "")\(rhsID)\(subjectType)(↩︎)" + .indenting(by: indent) + .indenting(with: format.second + " "), + terminator: "", + to: &out + ) + } else { + print( + _customDump( + rhs, + name: rhsName, + indent: indent, + isRoot: isRoot, + maxDepth: .max, + tracker: &tracker + ) + .indenting(with: format.second + " "), + terminator: "", + to: &out + ) + } + } + + case (_, .collection?, _, .collection?): + diffChildren( + lhsMirror, + rhsMirror, + prefix: "[", + suffix: "]", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: true, + areEquivalent: { + isIdentityEqual($0.value, $1.value) || isMirrorEqual($0.value, $1.value) + }, + map: { $0.label = "[\($1)]" } + ) + + case (_, .dictionary?, _, .dictionary?): + diffChildren( + lhsMirror, + rhsMirror, + prefix: "[", + suffix: "]", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: true, + areEquivalent: { + guard + let lhs = $0.value as? (key: AnyHashable, value: Any), + let rhs = $1.value as? (key: AnyHashable, value: Any) + else { + return isMirrorEqual($0.value, $1.value) + } + return lhs.key == rhs.key + }, + areInIncreasingOrder: lhsMirror.subjectType is _UnorderedCollection.Type + ? { + let (lhsValue, rhsValue): (Any, Any) + if let lhs = $0.value as? (key: AnyHashable, value: Any), + let rhs = $1.value as? (key: AnyHashable, value: Any) + { + lhsValue = lhs.key.base + rhsValue = rhs.key.base + } else { + lhsValue = $0.value + rhsValue = $1.value + } + let lhsDump = _customDump( + lhsValue, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + let rhsDump = _customDump( + rhsValue, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + return lhsDump < rhsDump + } + : nil + ) { child, _ in + guard let pair = child.value as? (key: AnyHashable, value: Any) else { return } + child = ( + _customDump( + pair.key.base, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ), + pair.value + ) + } + + case (_, .enum?, _, .enum?): + guard + let lhsChild = lhsMirror.children.first, + let rhsChild = rhsMirror.children.first, + let caseName = lhsChild.label, + caseName == rhsChild.label + else { + diffEverything() + break + } + let lhsChildMirror = Mirror(customDumpReflecting: lhsChild.value) + let rhsChildMirror = Mirror(customDumpReflecting: rhsChild.value) + let lhsAssociatedValuesMirror = + lhsChildMirror.displayStyle == .tuple + ? lhsChildMirror + : Mirror(lhs, unlabeledChildren: [lhsChild.value], displayStyle: .tuple) + let rhsAssociatedValuesMirror = + rhsChildMirror.displayStyle == .tuple + ? rhsChildMirror + : Mirror(rhs, unlabeledChildren: [rhsChild.value], displayStyle: .tuple) + + let subjectType = isRoot ? customDumptypeName(lhsMirror.subjectType) : "" + diffChildren( + lhsAssociatedValuesMirror, + rhsAssociatedValuesMirror, + prefix: "\(subjectType).\(caseName)(", + suffix: ")", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: false, + map: { child, _ in + if child.label?.first == "." { + child.label = nil + } + } + ) + + case (_, .optional?, _, .optional?): + guard + let lhsValue = lhsMirror.children.first?.value, + let rhsValue = rhsMirror.children.first?.value + else { + diffEverything() + break + } + + out.write( + diffHelp( + lhsValue, + rhsValue, + lhsName: lhsName, + rhsName: rhsName, + separator: separator, + indent: indent, + isRoot: isRoot + ) + ) + + case (_, .set?, _, .set?): + diffChildren( + lhsMirror, + rhsMirror, + prefix: "Set([", + suffix: "])", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: true, + areEquivalent: { + isIdentityEqual($0.value, $1.value) || isMirrorEqual($0.value, $1.value) + }, + areInIncreasingOrder: lhsMirror.subjectType is _UnorderedCollection.Type + ? { + let lhsDump = _customDump( + $0.value, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + let rhsDump = _customDump( + $1.value, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + return lhsDump < rhsDump + } + : nil + ) + + case (_, .struct?, _, .struct?): + diffChildren( + lhsMirror, + rhsMirror, + prefix: "\(customDumptypeName(lhsMirror.subjectType))(", + suffix: ")", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: false, + filter: macroPropertyFilter(for: lhs) + ) + + case (_, .tuple?, _, .tuple?): + diffChildren( + lhsMirror, + rhsMirror, + prefix: "(", + suffix: ")", + elementIndent: 2, + elementSeparator: ",", + collapseUnchanged: false, + map: { child, _ in + if child.label?.first == "." { + child.label = nil + } + } + ) + + default: + if let lhs = String(stringProtocol: lhs), + let rhs = String(stringProtocol: rhs), + lhs.contains(where: \.isNewline) || rhs.contains(where: \.isNewline) + { + let lhsMirror = Mirror( + customDumpReflecting: + lhs.isEmpty + ? [] + : lhs + .split(separator: "\n", omittingEmptySubsequences: false) + .map(Line.init(rawValue:)) + ) + let rhsMirror = Mirror( + customDumpReflecting: + rhs.isEmpty + ? [] + : rhs + .split(separator: "\n", omittingEmptySubsequences: false) + .map(Line.init(rawValue:)) + ) + let hashes = String( + repeating: "#", + count: max(lhs.hashCount(isMultiline: true), rhs.hashCount(isMultiline: true)) + ) + diffChildren( + lhsMirror, + rhsMirror, + prefix: "\(hashes)\"\"\"", + suffix: rhsName != nil ? " \"\"\"\(hashes)" : "\"\"\"\(hashes)", + elementIndent: rhsName != nil ? 2 : 0, + elementSeparator: "", + collapseUnchanged: false, + areEquivalent: { + isIdentityEqual($0.value, $1.value) || isMirrorEqual($0.value, $1.value) + } + ) + } else { + diffEverything() + } + } + + return out + } + + guard !isMirrorEqual(lhs, rhs) else { return nil } + + var diff = diffHelp(lhs, rhs, lhsName: nil, rhsName: nil, separator: "", indent: 0, isRoot: true) + if diff.last == "\n" { diff.removeLast() } + return diff +} + +/// Describes how to format a difference between two values when using ``diff(_:_:format:)``. +/// +/// Typically one simply wants to use "-" to denote removals, "+" to denote additions, and " " for +/// spacing. However, in some contexts, such as in `XCTest` failures, messages are displayed in a +/// non-monospaced font. In those times the simple "-" and " " characters do not properly line up +/// visually, and so you need to use different characters that visually look similar to "-" and " " +/// but have the proper widths. +/// +/// This type comes with two pre-configured formats that you will probably want to use for most +/// situations: ``DiffFormat/default`` and ``DiffFormat/proportional``. +public struct DiffFormat: Sendable { + /// A string prepended to lines that only appear in the string representation of the first value, + /// e.g. a "removal." + public var first: String + + /// A string prepended to lines that only appear in the string representation of the second value, + /// e.g. an "insertion." + public var second: String + + /// A string prepended to lines that appear in the string representation of both values, e.g. + /// something "unchanged." + public var both: String + + public init( + first: String, + second: String, + both: String + ) { + self.first = first + self.second = second + self.both = both + } + + /// The default format for ``diff(_:_:format:)`` output, appropriate for where monospaced fonts + /// are used, e.g. console output. + /// + /// Uses ascii characters for removals (hyphen "-"), insertions (plus "+"), and unchanged (space + /// " "). + public static let `default` = Self(first: "-", second: "+", both: " ") + + /// A diff format appropriate for where proportional (non-monospaced) fonts are used, e.g. Xcode's + /// failure overlays. + /// + /// Uses ascii plus ("+") for insertions, unicode minus sign ("−") for removals, and unicode + /// figure space (" ") for unchanged. These three characters are more likely to render with equal + /// widths in proportional fonts. + public static let proportional = Self(first: "\u{2212}", second: "+", both: "\u{2007}") +} + +private struct Line: CustomDumpStringConvertible, Identifiable { + var rawValue: Substring + + var id: Substring { + self.rawValue + } + + var customDumpDescription: String { + .init(self.rawValue) + } +} + +public protocol _CustomDiffObject { + var _customDiffValues: (Any, Any) { get } + var _objectIdentifier: ObjectIdentifier { get } +} + +extension _CustomDiffObject where Self: AnyObject { + public var _objectIdentifier: ObjectIdentifier { + ObjectIdentifier(self) + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Dump.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Dump.swift new file mode 100644 index 00000000..2942e0a2 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Dump.swift @@ -0,0 +1,483 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// Dumps the given value's contents using its mirror to standard output. +/// +/// This function aims to dump the contents of a value into a nicely formatted, tree-like +/// description. It works with any value passed to it, and tries a few things to turn the value into +/// a string: +/// +/// 1. If the value conforms to ``CustomDumpStringConvertible``, then the string returned from +/// `customDumpDescription` is used immediately. +/// 2. If the value conforms to ``CustomDumpRepresentable``, then the value it returns from +/// `customDumpValue` is used for the dump instead. +/// 3. If the value conforms to ``CustomDumpReflectable``, the custom mirror returned from +/// `customDumpMirror` is used for the dump instead. +/// 4. Otherwise, the default mirror returned from `Mirror.init(reflecting:)` is used, which will +/// either come from the type's `CustomReflectable` conformance, or from the default mirror +/// representation of the value. +/// +/// - Parameters: +/// - value: The value to output to the `target` stream. +/// - name: A label to use when writing the contents of `value`. When `nil` is passed, the label +/// is omitted. The default is `nil`. +/// - indent: The number of spaces to use as an indent for each line of the output. The default is +/// `0`. +/// - maxDepth: The maximum depth to descend when writing the contents of a value that has nested +/// components. The default is `Int.max`. +/// - Returns: The instance passed as `value`. +@discardableResult +public func customDump( + _ value: T, + name: String? = nil, + indent: Int = 0, + maxDepth: Int = .max +) -> T { + var target = "" + let value = customDump(value, to: &target, name: name, indent: indent, maxDepth: maxDepth) + print(target) + return value +} + +extension String { + /// Creates a string dumping the given value. + public init(customDumping subject: Subject) { + var dump = "" + customDump(subject, to: &dump) + self = dump + } +} + +struct ObjectTracker { + var idPerItem: [ObjectIdentifier: UInt] = [:] + var occurrencePerType: [String: UInt] = [:] + var visitedItems: Set = [] +} + +/// Dumps the given value's contents using its mirror to the specified output stream. +/// +/// - Parameters: +/// - value: The value to output to the `target` stream. +/// - target: The stream to use for writing the contents of `value`. +/// - name: A label to use when writing the contents of `value`. When `nil` is passed, the label +/// is omitted. The default is `nil`. +/// - indent: The number of spaces to use as an indent for each line of the output. The default is +/// `0`. +/// - maxDepth: The maximum depth to descend when writing the contents of a value that has nested +/// components. The default is `Int.max`. +/// - Returns: The instance passed as `value`. +@discardableResult +public func customDump( + _ value: T, + to target: inout TargetStream, + name: String? = nil, + indent: Int = 0, + maxDepth: Int = .max +) -> T where TargetStream: TextOutputStream { + var tracker = ObjectTracker() + return _customDump( + value, + to: &target, + name: name, + indent: indent, + isRoot: true, + maxDepth: maxDepth, + tracker: &tracker + ) +} + +@discardableResult +func _customDump( + _ value: T, + to target: inout TargetStream, + name: String?, + nameSuffix: String = ":", + indent: Int, + isRoot: Bool, + maxDepth: Int, + tracker: inout ObjectTracker +) -> T where TargetStream: TextOutputStream { + func customDumpHelp( + _ value: InnerT, + to target: inout InnerTargetStream, + name: String?, + nameSuffix: String, + indent: Int, + isRoot: Bool, + maxDepth: Int + ) where InnerTargetStream: TextOutputStream { + if InnerT.self is AnyObject.Type, withUnsafeBytes(of: value, { $0.allSatisfy { $0 == 0 } }) { + target.write( + (name.map { "\($0)\(nameSuffix) " } ?? "") + .appending("(null pointer)") + .indenting(by: indent) + ) + return + } + + let mirror = Mirror(customDumpReflecting: value) + var out = "" + + func dumpChildren( + of mirror: Mirror, + prefix: String, + suffix: String, + shouldSort: Bool, + filter isIncluded: (Mirror.Child) -> Bool = { _ in true }, + by areInIncreasingOrder: (Mirror.Child, Mirror.Child) -> Bool = { _, _ in false }, + map transform: (inout Mirror.Child, Int) -> Void = { _, _ in } + ) { + out.write(prefix) + if !mirror.children.isEmpty { + if mirror.isSingleValueContainer { + var childOut = "" + let child = mirror.children.first! + customDumpHelp( + child.value, + to: &childOut, + name: child.label, + nameSuffix: ":", + indent: 0, + isRoot: false, + maxDepth: maxDepth - 1 + ) + if childOut.contains("\n") { + if maxDepth <= 0 { + out.write("…") + } else { + out.write("\n") + out.write(childOut.indenting(by: 2)) + out.write("\n") + } + } else { + out.write(childOut) + } + } else if maxDepth <= 0 { + out.write("…") + } else { + out.write("\n") + var children = Array(mirror.children) + children.removeAll(where: { !isIncluded($0) }) + if shouldSort { + children.sort(by: areInIncreasingOrder) + } + for (offset, var child) in children.enumerated() { + transform(&child, offset) + customDumpHelp( + child.value, + to: &out, + name: child.label, + nameSuffix: ":", + indent: 2, + isRoot: false, + maxDepth: maxDepth - 1 + ) + if offset != children.count - 1 { + out.write(",") + } + out.write("\n") + } + } + } + out.write(suffix) + } + + switch (value, mirror.displayStyle) { + case let (value as Any.Type, _): + out.write("\(customDumptypeName(value)).self") + + case let (value as CustomDumpStringConvertible, _): + out.write(value.customDumpDescription) + + case let (value as _CustomDiffObject, _): + let item = value._objectIdentifier + let (_, value) = value._customDiffValues + let subjectType = customDumptypeName(type(of: value)) + var occurrence = tracker.occurrencePerType[subjectType, default: 1] { + didSet { tracker.occurrencePerType[subjectType] = occurrence } + } + + var id: String { + let id = tracker.idPerItem[item, default: occurrence] + tracker.idPerItem[item] = id + + return id > 0 ? "#\(id)" : "" + } + if !id.isEmpty { + out.write("\(id) ") + } + if tracker.visitedItems.contains(item) { + out.write("\(subjectType)(↩︎)") + } else { + tracker.visitedItems.insert(item) + occurrence += 1 + customDumpHelp( + value, + to: &out, + name: nil, + nameSuffix: "", + indent: 0, + isRoot: false, + maxDepth: maxDepth + ) + } + + case let (value as CustomDumpRepresentable, _): + customDumpHelp( + value.customDumpValue, + to: &out, + name: nil, + nameSuffix: "", + indent: 0, + isRoot: false, + maxDepth: maxDepth + ) + + case let (value as AnyObject, .class?): + let item = ObjectIdentifier(value) + var occurrence = tracker.occurrencePerType[customDumptypeName(mirror.subjectType), default: 0] { + didSet { tracker.occurrencePerType[customDumptypeName(mirror.subjectType)] = occurrence } + } + + var id: String { + let id = tracker.idPerItem[item, default: occurrence] + tracker.idPerItem[item] = id + + return id > 0 ? "#\(id)" : "" + } + if !id.isEmpty { + out.write("\(id) ") + } + if tracker.visitedItems.contains(item) { + out.write("\(customDumptypeName(mirror.subjectType))(↩︎)") + } else { + tracker.visitedItems.insert(item) + occurrence += 1 + var children = Array(mirror.children) + + var superclassMirror = mirror.superclassMirror + while let mirror = superclassMirror { + children.insert(contentsOf: mirror.children, at: 0) + superclassMirror = mirror.superclassMirror + } + dumpChildren( + of: Mirror(value, children: children), + prefix: "\(customDumptypeName(mirror.subjectType))(", + suffix: ")", + shouldSort: false, + filter: macroPropertyFilter(for: value) + ) + } + + case (_, .collection?): + dumpChildren( + of: mirror, + prefix: "[", + suffix: "]", + shouldSort: false, + map: { + $0.label = "[\($1)]" + } + ) + + case (_, .dictionary?): + if mirror.children.isEmpty { + out.write("[:]") + } else { + dumpChildren( + of: mirror, + prefix: "[", suffix: "]", + shouldSort: mirror.subjectType is _UnorderedCollection.Type, + by: { + guard + let (lhsKey, _) = $0.value as? (key: AnyHashable, value: Any), + let (rhsKey, _) = $1.value as? (key: AnyHashable, value: Any) + else { return false } + + let lhsDump = _customDump( + lhsKey.base, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + let rhsDump = _customDump( + rhsKey.base, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + return lhsDump < rhsDump + }, + map: { child, _ in + guard let pair = child.value as? (key: AnyHashable, value: Any) else { return } + let key = _customDump( + pair.key.base, + name: nil, + indent: 0, + isRoot: false, + maxDepth: maxDepth - 1, + tracker: &tracker + ) + child = (key, pair.value) + } + ) + } + + case (_, .enum?): + out.write(isRoot ? "\(customDumptypeName(mirror.subjectType))." : ".") + if let child = mirror.children.first { + let childMirror = Mirror(customDumpReflecting: child.value) + let associatedValuesMirror = + childMirror.displayStyle == .tuple + ? childMirror + : Mirror(value, unlabeledChildren: [child.value], displayStyle: .tuple) + dumpChildren( + of: associatedValuesMirror, + prefix: "\(child.label ?? "@unknown")(", + suffix: ")", + shouldSort: false, + map: { child, _ in + if child.label?.first == "." { + child.label = nil + } + } + ) + } else { + out.write("\(value)") + } + + case (_, .optional?): + if let value = mirror.children.first?.value { + customDumpHelp( + value, + to: &out, + name: nil, + nameSuffix: "", + indent: 0, + isRoot: false, + maxDepth: maxDepth + ) + } else { + out.write("nil") + } + + case (_, .set?): + dumpChildren( + of: mirror, + prefix: "Set([", suffix: "])", + shouldSort: mirror.subjectType is _UnorderedCollection.Type, + by: { + let lhs = _customDump( + $0.value, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + let rhs = _customDump( + $1.value, + name: nil, + indent: 0, + isRoot: false, + maxDepth: 1, + tracker: &tracker + ) + return lhs < rhs + } + ) + + case (_, .struct?): + dumpChildren( + of: mirror, + prefix: "\(customDumptypeName(mirror.subjectType))(", + suffix: ")", + shouldSort: false, + filter: macroPropertyFilter(for: value) + ) + + case (_, .tuple?): + dumpChildren( + of: mirror, + prefix: "(", + suffix: ")", + shouldSort: false, + map: { child, _ in + if child.label?.first == "." { + child.label = nil + } + } + ) + + default: + if let value = String(stringProtocol: value) { + if value.contains(where: \.isNewline) { + if maxDepth <= 0 { + out.write("\"…\"") + } else { + let hashes = String(repeating: "#", count: value.hashCount(isMultiline: true)) + out.write("\(hashes)\"\"\"") + out.write("\n") + print(value.indenting(by: name != nil ? 2 : 0), to: &out) + out.write(name != nil ? " \"\"\"\(hashes)" : "\"\"\"\(hashes)") + } + } else if value.contains("\"") || value.contains("\\") { + let hashes = String(repeating: "#", count: max(value.hashCount(isMultiline: false), 1)) + out.write("\(hashes)\"\(value)\"\(hashes)") + } else { + out.write(value.debugDescription) + } + } else { + out.write("\(value)") + } + } + + target.write((name.map { "\($0)\(nameSuffix) " } ?? "").appending(out).indenting(by: indent)) + } + + customDumpHelp( + value, + to: &target, + name: name, + nameSuffix: nameSuffix, + indent: indent, + isRoot: isRoot, + maxDepth: maxDepth + ) + return value +} + +func _customDump( + _ value: Any, + name: String?, + nameSuffix: String = ":", + indent: Int, + isRoot: Bool, + maxDepth: Int, + tracker: inout ObjectTracker +) -> String { + var out = "" + var t = tracker + defer { tracker = t } + _customDump( + value, + to: &out, + name: name, + nameSuffix: nameSuffix, + indent: indent, + isRoot: isRoot, + maxDepth: maxDepth, + tracker: &t + ) + return out +} + +func macroPropertyFilter(for value: Any) -> (Mirror.Child) -> Bool { + value is CustomDumpReflectable + ? { _ in true } + : { $0.label.map { !$0.hasPrefix("_$") } ?? true } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/ExpectDifference.swift b/Sources/KlaviyoSDKDependencies/CustomDump/ExpectDifference.swift new file mode 100644 index 00000000..9930daae --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/ExpectDifference.swift @@ -0,0 +1,165 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// Expects that a value has a set of changes. +/// +/// This function evaluates a given expression before and after a given operation and then compares +/// the results. The comparison is done by invoking the `changes` closure with a mutable version of +/// the initial value, and then asserting that the modifications made match the final value using +/// ``expectNoDifference``. +/// +/// For example, given a very simple counter structure, we can write a test against its incrementing +/// functionality: +/// ` +/// ```swift +/// struct Counter { +/// var count = 0 +/// var isOdd = false +/// mutating func increment() { +/// self.count += 1 +/// self.isOdd.toggle() +/// } +/// } +/// +/// var counter = Counter() +/// expectDifference(counter) { +/// counter.increment() +/// } changes: { +/// $0.count = 1 +/// $0.isOdd = true +/// } +/// ``` +/// +/// If the `changes` does not exhaustively describe all changed fields, the assertion will fail. +/// +/// By omitting the operation you can write a "non-exhaustive" assertion against a value by +/// describing just the fields you want to assert against in the `changes` closure: +/// +/// ```swift +/// counter.increment() +/// expectDifference(counter) { +/// $0.count = 1 +/// // Don't need to further describe how `isOdd` has changed +/// } +/// ``` +/// +/// - Parameters: +/// - expression: An expression that is evaluated before and after `operation`, and then compared. +/// - message: An optional description of a failure. +/// - operation: An optional operation that is performed in between an initial and final +/// evaluation of `operation`. By omitting this operation, you can write a "non-exhaustive" +/// assertion against an already-changed value by describing just the fields you want to assert +/// against in the `changes` closure. +/// - updateExpectingResult: A closure that asserts how the expression changed by supplying a +/// mutable version of the initial value. This value must be modified to match the final value. +public func expectDifference( + _ expression: @autoclosure () throws -> T, + _ message: @autoclosure () -> String? = nil, + operation: () throws -> Void = {}, + changes updateExpectingResult: (inout T) throws -> Void, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + do { + var expression1 = try expression() + try updateExpectingResult(&expression1) + try operation() + let expression2 = try expression() + guard expression1 != expression2 else { return } + let format = DiffFormat.proportional + guard let difference = diff(expression1, expression2, format: format) + else { + reportIssue( + """ + ("\(expression1)" is not equal to ("\(expression2)"), but no difference was detected. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + reportIssue( + """ + \(message()?.appending(" - ") ?? "")Difference: … + + \(difference.indenting(by: 2)) + + (Expected: \(format.first), Actual: \(format.second)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } catch { + reportIssue( + error, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +} + +/// Expect that two values have no difference. +/// +/// An async version of +/// ``expectDifference(_:_:operation:changes:fileID:filePath:line:column:)-5fu8q``. +public func expectDifference( + _ expression: @autoclosure @Sendable () throws -> T, + _ message: @autoclosure @Sendable () -> String? = nil, + operation: @Sendable () async throws -> Void = {}, + changes updateExpectingResult: @Sendable (inout T) throws -> Void, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) async { + do { + var expression1 = try expression() + try updateExpectingResult(&expression1) + try await operation() + let expression2 = try expression() + guard expression1 != expression2 else { return } + let format = DiffFormat.proportional + guard let difference = diff(expression1, expression2, format: format) + else { + reportIssue( + """ + ("\(expression1)" is not equal to ("\(expression2)"), but no difference was detected. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + reportIssue( + """ + \(message()?.appending(" - ") ?? "")Difference: … + + \(difference.indenting(by: 2)) + + (Expected: \(format.first), Actual: \(format.second)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } catch { + reportIssue( + error, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/ExpectNoDifference.swift b/Sources/KlaviyoSDKDependencies/CustomDump/ExpectNoDifference.swift new file mode 100644 index 00000000..dc6f0b2e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/ExpectNoDifference.swift @@ -0,0 +1,97 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +/// Asserts that two values have no difference. +/// +/// Similar to `XCTAssertEqual`, but that function uses either `TextOutputStreamable`, +/// `CustomStringConvertible` or `CustomDebugStringConvertible` in order to display a failure +/// message: +/// +/// ```swift +/// XCTAssertEqual(user1, user2) +/// ``` +/// ```text +/// XCTAssertEqual failed: ("User(id: 42, name: "Blob")") is not equal to ("User(id: 42, name: "Blob, Esq.")") +/// ``` +/// +/// `XCTAssertNoDifference` uses the output of ``diff(_:_:format:)`` to display a failure message, +/// which helps highlight the differences between the given values: +/// +/// ```swift +/// XCTAssertNoDifference(user1, user2) +/// ``` +/// ```text +/// XCTAssertNoDifference failed: … +/// +/// User( +/// id: 42, +/// - name: "Blob" +/// + name: "Blob, Esq." +/// ) +/// +/// (First: -, Second: +) +/// ``` +/// +/// - Parameters: +/// - expression1: An expression of type `T`, where `T` is `Equatable`. +/// - expression2: A second expression of type `T`, where `T` is `Equatable`. +/// - message: An optional description of a failure. +/// - fileID: The file where the failure occurs. The default is the file ID of the test case where +/// you call this function. +/// - filePath: The file where the failure occurs. The default is the file path of the test case +/// where you call this function. +/// - line: The line number where the failure occurs. The default is the line number where you +/// call this function. +/// - line: The column where the failure occurs. The default is the column where you call this +/// function. +public func expectNoDifference( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + do { + let expression1 = try expression1() + let expression2 = try expression2() + let message = message() + guard expression1 != expression2 else { return } + let format = DiffFormat.proportional + guard let difference = diff(expression1, expression2, format: format) + else { + reportIssue( + """ + ("\(expression1)" is not equal to ("\(expression2)"), but no difference was detected. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return + } + reportIssue( + """ + \(message?.appending(" - ") ?? "")Difference: … + + \(difference.indenting(by: 2)) + + (First: \(format.first), Second: \(format.second)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } catch { + reportIssue( + error, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Internal/AnyType.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/AnyType.swift new file mode 100644 index 00000000..de61e6eb --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/AnyType.swift @@ -0,0 +1,50 @@ +/// Adapted from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 +/// Comments - renamed to avoid collision with case paths type name. + +func customDumptypeName( + _ type: Any.Type, + qualified: Bool = true, + genericsAbbreviated: Bool = true +) -> String { + var name = _typeName(type, qualified: qualified) + .replacingOccurrences( + of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, + with: "", + options: .regularExpression + ) + for _ in 1...10 { // NB: Only handle so much nesting + let abbreviated = + name + .replacingOccurrences( + of: #"\bSwift.Optional<([^><]+)>"#, + with: "$1?", + options: .regularExpression + ) + .replacingOccurrences( + of: #"\bSwift.Array<([^><]+)>"#, + with: "[$1]", + options: .regularExpression + ) + .replacingOccurrences( + of: #"\bSwift.Dictionary<([^,<]+), ([^><]+)>"#, + with: "[$1: $2]", + options: .regularExpression + ) + if abbreviated == name { break } + name = abbreviated + } + name = name.replacingOccurrences( + of: #"\w+\.([\w.]+)"#, + with: "$1", + options: .regularExpression + ) + if genericsAbbreviated { + name = name.replacingOccurrences( + of: #"<.+>"#, + with: "", + options: .regularExpression + ) + } + return name +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Internal/CollectionDifference.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/CollectionDifference.swift new file mode 100644 index 00000000..d369c413 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/CollectionDifference.swift @@ -0,0 +1,11 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +extension CollectionDifference.Change { + var offset: Int { + switch self { + case let .insert(offset, _, _), let .remove(offset, _, _): + return offset + } + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Identifiable.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Identifiable.swift new file mode 100644 index 00000000..4bfbe798 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Identifiable.swift @@ -0,0 +1,11 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +func isIdentityEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard let lhs = lhs as? any Identifiable else { return false } + func open(_ lhs: LHS) -> Bool { + guard let rhs = rhs as? LHS else { return false } + return lhs.id == rhs.id + } + return open(lhs) +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Mirror.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Mirror.swift new file mode 100644 index 00000000..649240c0 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Mirror.swift @@ -0,0 +1,57 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +extension Mirror { + var isSingleValueContainer: Bool { + switch self.displayStyle { + case .collection?, .dictionary?, .set?: + return false + default: + guard + self.children.count == 1, + let child = self.children.first + else { return false } + var value = child.value + if value is _CustomDiffObject { + return false + } + while let representable = value as? CustomDumpRepresentable { + value = representable.customDumpValue + if value is _CustomDiffObject { + return false + } + } + if let convertible = child.value as? CustomDumpStringConvertible { + return !convertible.customDumpDescription.contains("\n") + } + return Mirror(customDumpReflecting: value).children.isEmpty + } + } +} + +func isMirrorEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard let lhs = lhs as? any Equatable else { + let lhsMirror = Mirror(customDumpReflecting: lhs) + let rhsMirror = Mirror(customDumpReflecting: rhs) + guard + lhsMirror.subjectType == rhsMirror.subjectType, + lhsMirror.children.count == rhsMirror.children.count + else { return false } + guard !lhsMirror.children.isEmpty, !rhsMirror.children.isEmpty + else { + return String(describing: lhs) == String(describing: rhs) + } + for (lhsChild, rhsChild) in zip(lhsMirror.children, rhsMirror.children) { + guard + lhsChild.label == rhsChild.label, + isMirrorEqual(lhsChild.value, rhsChild.value) + else { return false } + } + return true + } + func open(_ lhs: T) -> Bool { + guard let rhs = rhs as? T else { return false } + return lhs == rhs + } + return open(lhs) +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Internal/String.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/String.swift new file mode 100644 index 00000000..61f1b1db --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/String.swift @@ -0,0 +1,33 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +import Foundation + +extension String { + init?(stringProtocol value: Any) { + guard let value = value as? any StringProtocol else { return nil } + self.init(value) + } + + func indenting(by count: Int) -> String { + self.indenting(with: String(repeating: " ", count: count)) + } + + func indenting(with prefix: String) -> String { + guard !prefix.isEmpty else { return self } + return "\(prefix)\(self.replacingOccurrences(of: "\n", with: "\n\(prefix)"))" + } + + func hashCount(isMultiline: Bool) -> Int { + let (quote, offset) = isMultiline ? ("\"\"\"", 2) : ("\"", 0) + var substring = self[...] + var hashCount = 0 + let pattern = "(\(quote)[#]*)" + while let range = substring.range(of: pattern, options: .regularExpression) { + let count = substring.distance(from: range.lowerBound, to: range.upperBound) - offset + hashCount = max(count, hashCount) + substring = substring[range.upperBound...] + } + return hashCount + } +} diff --git a/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Unordered.swift b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Unordered.swift new file mode 100644 index 00000000..9c3ef032 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/CustomDump/Internal/Unordered.swift @@ -0,0 +1,10 @@ +/// Copied verbatim from swift-case-paths v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + +import Foundation + +public protocol _UnorderedCollection {} +extension Dictionary: _UnorderedCollection {} +extension NSDictionary: _UnorderedCollection {} +extension NSSet: _UnorderedCollection {} +extension Set: _UnorderedCollection {} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/AppHostWarning.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/AppHostWarning.swift new file mode 100644 index 00000000..ee939b20 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/AppHostWarning.swift @@ -0,0 +1,82 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +import Foundation + +extension String? { + @usableFromInline + func withAppHostWarningIfNeeded() -> String? { + guard let self + else { + let warning = "".withAppHostWarningIfNeeded() + return warning.isEmpty ? nil : warning + } + return self.withAppHostWarningIfNeeded() + } +} + +extension String { + @usableFromInline + func withAppHostWarningIfNeeded() -> String { + #if os(WASI) + return self + #else + guard + isTesting, + Bundle.main.bundleIdentifier != "com.apple.dt.xctest.tool" + else { return self } + + let callStack = Thread.callStackSymbols + guard + callStack.allSatisfy({ !$0.contains(" XCTestCore ") }), + callStack.allSatisfy({ !$0.isTestFrame }) + else { return self } + + let warning = """ + This issue was emitted from tests running in a host application\ + \(Bundle.main.bundleIdentifier.map { " (\($0))" } ?? ""). + + This can lead to false positives, where failures could have emitted from live application \ + code at launch time, and not from the current test. + + For more information (and workarounds), see "Testing gotchas": + + https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/testing#Testing-gotchas + """ + + return isEmpty + ? warning + : """ + \(self) + + ━━┉┅ + \(warning) + """ + #endif + } + + #if !os(WASI) + @usableFromInline + var isTestFrame: Bool { + guard let xcTestCase = NSClassFromString("XCTestCase") + else { return false } + + // Regular expression to detect and demangle an XCTest case frame: + // + // 1. `(?<=\$s)`: Starts with "$s" (stable mangling) + // 2. `\d{1,3}`: Some numbers (the class name length or the module name length) + // 3. `.*`: The class name, or module name + class name length + class name + // 4. `C`: The class type identifier + // 5. `(?=\d{1,3}test.*yy(Ya)?K?F)`: The function name length, a function that starts with + // `test`, has no arguments (`y`), returns Void (`y`), and is a function (`F`), + // potentially async (`Ya`), throwing (`K`), or both. + return range( + of: #"(?<=\$s)\d{1,3}.*C(?=\d{1,3}test.*yy(Ya)?K?F)"#, options: .regularExpression + ) + .map { + (_typeByName(String(self[$0])) as? NSObject.Type)?.isSubclass(of: xcTestCase) ?? false + } + ?? false + } + #endif +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Deprecations.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Deprecations.swift new file mode 100644 index 00000000..e154b18e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Deprecations.swift @@ -0,0 +1,16 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + + +// NB: Deprecated after 1.2.2 + +#if canImport(Darwin) + @available(*, unavailable, renamed: "_BreakpointReporter") + public typealias BreakpointReporter = _BreakpointReporter +#endif + +@available(*, unavailable, renamed: "_FatalErrorReporter") +public typealias FatalErrorReporter = _FatalErrorReporter + +@available(*, unavailable, renamed: "_RuntimeWarningReporter") +public typealias RuntimeWarningReporter = _RuntimeWarningReporter diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/FailureObserver.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/FailureObserver.swift new file mode 100644 index 00000000..bb6c1c4c --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/FailureObserver.swift @@ -0,0 +1,24 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +import Foundation + +@usableFromInline +final class FailureObserver: @unchecked Sendable { + @TaskLocal public static var current: FailureObserver? + + private let lock = NSRecursiveLock() + private var count = 0 + + @usableFromInline + init(count: Int = 0) { + self.count = count + } + + @usableFromInline + func withLock(_ body: (inout Int) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return body(&count) + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingLockIsolated.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingLockIsolated.swift new file mode 100644 index 00000000..f9a3f71e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingLockIsolated.swift @@ -0,0 +1,25 @@ +/// Adapted from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 +/// Comments - Modified to avoid collision + +import Foundation + +@usableFromInline +final class IssueReportingLockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + @usableFromInline + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + @usableFromInline + func withLock( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + lock.lock() + defer { lock.unlock() } + var value = _value + defer { _value = value } + return try operation(&value) + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingUncheckedSendable.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingUncheckedSendable.swift new file mode 100644 index 00000000..bd15e74f --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/IssueReportingUncheckedSendable.swift @@ -0,0 +1,13 @@ +/// Adapted from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 +/// Comments - Modified to avoid collision + +@propertyWrapper +@usableFromInline +struct IssueReportingUncheckedSendable: @unchecked Sendable { + @usableFromInline + var wrappedValue: Value + init(wrappedValue value: Value) { + self.wrappedValue = value + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Rethrows.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Rethrows.swift new file mode 100644 index 00000000..404a7275 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Rethrows.swift @@ -0,0 +1,20 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +@rethrows +@usableFromInline +protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} +extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + @usableFromInline + func _rethrowGet() rethrows -> Output { + return try get() + } +} +extension Result: _ErrorMechanism {} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/SwiftTesting.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/SwiftTesting.swift new file mode 100644 index 00000000..de2c9067 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/SwiftTesting.swift @@ -0,0 +1,504 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +import Foundation + + +#if canImport(WinSDK) + import WinSDK +#endif + +@usableFromInline +func _recordIssue( + message: String?, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column +) { + guard let function = function(for: "$s25IssueReportingTestSupport07_recordA0ypyF") + else { + #if DEBUG + guard + let record = unsafeBitCast( + symbol: "$s7Testing5IssueV6record_14sourceLocationAcA7CommentVSg_AA06SourceE0VtFZ", + in: "Testing", + to: (@convention(thin) (Any?, SourceLocation) -> Issue).self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + _ = record( + comment, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column) + ) + #else + printError( + """ + \(fileID):\(line): An issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let recordIssue = function as! @Sendable (String?, String, String, Int, Int) -> Void + recordIssue(message, fileID, filePath, line, column) +} + +@usableFromInline +func _recordError( + error: any Error, + message: String?, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column +) { + guard let function = function(for: "$s25IssueReportingTestSupport12_recordErrorypyF") + else { + #if DEBUG + guard + let record = unsafeBitCast( + symbol: """ + $s7Testing5IssueV6record__14sourceLocationACs5Error_p_AA7CommentVSgAA06SourceE0VtFZ + """, + in: "Testing", + to: (@convention(thin) (any Error, Any?, SourceLocation) -> Issue).self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + _ = record( + error, + comment, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column) + ) + #else + printError( + """ + \(fileID):\(line): An issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let recordError = function as! @Sendable (any Error, String?, String, String, Int, Int) -> Void + recordError(error, message, fileID, filePath, line, column) +} + +@usableFromInline +func _withKnownIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column, + _ body: () throws -> Void +) { + guard let function = function(for: "$s25IssueReportingTestSupport010_withKnownA0ypyF") + else { + #if DEBUG + guard + let withKnownIssue = unsafeBitCast( + symbol: """ + $s7Testing14withKnownIssue_14isIntermittent14sourceLocation_yAA7CommentVSg_SbAA06Source\ + H0VyyKXEtF + """, + in: "Testing", + to: (@convention(thin) ( + Any?, + Bool, + SourceLocation, + () throws -> Void + ) -> Void) + .self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + withKnownIssue( + comment, + isIntermittent, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column), + body + ) + #else + printError( + """ + \(fileID):\(line): A known issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let withKnownIssue = + function + as! @Sendable ( + String?, + Bool, + String, + String, + Int, + Int, + () throws -> Void + ) -> Void + withKnownIssue(message, isIntermittent, fileID, filePath, line, column, body) +} + +@usableFromInline +func _withKnownIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column, + _ body: () async throws -> Void +) async { + guard let function = function(for: "$s25IssueReportingTestSupport010_withKnownA5AsyncypyF") + else { + #if DEBUG + guard + let withKnownIssue = unsafeBitCast( + symbol: """ + $s7Testing14withKnownIssue_14isIntermittent14sourceLocation_yAA7CommentVSg_SbAA06Source\ + H0VyyYaKXEtYaFTu + """, + in: "Testing", + to: (@convention(thin) ( + Any?, + Bool, + SourceLocation, + () async throws -> Void + ) async -> Void) + .self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + await withKnownIssue( + comment, + isIntermittent, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column), + body + ) + #else + printError( + """ + \(fileID):\(line): A known issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let withKnownIssue = + function + as! @Sendable ( + String?, + Bool, + String, + String, + Int, + Int, + () async throws -> Void + ) async -> Void + await withKnownIssue(message, isIntermittent, fileID, filePath, line, column, body) +} +@usableFromInline +func _currentTestID() -> AnyHashable? { + guard let function = function(for: "$s25IssueReportingTestSupport08_currentC2IDypyF") + else { + #if DEBUG + return Test.current?.id + #else + return nil + #endif + } + + return (function as! @Sendable () -> AnyHashable?)() +} + +#if DEBUG + #if _runtime(_ObjC) + import ObjectiveC + + private typealias __XCTestCompatibleSelector = Selector + #else + private typealias __XCTestCompatibleSelector = Never + #endif + + private struct __Expression: Sendable { + enum Kind: Sendable { + case generic(_ sourceCode: String) + case stringLiteral(sourceCode: String, stringValue: String) + indirect case binaryOperation(lhs: __Expression, `operator`: String, rhs: __Expression) + struct FunctionCallArgument: Sendable { + var label: String? + var value: __Expression + } + indirect case functionCall( + value: __Expression?, functionName: String, arguments: [FunctionCallArgument] + ) + indirect case propertyAccess(value: __Expression, keyPath: __Expression) + indirect case negation(_ expression: __Expression, isParenthetical: Bool) + } + var kind: Kind + struct Value: Sendable { + var description: String + var debugDescription: String + var typeInfo: TypeInfo + var label: String? + var isCollection: Bool + var children: [Self]? + } + var runtimeValue: Value? + } + + private struct Backtrace: Sendable { + typealias Address = UInt64 + var addresses: [Address] + } + + private struct Comment: RawRepresentable, Sendable { + var rawValue: String + init(rawValue: String) { + self.rawValue = rawValue + self.kind = nil + } + enum Kind: Sendable { + case line + case block + case documentationLine + case documentationBlock + case trait + case stringLiteral + } + var kind: Kind? + } + + private struct Confirmation: Sendable { + } + private protocol ExpectedCount: Sendable, RangeExpression {} + + private struct Expectation: Sendable { + var evaluatedExpression: __Expression + var mismatchedErrorDescription: String? + var differenceDescription: String? + var mismatchedExitConditionDescription: String? + var isPassing: Bool + var isRequired: Bool + var sourceLocation: SourceLocation + } + + private struct Issue: Sendable { + enum Kind: Sendable { + case unconditional + indirect case expectationFailed(_ expectation: Expectation) + indirect case confirmationMiscounted(actual: Int, expected: Int) + indirect case confirmationOutOfRange(actual: Int, expected: any ExpectedCount) + indirect case errorCaught(_ error: any Error) + indirect case timeLimitExceeded(timeLimitComponents: (seconds: Int64, attoseconds: Int64)) + case knownIssueNotRecorded + case apiMisused + case system + } + var kind: Kind + var comments: [Comment] + var sourceContext: SourceContext + } + + private struct SourceContext: Sendable { + var backtrace: Backtrace? + var sourceLocation: SourceLocation? + } + + private struct SourceLocation: Hashable, Sendable { + var fileID: String + var _filePath: String + var line: Int + var column: Int + var moduleName: String { + let firstSlash = fileID.firstIndex(of: "/")! + return String(fileID[.. Test?).self + ) + else { return nil } + return current() + } + + struct Case {} + private var name: String + private var displayName: String? + private var traits: [any Trait] + private var sourceLocation: SourceLocation + private var containingTypeInfo: TypeInfo? + private var xcTestCompatibleSelector: __XCTestCompatibleSelector? + fileprivate enum TestCasesState: @unchecked Sendable { + case unevaluated(_ function: @Sendable () async throws -> AnySequence) + case evaluated(_ testCases: AnySequence) + case failed(_ error: any Error) + } + fileprivate var testCasesState: TestCasesState? + private var parameters: [Parameter]? + private struct Parameter: Sendable { + var index: Int + var firstName: String + var secondName: String? + var typeInfo: TypeInfo + } + private var isSynthesized = false + + private var isSuite: Bool { + containingTypeInfo != nil && testCasesState == nil + } + fileprivate var id: ID { + var result = + containingTypeInfo.map(ID.init) + ?? ID(moduleName: sourceLocation.moduleName, nameComponents: [], sourceLocation: nil) + + if !isSuite { + result.nameComponents.append(name) + result.sourceLocation = sourceLocation + } + + return result + } + fileprivate struct ID: Hashable { + var moduleName: String + var nameComponents: [String] + var sourceLocation: SourceLocation? + init(moduleName: String, nameComponents: [String], sourceLocation: SourceLocation?) { + self.moduleName = moduleName + self.nameComponents = nameComponents + self.sourceLocation = sourceLocation + } + init(_ fullyQualifiedNameComponents: some Collection) { + moduleName = fullyQualifiedNameComponents.first ?? "" + if fullyQualifiedNameComponents.count > 0 { + nameComponents = Array(fullyQualifiedNameComponents.dropFirst()) + } else { + nameComponents = [] + } + } + init(typeInfo: TypeInfo) { + self.init(typeInfo.fullyQualifiedNameComponents) + } + } + } + + private protocol Trait: Sendable {} + + private struct TypeInfo: Sendable { + enum _Kind: Sendable { + case type(_ type: Any.Type) + case nameOnly(fullyQualifiedComponents: [String], unqualified: String, mangled: String?) + } + var _kind: _Kind + + static let _fullyQualifiedNameComponentsCache: + IssueReportingLockIsolated< + [ObjectIdentifier: [String]] + > = IssueReportingLockIsolated([:]) + var fullyQualifiedNameComponents: [String] { + switch _kind { + case let .type(type): + if let cachedResult = Self + ._fullyQualifiedNameComponentsCache.withLock({ $0[ObjectIdentifier(type)] }) + { + return cachedResult + } + var result = String(reflecting: type) + .split(separator: ".") + .map(String.init) + if let firstComponent = result.first, firstComponent.starts(with: "(extension in ") { + result[0] = String(firstComponent.split(separator: ":", maxSplits: 1).last!) + } + result = result.filter { !$0.starts(with: "(unknown context at") } + Self._fullyQualifiedNameComponentsCache.withLock { [result] in + $0[ObjectIdentifier(type)] = result + } + return result + + case let .nameOnly(fullyQualifiedComponents, _, _): + return fullyQualifiedComponents + } + } + } +#endif + +@usableFromInline +func function(for symbol: String) -> Any? { + let function = unsafeBitCast( + symbol: symbol, + in: "IssueReportingTestSupport", + to: (@convention(thin) () -> Any).self + ) + return function?() +} + +@usableFromInline +func unsafeBitCast(symbol: String, in library: String, to function: F.Type) -> F? { + #if os(Linux) + guard + let handle = dlopen("lib\(library).so", RTLD_LAZY), + let pointer = dlsym(handle, symbol) + else { return nil } + return unsafeBitCast(pointer, to: F.self) + #elseif canImport(Darwin) + guard + let handle = dlopen(nil, RTLD_LAZY), + let pointer = dlsym(handle, symbol) + else { return nil } + return unsafeBitCast(pointer, to: F.self) + #elseif os(Windows) + guard + let handle = LoadLibraryA("\(library).dll"), + let pointer = GetProcAddress(handle, symbol) + else { return nil } + return unsafeBitCast(pointer, to: F.self) + #else + return nil + #endif +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Warn.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Warn.swift new file mode 100644 index 00000000..95ab3520 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/Warn.swift @@ -0,0 +1,17 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +#if os(Linux) + @preconcurrency import Foundation +#else + import Foundation +#endif + +#if canImport(WinSDK) + import WinSDK +#endif + +@usableFromInline +func printError(_ message: String) { + fputs("\(message)\n", stderr) +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/XCTest.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/XCTest.swift new file mode 100644 index 00000000..85a3726e --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Internal/XCTest.swift @@ -0,0 +1,129 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +#if _runtime(_ObjC) + import Foundation +#endif + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(WinSDK) + import WinSDK +#endif + +@usableFromInline +func _XCTFail( + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line +) { + #if !_runtime(_ObjC) + guard !_XCTExpectedFailure.isInFailingBlock else { return } + #endif + guard let function = function(for: "$s25IssueReportingTestSupport8_XCTFailypyF") + else { + #if DEBUG + if let XCTFail = unsafeBitCast( + symbol: "$s6XCTest7XCTFail_4file4lineySS_s12StaticStringVSutF", + in: "XCTest", + to: (@convention(thin) (String, StaticString, UInt) -> Void).self + ) { + XCTFail(message, file, line) + return + } + #endif + printError( + """ + \(file):\(line): A failure was recorded without linking the XCTest framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + return + } + let XCTFail = function as! @Sendable (String, StaticString, UInt) -> Void + XCTFail(message, file, line) +} + +@_transparent +@usableFromInline +func _XCTExpectFailure( + _ failureReason: String? = nil, + enabled: Bool? = nil, + strict: Bool? = nil, + file: StaticString, + line: UInt, + failingBlock: () throws -> R +) rethrows -> R { + #if _runtime(_ObjC) + guard let function = function(for: "$s25IssueReportingTestSupport17_XCTExpectFailureypyF") + else { + #if DEBUG + guard enabled != false + else { return try failingBlock() } + if let pointer = dlsym(dlopen(nil, RTLD_NOW), "XCTExpectFailureWithOptionsInBlock"), + let XCTExpectedFailureOptions = NSClassFromString("XCTExpectedFailureOptions") + as Any as? NSObjectProtocol, + let options = strict ?? true + ? XCTExpectedFailureOptions + .perform(NSSelectorFromString("alloc"))?.takeUnretainedValue() + .perform(NSSelectorFromString("init"))?.takeUnretainedValue() + : XCTExpectedFailureOptions + .perform(NSSelectorFromString("nonStrictOptions"))?.takeUnretainedValue() + { + let XCTExpectFailureInBlock = unsafeBitCast( + pointer, + to: (@convention(c) (String?, AnyObject, () -> Void) -> Void).self + ) + var result: Result? + XCTExpectFailureInBlock(failureReason, options) { + result = Result { try failingBlock() } + } + return try result!._rethrowGet() + } + #else + printError( + """ + \(file):\(line): An expected failure was recorded without linking the XCTest framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return try failingBlock() + } + let XCTExpectFailure = + function + as! @Sendable (String?, Bool?, Bool?, () throws -> Void) throws -> Void + var result: Result! + do { + try XCTExpectFailure(failureReason, enabled, strict) { + result = Result { try failingBlock() } + } + } catch { + fatalError() + } + return try result._rethrowGet() + #else + _XCTFail( + """ + Expecting failures is unavailable in XCTest on this platform. + + Omit this test from your suite by wrapping it in '#if canImport(Darwin)', or consider using \ + Swift Testing and 'withKnownIssue', instead. + """ + ) + return try _XCTExpectedFailure.$isInFailingBlock.withValue(true) { + try failingBlock() + } + #endif +} + +#if !_runtime(_ObjC) + @usableFromInline + enum _XCTExpectedFailure { + @TaskLocal public static var isInFailingBlock = false + } +#endif diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IsTesting.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IsTesting.swift new file mode 100644 index 00000000..fdb03054 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IsTesting.swift @@ -0,0 +1,46 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +#if os(WASI) + public let isTesting = false +#else + import Foundation + + /// Whether or not the current process is running tests. + /// + /// You can use this information to prevent application code from running when hosting tests. For + /// example, you can wrap your app entry point: + /// + /// ```swift + /// import IssueReporting + /// + /// @main + /// struct MyApp: App { + /// var body: some Scene { + /// WindowGroup { + /// if !isTesting { + /// MyRootView() + /// } + /// } + /// } + /// } + /// + /// To detect if the current task is running inside a test, use ``TestContext/current``, instead. + public let isTesting = ProcessInfo.processInfo.isTesting + + extension ProcessInfo { + fileprivate var isTesting: Bool { + if environment.keys.contains("XCTestBundlePath") { return true } + if environment.keys.contains("XCTestConfigurationFilePath") { return true } + if environment.keys.contains("XCTestSessionIdentifier") { return true } + + return arguments.contains { argument in + let path = URL(fileURLWithPath: argument) + return path.lastPathComponent == "swiftpm-testing-helper" + || argument == "--testing-library" + || path.lastPathComponent == "xctest" + || path.pathExtension == "xctest" + } + } + } +#endif diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift new file mode 100644 index 00000000..466c70af --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift @@ -0,0 +1,200 @@ +/// A type that can report issues. +public protocol IssueReporter: Sendable { + /// Called when an issue is reported. + /// + /// - Parameters: + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) + + /// Called when an error is caught. + /// + /// The default implementation of this conformance simply calls + /// ``reportIssue(_:fileID:filePath:line:column:)`` with a description of the error. + /// + /// - Parameters: + /// - error: An error. + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) + + /// Called when an expected issue is reported. + /// + /// The default implementation of this conformance simply ignores the issue. + /// + /// - Parameters: + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func expectIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) + + /// Called when an expected error is reported. + /// + /// The default implementation of this conformance simply ignores the error. + /// + /// - Parameters: + /// - error: An error. + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func expectIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) +} + +extension IssueReporter { + public func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + reportIssue( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + public func expectIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) {} + + public func expectIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + expectIssue( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +} + +public enum IssueReporters { + /// The task's current issue reporters. + /// + /// Assigning this directly will override the which issue reporters are notified in the current + /// task. This is generally useful at the entry point of your application, should you want to + /// replace the default reporting: + /// + /// ```swift + /// import IssueReporting + /// + /// @main + /// struct MyApp: App { + /// init() { + /// IssueReporters.current = [.fatalError] + /// } + /// + /// var body: some Scene { + /// // ... + /// } + /// } + /// ``` + /// + /// Issue reporters are fed issues in order. + /// + /// To override the task's issue reporters for a scoped operation, prefer + /// ``withIssueReporters(_:operation:)-91179``. + public static var current: [any IssueReporter] { + get { _current.withLock { $0 } } + set { _current.withLock { $0 = newValue } } + } + + @TaskLocal fileprivate static var _current = IssueReportingLockIsolated<[any IssueReporter]>([.runtimeWarning]) +} + +/// Overrides the task's issue reporters for the duration of the synchronous operation. +/// +/// For example, you can ignore all reported issues by passing an empty array of reporters: +/// +/// ```swift +/// withIssueReporters([]) { +/// // Reported issues will be ignored here... +/// } +/// ``` +/// +/// Or, to temporarily add a custom reporter, you can append it to ``IssueReporters/current``: +/// +/// ```swift +/// withIssueReporters(IssueReporters.current + [MyCustomReporter()]) { +/// // Reported issues will be fed to the +/// } +/// ``` +/// +/// - Parameters: +/// - reporters: Issue reporters to notify during the operation. +/// - operation: A synchronous operation. +public func withIssueReporters( + _ reporters: [any IssueReporter], + operation: () throws -> R +) rethrows -> R { + try IssueReporters.$_current.withValue(IssueReportingLockIsolated(reporters), operation: operation) +} + +/// Overrides the task's issue reporters for the duration of the asynchronous operation. +/// +/// An asynchronous version of ``withIssueReporters(_:operation:)-91179``. +/// +/// - Parameters: +/// - reporters: Issue reporters to notify during the operation. +/// - operation: An asynchronous operation. +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +public func withIssueReporters( + _ reporters: [any IssueReporter], + operation: () async throws -> R +) async rethrows -> R { + try await IssueReporters.$_current.withValue(IssueReportingLockIsolated(reporters), operation: operation) +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/BreakpointReporter.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/BreakpointReporter.swift new file mode 100644 index 00000000..79658a91 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/BreakpointReporter.swift @@ -0,0 +1,55 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +#if canImport(Darwin) + import Darwin + + extension IssueReporter where Self == _BreakpointReporter { + /// An issue reporter that pauses program execution when a debugger is attached. + /// + /// Logs a warning to the console and raises `SIGTRAP` when an issue is received. + public static var breakpoint: Self { Self() } + } + + /// A type representing an issue reporter that pauses program execution when a debugger is + /// attached. + /// + /// Use ``IssueReporter/breakpoint`` to create one of these values. + public struct _BreakpointReporter: IssueReporter { + public func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + printError("\(fileID):\(line): \(message)") + guard isDebuggerAttached else { return } + printError( + """ + + Caught debug breakpoint. Type "continue" ("c") to resume execution. + """ + ) + raise(SIGTRAP) + } + + var isDebuggerAttached: Bool { + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var info: kinfo_proc = kinfo_proc() + var info_size = MemoryLayout.size + + return name.withUnsafeMutableBytes { + $0.bindMemory(to: Int32.self).baseAddress + .map { + sysctl($0, 4, &info, &info_size, nil, 0) != -1 && info.kp_proc.p_flag & P_TRACED != 0 + } + ?? false + } + } + } +#endif diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/FatalErrorReporter.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/FatalErrorReporter.swift new file mode 100644 index 00000000..61e238d2 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/FatalErrorReporter.swift @@ -0,0 +1,28 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +extension IssueReporter where Self == _FatalErrorReporter { + /// An issue reporter that terminates program execution. + /// + /// Calls Swift's `fatalError` function when an issue is received. + public static var fatalError: Self { Self() } +} + +/// A type representing an issue reporter that terminates program execution. +/// +/// Use ``IssueReporter/fatalError`` to create one of these values. +public struct _FatalErrorReporter: IssueReporter { + public func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + Swift.fatalError(message, file: filePath, line: line) + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift new file mode 100644 index 00000000..4026c52c --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift @@ -0,0 +1,124 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +import Foundation + +#if canImport(os) + import os +#endif + +extension IssueReporter where Self == _RuntimeWarningReporter { + /// An issue reporter that emits "purple" runtime warnings to Xcode and logs fault-level messages + /// to the console. + /// + /// This is the default issue reporter. On non-Apple platforms it logs messages to `stderr`. + /// + /// If this issue reporter receives an expected issue, it will log an info-level message to the + /// console, instead. + #if canImport(Darwin) + @_transparent + #endif + public static var runtimeWarning: Self { Self() } +} + +/// A type representing an issue reporter that emits "purple" runtime warnings to Xcode and logs +/// fault-level messages to the console. +/// +/// Use ``IssueReporter/runtimeWarning`` to create one of these values. +/// Copied verbatim from xctest dynamic overlay v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/xctest-dynamic-overlay/tree/1.3.0 + +public struct _RuntimeWarningReporter: IssueReporter { + #if canImport(os) + @IssueReportingUncheckedSendable + #if canImport(Darwin) + @_transparent + #endif + @usableFromInline var dso: UnsafeRawPointer + + init(dso: UnsafeRawPointer) { + self.dso = dso + } + + @usableFromInline + init() { + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + let count = _dyld_image_count() + for i in 0.. String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + #if canImport(os) + guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" + else { + print("🟣 \(fileID):\(line): \(message() ?? "")") + return + } + let moduleName = String( + Substring("\(fileID)".utf8.prefix(while: { $0 != UTF8.CodeUnit(ascii: "/") })) + ) + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + os_log( + .fault, + dso: dso, + log: OSLog(subsystem: "com.apple.runtime-issues", category: moduleName), + "%@", + "\(isTesting ? "\(fileID):\(line): " : "")\(message)" + ) + #else + printError("\(fileID):\(line): \(message() ?? "")") + #endif + } + + public func expectIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + #if canImport(os) + let moduleName = String( + Substring("\(fileID)".utf8.prefix(while: { $0 != UTF8.CodeUnit(ascii: "/") })) + ) + var message = message() ?? "" + if message.isEmpty { + message = "Issue expected" + } + os_log( + .info, + log: OSLog(subsystem: "co.pointfree.expected-issues", category: moduleName), + "%@", + "\(isTesting ? "\(fileID):\(line): " : "")\(message)" + ) + #else + print("\(fileID):\(line): \(message() ?? "")") + #endif + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/ReportIssue.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/ReportIssue.swift new file mode 100644 index 00000000..24520121 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/ReportIssue.swift @@ -0,0 +1,154 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +/// Report an issue. +/// +/// Invoking this function has two different behaviors depending on the context: +/// +/// * When running your code in a non-testing context, this method will loop over the +/// collection of issue reports registered and invoke them. The default issue reporter for the +/// library is ``IssueReporter/runtimeWarning``, which emits a purple, runtime warning in Xcode: +/// +/// ![A purple runtime warning in Xcode showing that an issue has been reported.](runtime-warning) +/// +/// But you can there are also [other issue reports]() you +/// can use, and you can create your own. +/// +/// * When running your app in tests (both XCTest and Swift's native Testing framework), it will +/// emit a test failure. This allows you to get test coverage on your reported issues, both expected +/// and unexpected ones. +/// +/// ![A test failure in Xcode where an issue has been reported.](test-failure) +/// +/// [Issue.record]: https://developer.apple.com/documentation/testing/issue/record(_:sourcelocation:) +/// [XCTFail]: https://developer.apple.com/documentation/xctest/1500970-xctfail/ +/// +/// - Parameters: +/// - message: A message describing the issue. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +@_transparent +public func reportIssue( + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + guard let context = TestContext.current else { + guard !isTesting else { return } + if let observer = FailureObserver.current { + observer.withLock { $0 += 1 } + for reporter in IssueReporters.current { + reporter.expectIssue( + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } else { + for reporter in IssueReporters.current { + reporter.reportIssue( + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + return + } + + switch context { + case .swiftTesting: + _recordIssue( + message: message(), + fileID: "\(IssueContext.current?.fileID ?? fileID)", + filePath: "\(IssueContext.current?.filePath ?? filePath)", + line: Int(IssueContext.current?.line ?? line), + column: Int(IssueContext.current?.column ?? column) + ) + case .xcTest: + _XCTFail( + message().withAppHostWarningIfNeeded() ?? "", + file: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line + ) + @unknown default: break + } +} + +/// Report a caught error. +/// +/// This function behaves similarly to ``reportIssue(_:fileID:filePath:line:column:)``, but for +/// reporting errors. +/// +/// - Parameters: +/// - error: The error that caused the issue. +/// - message: A message describing the expectation. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +@_transparent +public func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + guard let context = TestContext.current else { + guard !isTesting else { return } + if let observer = FailureObserver.current { + observer.withLock { $0 += 1 } + for reporter in IssueReporters.current { + reporter.expectIssue( + error, + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } else { + for reporter in IssueReporters.current { + reporter.reportIssue( + error, + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + return + } + + switch context { + case .swiftTesting: + _recordError( + error: error, + message: message(), + fileID: "\(IssueContext.current?.fileID ?? fileID)", + filePath: "\(IssueContext.current?.filePath ?? filePath)", + line: Int(IssueContext.current?.line ?? line), + column: Int(IssueContext.current?.column ?? column) + ) + case .xcTest: + _XCTFail( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")".withAppHostWarningIfNeeded(), + file: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line + ) + @unknown default: break + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/TestContext.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/TestContext.swift new file mode 100644 index 00000000..bf66f541 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/TestContext.swift @@ -0,0 +1,82 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +/// A type representing the context in which a test is being run, _i.e._ either in Swift's native +/// Testing framework, or Xcode's XCTest framework. +public enum TestContext: Equatable, Sendable { + /// The Swift Testing framework. + case swiftTesting(Testing?) + + /// The XCTest framework. + case xcTest + + /// The context associated with current test. + /// + /// How the test context is detected depends on the framework: + /// + /// * If Swift Testing is running, _and_ this is called from the current test's task, this will + /// return ``swiftTesting`` with an associated value of the current test. You can invoke + /// ``isSwiftTesting`` to detect if the test is currently in the Swift Testing framework, + /// which is equivalent to checking `Test.current != nil`, but safe to do from library and + /// application code. + /// + /// * If XCTest is running, _and_ this is called during the execution of a test _regardless_ of + /// task, this will return ``xcTest``. + /// + /// If executed outside of a test process, this will return `nil`. + public static var current: Self? { + guard isTesting else { return nil } + if let currentTestID = _currentTestID() { + return .swiftTesting(Testing(id: currentTestID)) + } else { + return .xcTest + } + } + + /// Determines if the test context is Swift's native Testing framework. + public var isSwiftTesting: Bool { + guard case .swiftTesting = self + else { return false } + return true + } + + public struct Testing: Equatable, Sendable { + public let test: Test + + public struct Test: Equatable, Hashable, Identifiable, Sendable { + public let id: ID + + public struct ID: Equatable, Hashable, @unchecked Sendable { + public let rawValue: AnyHashable + } + } + } + + @available(*, deprecated, message: "Test using pattern matching, instead.") + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.swiftTesting(nil), .swiftTesting), + (.swiftTesting, .swiftTesting(nil)), + (.xcTest, .xcTest): + return true + case (.swiftTesting(let lhs), .swiftTesting(let rhs)): + return lhs == rhs + case (.swiftTesting, .xcTest), (.xcTest, .swiftTesting): + return false + } + } + + @available( + *, deprecated, + message: "Test for '.swiftTesting' using pattern matching or 'isSwiftTesting', instead." + ) + public static var swiftTesting: Self { + .swiftTesting(nil) + } +} + +extension TestContext.Testing { + fileprivate init(id: AnyHashable) { + self.init(test: Test(id: Test.ID(rawValue: id))) + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/Unimplemented.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/Unimplemented.swift new file mode 100644 index 00000000..9280faf4 --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/Unimplemented.swift @@ -0,0 +1,306 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +/// Returns a closure that reports an issue when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - placeholder: A placeholder value returned from the closure when left unimplemented. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: A closure that reports an issue when invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + placeholder: @autoclosure @escaping @Sendable () -> Result = (), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) -> Result { + return { (argument: repeat each Argument) in + _fail( + description(), + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + return placeholder() + } +} + +/// Returns a throwing closure that reports an issue and throws an error when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: A throwing closure that reports an issue and throws an error when invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) throws -> Result { + return { (argument: repeat each Argument) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw UnimplementedFailure(description: description) + } +} + +#if compiler(>=6) + /// Returns a throwing closure that reports an issue and throws a given error when invoked. + /// + /// Useful for creating closures that need to be overridden by users of your API, and if it is + /// ever invoked without being overridden an issue will be reported. See + /// for more information. + /// + /// - Parameters: + /// - description: An optional description of the unimplemented closure. + /// - failure: The error thrown by the unimplemented closure. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - function: The function. + /// - line: The line. + /// - column: The column. + /// - Returns: A throwing closure that reports an issue and throws an error when invoked. + public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + throwing failure: @autoclosure @escaping @Sendable () -> Failure, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) -> @Sendable (repeat each Argument) throws(Failure) -> Result { + return { (argument: repeat each Argument) throws(Failure) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw failure() + } + } +#endif + +/// Returns an asynchronous closure that reports an issue when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - placeholder: A placeholder value returned from the closure when left unimplemented. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: An asynchronous closure that reports an issue when invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + placeholder: @autoclosure @escaping @Sendable () -> Result = (), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) async -> Result { + return { (argument: repeat each Argument) in + _fail( + description(), + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + return placeholder() + } +} + +/// Returns a throwing, asynchronous closure that reports an issue and throws an error when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: A throwing, asynchronous closure that reports an issue and throws an error when +/// invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) async throws -> Result { + return { (argument: repeat each Argument) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw UnimplementedFailure(description: description) + } +} + +#if compiler(>=6) + /// Returns a throwing, asynchronous closure that reports an issue and throws a given error when + /// invoked. + /// + /// Useful for creating closures that need to be overridden by users of your API, and if it is + /// ever invoked without being overridden an issue will be reported. See + /// for more information. + /// + /// - Parameters: + /// - description: An optional description of the unimplemented closure. + /// - failure: The error thrown by the unimplemented closure. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - function: The function. + /// - line: The line. + /// - column: The column. + /// - Returns: A throwing, asynchronous closure that reports an issue and throws an error when + /// invoked. + public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + throwing failure: @autoclosure @escaping @Sendable () -> Failure, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) -> @Sendable (repeat each Argument) async throws(Failure) -> Result { + return { (argument: repeat each Argument) async throws(Failure) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw failure() + } + } +#endif + +@_disfavoredOverload +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + placeholder: @autoclosure @escaping @Sendable () -> Result = (), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> Result { + _fail( + description(), + nil, + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + return placeholder() +} + +/// An error thrown from throwing `unimplemented` closures. +public struct UnimplementedFailure: Error { + public let description: String + + public init(description: String) { + self.description = description + } +} + +package func _fail( + _ description: String, + _ parameters: Any?, + fileID: StaticString, + filePath: StaticString, + function: StaticString, + line: UInt, + column: UInt +) { + var debugDescription = """ + … + + Defined in '\(function)' at: + \(fileID):\(line) + """ + if let parameters { + var parametersDescription = "" + debugPrint(parameters, terminator: "", to: ¶metersDescription) + debugDescription.append( + """ + + + Invoked with: + \(parametersDescription) + """ + ) + } + reportIssue( + """ + Unimplemented\(description.isEmpty ? "" : ": \(description)")\(debugDescription) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/WithExpectedIssue.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/WithExpectedIssue.swift new file mode 100644 index 00000000..3cab170c --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/WithExpectedIssue.swift @@ -0,0 +1,206 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +/// Invoke a function that has an issue that is expected to occur during its execution. +/// +/// A generalized version of Swift Testing's [`withKnownIssue`][withKnownIssue] that works with this +/// library's [`reportIssue`]() instead of just +/// Swift Testing's tools. +/// +/// At runtime it can be used to lower the log level of reported issues: +/// +/// ```swift +/// // Emits a "purple" warning to Xcode and logs a fault-level message to console +/// reportIssue("Failed") +/// +/// withExpectedIssue { +/// // Simply logs an info-level message +/// reportIssue("Failed") +/// } +/// ``` +/// +/// During test runs, the issue will be sent to Swift Testing's [`withKnownIssue`][withKnownIssue] +/// _or_ XCTest's [`XCTExpectFailure`][XCTExpectFailure] accordingly, which means you can use it to +/// drive custom assertion helpers that you want to work in both Swift Testing and XCTest. +/// +/// Errors thrown from the function are automatically caught and reported as issues: +/// +/// ```swift +/// withExpectedIssue { +/// // If this function throws an error, it will be caught and reported as an issue +/// try functionThatCanFail() +/// } +/// ``` +/// +/// [withKnownIssue]: https://developer.apple.com/documentation/testing/withknownissue(_:isintermittent:fileid:filepath:line:column:_:)-30kgk +/// [XCTExpectFailure]: https://developer.apple.com/documentation/xctest/3727246-xctexpectfailure/ +/// +/// - Parameters: +/// - message: An optional message describing the expected issue. +/// - isIntermittent: Whether or not the expected issue occurs intermittently. If this argument is +/// `true` and the expected issue does not occur, no secondary issue is recorded. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +/// - body: The function to invoke. +@_transparent +public func withExpectedIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column, + _ body: () throws -> Void +) { + guard let context = TestContext.current else { + guard !isTesting else { return } + let observer = FailureObserver() + FailureObserver.$current.withValue(observer) { + do { + try body() + if observer.withLock({ $0 == 0 }), !isIntermittent { + for reporter in IssueReporters.current { + reporter.reportIssue( + "Known issue was not recorded\(message.map { ": \($0)" } ?? "")", + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } catch { + for reporter in IssueReporters.current { + reporter.expectIssue( + error, + message, + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } + return + } + + switch context { + case .swiftTesting: + _withKnownIssue( + message, + isIntermittent: isIntermittent, + fileID: fileID.description, + filePath: filePath.description, + line: Int(line), + column: Int(column), + body + ) + case .xcTest: + _XCTExpectFailure( + message.withAppHostWarningIfNeeded(), + strict: !isIntermittent, + file: filePath, + line: line + ) { + do { + try body() + } catch { + reportIssue(error, fileID: fileID, filePath: filePath, line: line, column: column) + } + } + @unknown default: break + } +} + +/// Invoke an asynchronous function that has an issue that is expected to occur during its +/// execution. +/// +/// An asynchronous version of +/// ``withExpectedIssue(_:isIntermittent:fileID:filePath:line:column:_:)-9pinm``. +/// +/// > Warning: The asynchronous version of this function is incompatible with XCTest and will +/// > unconditionally report an issue when used, instead. +/// +/// - Parameters: +/// - message: An optional message describing the expected issue. +/// - isIntermittent: Whether or not the known expected occurs intermittently. If this argument is +/// `true` and the expected issue does not occur, no secondary issue is recorded. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +/// - body: The asynchronous function to invoke. +@_transparent +public func withExpectedIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column, + _ body: () async throws -> Void +) async { + + guard let context = TestContext.current else { + guard !isTesting else { return } + let observer = FailureObserver() + await FailureObserver.$current.withValue(observer) { + do { + try await body() + if observer.withLock({ $0 == 0 }), !isIntermittent { + for reporter in IssueReporters.current { + reporter.reportIssue( + "Known issue was not recorded\(message.map { ": \($0)" } ?? "")", + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } catch { + for reporter in IssueReporters.current { + reporter.expectIssue( + error, + message, + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } + return + } + + switch context { + case .swiftTesting: + await _withKnownIssue( + message, + isIntermittent: isIntermittent, + fileID: fileID.description, + filePath: filePath.description, + line: Int(line), + column: Int(column), + body + ) + case .xcTest: + reportIssue( + """ + Asynchronously expecting failures is unavailable in XCTest. + + Omit this test from your XCTest suite, or consider using Swift Testing, instead. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + try? await body() + @unknown default: break + } +} diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/WithIssueContext.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/WithIssueContext.swift new file mode 100644 index 00000000..50a1239a --- /dev/null +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/WithIssueContext.swift @@ -0,0 +1,65 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + +/// Sets the context for issues reported for the duration of the synchronous operation. +/// +/// This context will override the implicit context from the call sites of +/// ``reportIssue(_:fileID:filePath:line:column:)`` and +/// ``withExpectedIssue(_:isIntermittent:fileID:filePath:line:column:_:)-9pinm``, and can be +/// leveraged by custom test helpers that want to associate reported issues with specific source +/// code. +/// +/// - Parameters: +/// - fileID: The source `#fileID` to associate with issues reported during the operation. +/// - filePath: The source `#filePath` to associate with issues reported during the operation. +/// - line: The source `#line` to associate with issues reported during the operation. +/// - column: The source `#column` to associate with issues reported during the operation. +/// - operation: A synchronous operation. +public func withIssueContext( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt, + operation: () throws -> R +) rethrows -> R { + try IssueContext.$current.withValue( + IssueContext(fileID: fileID, filePath: filePath, line: line, column: column), + operation: operation + ) +} + +/// Sets the context for issues reported for the duration of the asynchronous operation. +/// +/// An asynchronous version of ``withIssueContext(fileID:filePath:line:column:operation:)-97lux``. +/// +/// - Parameters: +/// - fileID: The source `#fileID` to associate with issues reported during the operation. +/// - filePath: The source `#filePath` to associate with issues reported during the operation. +/// - line: The source `#line` to associate with issues reported during the operation. +/// - column: The source `#column` to associate with issues reported during the operation. +/// - operation: An asynchronous operation. +public func withIssueContext( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt, + operation: () async throws -> R +) async rethrows -> R { + try await IssueContext.$current.withValue( + IssueContext(fileID: fileID, filePath: filePath, line: line, column: column), + operation: operation + ) +} + +@usableFromInline +struct IssueContext: Sendable { + @TaskLocal public static var current: Self? + @usableFromInline + let fileID: StaticString + @usableFromInline + let filePath: StaticString + @usableFromInline + let line: UInt + @usableFromInline + let column: UInt +} diff --git a/Sources/KlaviyoSwift/Klaviyo.swift b/Sources/KlaviyoSwift/Klaviyo.swift index 79193c9b..0df6c659 100644 --- a/Sources/KlaviyoSwift/Klaviyo.swift +++ b/Sources/KlaviyoSwift/Klaviyo.swift @@ -5,17 +5,14 @@ // Copyright (c) 2022 Klaviyo. All rights reserved. // -import AnyCodable +import Combine import Foundation import KlaviyoCore +import KlaviyoSDKDependencies import UIKit -func dispatchOnMainThread(action: KlaviyoAction) { - Task { - await MainActor.run { - klaviyoSwiftEnvironment.send(action) - } - } +func dispatchStoreAction(action: KlaviyoAction) async { + _ = await klaviyoSwiftEnvironment.send(action) } /// The main interface for the Klaviyo SDK. @@ -27,10 +24,13 @@ func dispatchOnMainThread(action: KlaviyoAction) { /// ``` /// /// From there you can you can call the additional methods below to track events and profile. +#if swift(<5.10) +@MainActor(unsafe) +#else +@preconcurrency@MainActor +#endif public struct KlaviyoSDK { - /// Default initializer for the Klaviyo SDK. public init() {} - private var state: KlaviyoState { klaviyoSwiftEnvironment.state() } @@ -62,7 +62,10 @@ public struct KlaviyoSDK { /// - Returns: a KlaviyoSDK instance @discardableResult public func initialize(with apiKey: String) -> KlaviyoSDK { - dispatchOnMainThread(action: .initialize(apiKey)) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .initialize(apiKey, appContextInfo)) + } return self } @@ -72,7 +75,10 @@ public struct KlaviyoSDK { /// NOTE: this will trigger a reset of existing profile see ``resetProfile()`` for details. /// - Parameter profile: a profile object to send to Klaviyo public func set(profile: Profile) { - dispatchOnMainThread(action: .enqueueProfile(profile)) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .enqueueProfile(profile, appContextInfo)) + } } /// Clears all stored profile identifiers (e.g. email or phone) and starts a new tracked profile. @@ -80,14 +86,19 @@ public struct KlaviyoSDK { /// from the current profile. Existing token data will be associated with a new anonymous profile. /// This should be called whenever an active user in your app is removed (e.g. after a logout). public func resetProfile() { - dispatchOnMainThread(action: .resetProfile) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .resetProfile(appContextInfo)) + } } /// Sets the badge number on the application icon. Syncs with the persisted count /// stored in the User Defaults suite set up with the App Group. Used to set the badge count /// to 0 when autoclearing is turned on (in the plist). Can be called otherwise as well. public func setBadgeCount(_ count: Int) { - dispatchOnMainThread(action: .setBadgeCount(count)) + Task { + await dispatchStoreAction(action: .setBadgeCount(count)) + } } /// Set the current user's email. @@ -95,7 +106,10 @@ public struct KlaviyoSDK { /// - Returns: a KlaviyoSDK instance @discardableResult public func set(email: String) -> KlaviyoSDK { - dispatchOnMainThread(action: .setEmail(email)) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .setEmail(email, appContextInfo)) + } return self } @@ -107,7 +121,10 @@ public struct KlaviyoSDK { /// - Returns: a KlaviyoSDK instance @discardableResult public func set(phoneNumber: String) -> KlaviyoSDK { - dispatchOnMainThread(action: .setPhoneNumber(phoneNumber)) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .setPhoneNumber(phoneNumber, appContextInfo)) + } return self } @@ -119,7 +136,10 @@ public struct KlaviyoSDK { /// - Returns: a KlaviyoSDK instance @discardableResult public func set(externalId: String) -> KlaviyoSDK { - dispatchOnMainThread(action: .setExternalId(externalId)) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .setExternalId(externalId, appContextInfo)) + } return self } @@ -130,14 +150,19 @@ public struct KlaviyoSDK { @discardableResult public func set(profileAttribute: Profile.ProfileKey, value: Any) -> KlaviyoSDK { // This seems tricky to implement with Any - might need to restrict to something equatable, encodable.... - dispatchOnMainThread(action: .setProfileProperty(profileAttribute, AnyEncodable(value))) + Task { + await dispatchStoreAction(action: .setProfileProperty(profileAttribute, AnyEncodable(value))) + } return self } /// Create and send an event for the current user. /// - Parameter event: the event to be tracked in Klaviyo public func create(event: Event) { - dispatchOnMainThread(action: .enqueueEvent(event)) + Task { + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .enqueueEvent(event, appContextInfo)) + } } /// Set the current user's push token. This will be associated with profile and can be used to send them push notificaitons. @@ -152,7 +177,9 @@ public struct KlaviyoSDK { public func set(pushToken: String) { Task { let enablement = await environment.getNotificationSettings() - dispatchOnMainThread(action: .setPushToken(pushToken, enablement)) + let background = klaviyoSwiftEnvironment.getBackgroundSetting() + let appContextInfo = await environment.appContextInfo() + await dispatchStoreAction(action: .setPushToken(pushToken, enablement, background, appContextInfo)) } } @@ -166,19 +193,18 @@ public struct KlaviyoSDK { if let properties = notificationResponse.notification.request.content.userInfo as? [String: Any], let body = properties["body"] as? [String: Any], let _ = body["_k"] { create(event: Event(name: ._openedPush, properties: properties)) - Task { - await MainActor.run { - if let url = properties["url"] as? String, let url = URL(string: url) { - if let deepLinkHandler = deepLinkHandler { - deepLinkHandler(url) - } else { + if let url = properties["url"] as? String, let url = URL(string: url) { + if let deepLinkHandler = deepLinkHandler { + deepLinkHandler(url) + } else { + Task { + await MainActor.run { UIApplication.shared.open(url) } } - completionHandler() } } - + completionHandler() return true } return false diff --git a/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift b/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift index 164a871f..93304a85 100644 --- a/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift +++ b/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift @@ -7,41 +7,106 @@ import Combine import Foundation +import KlaviyoCore +@_spi(Internals) import KlaviyoSDKDependencies import UIKit import UserNotifications -var klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.production +@MainActor var klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.production +@MainActor var store = Store.production -struct KlaviyoSwiftEnvironment { - var send: (KlaviyoAction) -> Task? - var state: () -> KlaviyoState - var statePublisher: () -> AnyPublisher - var stateChangePublisher: () -> AnyPublisher - var setBadgeCount: (Int) -> Task? +#if swift(<5.10) +@MainActor(unsafe) +#else +@preconcurrency@MainActor +#endif +struct KlaviyoSwiftEnvironment: Sendable { + var send: @MainActor (KlaviyoAction) -> StoreTask + var state: @MainActor () -> KlaviyoState + var statePublisher: @MainActor () -> AnyPublisher + var stateChangePublisher: @MainActor () -> AsyncStream + var lifeCyclePublisher: @MainActor () -> AsyncStream + var getBackgroundSetting: @MainActor () -> PushBackground + var networkSession: @MainActor () -> NetworkSession + var setBadgeCount: @MainActor (Int) -> Void - static let production: KlaviyoSwiftEnvironment = { - let store = Store.production + static let production: KlaviyoSwiftEnvironment = createProductionInstance() - return KlaviyoSwiftEnvironment( + @MainActor + static func createProductionInstance() -> KlaviyoSwiftEnvironment { + // This instance is created via function to avoid a compiler error + // Default argument cannot be both main actor-isolated and nonisolated + // on newer versions of swift it's not necessary to do this. + KlaviyoSwiftEnvironment( send: { action in store.send(action) }, - state: { store.state.value }, - statePublisher: { store.state.eraseToAnyPublisher() }, - stateChangePublisher: StateChangePublisher().publisher, - setBadgeCount: { count in - Task { - if let userDefaults = UserDefaults(suiteName: Bundle.main.object(forInfoDictionaryKey: "Klaviyo_App_Group") as? String) { - if #available(iOS 16.0, *) { - try? await UNUserNotificationCenter.current().setBadgeCount(count) - } else { - await MainActor.run { - UIApplication.shared.applicationIconBadgeNumber = count + state: { + store.currentState + }, + statePublisher: { + store.publisher.eraseToAnyPublisher() + }, + stateChangePublisher: { + StateChangePublisher().publisher() + }, + lifeCyclePublisher: { + let publisher = AppLifeCycleEvents.production.lifeCycleEvents( + environment.notificationCenterPublisher, + environment.startReachability, environment.stopReachability, + environment.reachabilityStatus).map(\.transformToKlaviyoAction).eraseToAnyPublisher() + return AsyncStream { continuation in + + Task { + let cancellableStore = CancellableStore() + let cancellable = publisher.sink { value in + continuation.yield(value) + } + + Task { + await cancellableStore.store(cancellable) + } + + // Handle cancellation + continuation.onTermination = { @Sendable _ in + Task { + await cancellableStore.cancel() } } - userDefaults.set(count, forKey: "badgeCount") } } + + }, + getBackgroundSetting: { + .create(from: UIApplication.shared.backgroundRefreshStatus) + }, networkSession: { createNetworkSession() }, + setBadgeCount: { count in + if let userDefaults = UserDefaults( + suiteName: Bundle.main.object( + forInfoDictionaryKey: "Klaviyo_App_Group") + as? String) { + if #available(iOS 16.0, *) { + UNUserNotificationCenter.current() + .setBadgeCount(count) + } else { + UIApplication.shared + .applicationIconBadgeNumber = count + } + userDefaults.set(count, forKey: "badgeCount") + } }) - }() + } +} + +actor CancellableStore { + private var cancellable: AnyCancellable? + + func store(_ cancellable: AnyCancellable) { + self.cancellable = cancellable + } + + func cancel() { + cancellable?.cancel() + cancellable = nil + } } diff --git a/Sources/KlaviyoSwift/Models/Event.swift b/Sources/KlaviyoSwift/Models/Event.swift index 3456a2d7..2a825e73 100644 --- a/Sources/KlaviyoSwift/Models/Event.swift +++ b/Sources/KlaviyoSwift/Models/Event.swift @@ -5,12 +5,12 @@ // Created by Ajay Subramanya on 8/6/24. // -import AnyCodable import Foundation import KlaviyoCore +import KlaviyoSDKDependencies -public struct Event: Equatable { - public enum EventName: Equatable { +public struct Event: Equatable, Sendable { + public enum EventName: Equatable, Sendable { case openedAppMetric case viewedProductMetric case addedToCartMetric @@ -22,7 +22,7 @@ public struct Event: Equatable { } } - public struct Metric: Equatable { + public struct Metric: Equatable, Sendable { public let name: EventName public init(name: EventName) { @@ -49,17 +49,17 @@ public struct Event: Equatable { } private let _properties: AnyCodable - public let time: Date + public let time: Date? public let value: Double? - public let uniqueId: String + public let uniqueId: String? let identifiers: Identifiers? init(name: EventName, properties: [String: Any]? = nil, identifiers: Identifiers? = nil, value: Double? = nil, - time: Date = environment.date(), - uniqueId: String = environment.uuid().uuidString) { + time: Date? = nil, + uniqueId: String?) { metric = .init(name: name) _properties = AnyCodable(properties ?? [:]) self.time = time @@ -82,8 +82,8 @@ public struct Event: Equatable { _properties = AnyCodable(properties ?? [:]) identifiers = nil self.value = value + self.uniqueId = uniqueId time = environment.date() - self.uniqueId = uniqueId ?? environment.uuid().uuidString } } diff --git a/Sources/KlaviyoSwift/Models/Profile.swift b/Sources/KlaviyoSwift/Models/Profile.swift index 60791d7a..306dd770 100644 --- a/Sources/KlaviyoSwift/Models/Profile.swift +++ b/Sources/KlaviyoSwift/Models/Profile.swift @@ -5,12 +5,12 @@ // Created by Ajay Subramanya on 8/6/24. // -import AnyCodable import Foundation import KlaviyoCore +import KlaviyoSDKDependencies -public struct Profile: Equatable { - public enum ProfileKey: Equatable, Hashable, Codable { +public struct Profile: Equatable, Sendable { + public enum ProfileKey: Equatable, Hashable, Codable, Sendable { case firstName case lastName case address1 @@ -27,7 +27,7 @@ public struct Profile: Equatable { case custom(customKey: String) } - public struct Location: Equatable { + public struct Location: Equatable, Sendable { public var address1: String? public var address2: String? public var city: String? @@ -65,7 +65,7 @@ public struct Profile: Equatable { self.longitude = longitude self.region = region self.zip = zip - self.timezone = timezone ?? environment.timeZone() + self.timezone = timezone } } diff --git a/Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift b/Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift index 5c82ab50..4c82bfe3 100644 --- a/Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift +++ b/Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift @@ -52,7 +52,7 @@ private func parseError(_ data: Data) -> [InvalidField]? { func handleRequestError( request: KlaviyoRequest, error: KlaviyoAPIError, - retryInfo: RetryInfo) -> KlaviyoAction { + retryInfo: RetryInfo) async -> KlaviyoAction { switch error { case let .httpError(statuscode, data): let responseString = String(data: data, encoding: .utf8) ?? "[Unknown]" @@ -76,23 +76,23 @@ func handleRequestError( } case let .internalError(data): - environment.emitDeveloperWarning("An internal error occurred msg: \(data)") + await environment.emitDeveloperWarning("An internal error occurred msg: \(data)") return .deQueueCompletedResults(request) case let .internalRequestError(error): - environment.emitDeveloperWarning("An internal request error occurred msg: \(error)") + await environment.emitDeveloperWarning("An internal request error occurred msg: \(error)") return .deQueueCompletedResults(request) case let .unknownError(error): - environment.emitDeveloperWarning("An unknown request error occured \(error)") + await environment.emitDeveloperWarning("An unknown request error occured \(error)") return .deQueueCompletedResults(request) case .dataEncodingError: - environment.emitDeveloperWarning("A data encoding error occurred during transmission.") + await environment.emitDeveloperWarning("A data encoding error occurred during transmission.") return .deQueueCompletedResults(request) case .invalidData: - environment.emitDeveloperWarning("Invalid data supplied for request. Skipping.") + await environment.emitDeveloperWarning("Invalid data supplied for request. Skipping.") return .deQueueCompletedResults(request) case let .rateLimitError(retryAfter): @@ -115,7 +115,8 @@ func handleRequestError( currentBackoff: retryAfter)) case .missingOrInvalidResponse: - runtimeWarn("Missing or invalid response from api.") + + // runtimeWarn("Missing or invalid response from api.") return .deQueueCompletedResults(request) } } diff --git a/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift index 142f323c..ee4a926d 100644 --- a/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift +++ b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift @@ -5,30 +5,30 @@ // Created by Noah Durell on 12/1/22. // -import AnyCodable import Foundation import KlaviyoCore +import KlaviyoSDKDependencies import UIKit typealias DeviceMetadata = PushTokenPayload.PushToken.Attributes.MetaData -struct KlaviyoState: Equatable, Codable { - enum InitializationState: Equatable, Codable { +struct KlaviyoState: Equatable, Codable, Sendable { + enum InitializationState: Equatable, Codable, Sendable { case uninitialized case initializing case initialized } - enum PendingRequest: Equatable { - case event(Event) - case profile(Profile) - case pushToken(String, PushEnablement) - case setEmail(String) - case setExternalId(String) - case setPhoneNumber(String) + enum PendingRequest: Equatable, Sendable { + case event(Event, AppContextInfo) + case profile(Profile, AppContextInfo) + case pushToken(String, PushEnablement, PushBackground, AppContextInfo) + case setEmail(String, AppContextInfo) + case setExternalId(String, AppContextInfo) + case setPhoneNumber(String, AppContextInfo) } - struct PushTokenData: Equatable, Codable { + struct PushTokenData: Equatable, Codable, Sendable { var pushToken: String var pushEnablement: PushEnablement var pushBackground: PushBackground @@ -60,7 +60,7 @@ struct KlaviyoState: Equatable, Codable { var pendingRequests: [PendingRequest] = [] var pendingProfile: [Profile.ProfileKey: AnyEncodable]? - enum CodingKeys: CodingKey { + enum CodingKeys: String, CodingKey { case apiKey case email case anonymousId @@ -77,31 +77,32 @@ struct KlaviyoState: Equatable, Codable { queue.append(request) } - mutating func updateEmail(email: String) { + mutating func updateEmail(email: String, appContextInfo: AppContextInfo) { if email.isNotEmptyOrSame(as: self.email, identifier: "email") { self.email = email - enqueueProfileOrTokenRequest() + enqueueProfileOrTokenRequest(appConextInfo: appContextInfo) } } - mutating func updateExternalId(externalId: String) { + mutating func updateExternalId(externalId: String, appContextInfo: AppContextInfo) { if externalId.isNotEmptyOrSame(as: self.externalId, identifier: "external Id") { self.externalId = externalId - enqueueProfileOrTokenRequest() + enqueueProfileOrTokenRequest(appConextInfo: appContextInfo) } } - mutating func updatePhoneNumber(phoneNumber: String) { + mutating func updatePhoneNumber(phoneNumber: String, appContextInfo: AppContextInfo) { if phoneNumber.isNotEmptyOrSame(as: self.phoneNumber, identifier: "phone number") { self.phoneNumber = phoneNumber - enqueueProfileOrTokenRequest() + enqueueProfileOrTokenRequest(appConextInfo: appContextInfo) } } - mutating func enqueueProfileOrTokenRequest() { + mutating func enqueueProfileOrTokenRequest(appConextInfo: AppContextInfo) { guard let apiKey = apiKey, let anonymousId = anonymousId else { - environment.emitDeveloperWarning("SDK internal error") + // ND: revist - just log here... + // environment.emitDeveloperWarning("SDK internal error") return } // if we have push data and we are switching emails @@ -112,7 +113,7 @@ struct KlaviyoState: Equatable, Codable { apiKey: apiKey, anonymousId: anonymousId, pushToken: pushTokenData.pushToken, - enablement: pushTokenData.pushEnablement) + enablement: pushTokenData.pushEnablement, background: pushTokenData.pushBackground, appContextInfo: appConextInfo) enqueueRequest(request: request) } else { enqueueProfileRequest( @@ -126,7 +127,7 @@ struct KlaviyoState: Equatable, Codable { switch request.endpoint { case let .createProfile(payload): let updatedPayload = updateRequestAndStateWithPendingProfile(profile: payload) - let request = KlaviyoRequest(apiKey: apiKey, endpoint: .createProfile(updatedPayload)) + let request = KlaviyoRequest(apiKey: apiKey, endpoint: .createProfile(updatedPayload), uuid: environment.uuid().uuidString) enqueueRequest(request: request) default: environment.raiseFatalError("Unexpected request type. \(request.endpoint)") @@ -213,7 +214,7 @@ struct KlaviyoState: Equatable, Codable { email != nil || externalId != nil || phoneNumber != nil } - mutating func reset(preserveTokenData: Bool = true) { + mutating func reset(preserveTokenData: Bool = true, appContextInfo: AppContextInfo) { if isIdentified { // If we are still anonymous we want to preserve our anonymous id so we can merge this profile with the new profile. anonymousId = environment.uuid().uuidString @@ -233,26 +234,27 @@ struct KlaviyoState: Equatable, Codable { pushToken: tokenData.pushToken, enablement: tokenData.pushEnablement.rawValue, background: tokenData.pushBackground.rawValue, - profile: Profile().toAPIModel(anonymousId: anonymousId)) + profile: Profile().toAPIModel(anonymousId: anonymousId), appContextInfo: appContextInfo) let request = KlaviyoRequest( apiKey: apiKey, - endpoint: KlaviyoEndpoint.registerPushToken(payload)) + endpoint: KlaviyoEndpoint.registerPushToken(payload), uuid: environment.uuid().uuidString) enqueueRequest(request: request) } } } - func shouldSendTokenUpdate(newToken: String, enablement: PushEnablement) -> Bool { + func shouldSendTokenUpdate(newToken: String, enablement: PushEnablement, appContextInfo: AppContextInfo, pushBackground: PushBackground) -> Bool { guard let pushTokenData = pushTokenData else { return true } - let currentDeviceMetadata = DeviceMetadata(context: environment.appContextInfo()) + let currentDeviceMetadata = DeviceMetadata( + context: appContextInfo) let newPushTokenData = PushTokenData( pushToken: newToken, pushEnablement: enablement, - pushBackground: environment.getBackgroundSetting(), + pushBackground: pushBackground, deviceData: currentDeviceMetadata) return pushTokenData != newPushTokenData @@ -268,10 +270,10 @@ struct KlaviyoState: Equatable, Codable { let endpoint = KlaviyoEndpoint.createProfile(CreateProfilePayload(data: payload)) - return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint, uuid: environment.uuid().uuidString) } - mutating func buildTokenRequest(apiKey: String, anonymousId: String, pushToken: String, enablement: PushEnablement) -> KlaviyoRequest { + mutating func buildTokenRequest(apiKey: String, anonymousId: String, pushToken: String, enablement: PushEnablement, background: PushBackground, appContextInfo: AppContextInfo) -> KlaviyoRequest { var profile: Profile if let pendingProfile = pendingProfile { @@ -288,10 +290,11 @@ struct KlaviyoState: Equatable, Codable { let payload = PushTokenPayload( pushToken: pushToken, enablement: enablement.rawValue, - background: environment.getBackgroundSetting().rawValue, - profile: profile.toAPIModel(anonymousId: anonymousId)) + background: background.rawValue, + profile: profile.toAPIModel(anonymousId: anonymousId), + appContextInfo: appContextInfo) let endpoint = KlaviyoEndpoint.registerPushToken(payload) - return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint, uuid: environment.uuid().uuidString) } func buildUnregisterRequest(apiKey: String, anonymousId: String, pushToken: String) -> KlaviyoRequest { @@ -302,7 +305,7 @@ struct KlaviyoState: Equatable, Codable { externalId: externalId, anonymousId: anonymousId) let endpoint = KlaviyoEndpoint.unregisterPushToken(payload) - return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint, uuid: environment.uuid().uuidString) } } @@ -314,7 +317,7 @@ func saveKlaviyoState(state: KlaviyoState) { return } let file = klaviyoStateFile(apiKey: apiKey) - storeKlaviyoState(state: state, file: file) + storeKlaviyoState(fileClient: environment.fileClient, state: state, file: file) } private func klaviyoStateFile(apiKey: String) -> URL { @@ -323,10 +326,11 @@ private func klaviyoStateFile(apiKey: String) -> URL { return directory.appendingPathComponent(fileName, isDirectory: false) } -private func storeKlaviyoState(state: KlaviyoState, file: URL) { +private func storeKlaviyoState(fileClient: FileClient, state: KlaviyoState, file: URL) { do { - try environment.fileClient.write(environment.encodeJSON(AnyEncodable(state)), file) + try fileClient.write(environment.encodeJSON(AnyEncodable(state)), file) } catch { + // ND: handle logger here.. environment.logger.error("Unable to save klaviyo state.") } } @@ -339,8 +343,8 @@ private func removeStateFile(at file: URL) { } } -private func logDevWarning(for identifier: String) { - environment.emitDeveloperWarning(""" +private func logDevWarning(for identifier: String) async { + await environment.emitDeveloperWarning(""" \(identifier) is either empty or same as what is already set earlier. The SDK will ignore this change, please use resetProfile for resetting profile identifiers @@ -378,7 +382,7 @@ func loadKlaviyoStateFromDisk(apiKey: String) -> KlaviyoState { private func createAndStoreInitialState(with apiKey: String, at file: URL) -> KlaviyoState { let anonymousId = environment.uuid().uuidString let state = KlaviyoState(apiKey: apiKey, anonymousId: anonymousId, queue: [], requestsInFlight: []) - storeKlaviyoState(state: state, file: file) + storeKlaviyoState(fileClient: environment.fileClient, state: state, file: file) return state } @@ -466,7 +470,8 @@ extension String { fileprivate func isNotEmptyOrSame(as state: String?, identifier: String) -> Bool { let incoming = trimmingCharacters(in: .whitespacesAndNewlines) if incoming.isEmpty || incoming == state { - logDevWarning(for: identifier) + // fix - logging + // await logDevWarning(for: identifier) } return !incoming.isEmpty && incoming != state diff --git a/Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift b/Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift index 5fb7e865..25504c55 100644 --- a/Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift +++ b/Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift @@ -10,7 +10,8 @@ import Foundation import UIKit @_spi(KlaviyoPrivate) -public struct StateChangePublisher { +@MainActor +public struct StateChangePublisher: Sendable { static var debouncedPublisher: (AnyPublisher) -> AnyPublisher = { publisher in publisher .debounce(for: .seconds(1), scheduler: DispatchQueue.global()) @@ -24,19 +25,18 @@ public struct StateChangePublisher { .eraseToAnyPublisher() } - // publisher to listen for state and persist them on an interval. - // does not emit action but mapped that way so it can be used in the store. - var publisher: () -> AnyPublisher = { - debouncedPublisher(createStatePublisher()) - .flatMap { state -> Empty in - saveKlaviyoState(state: state) - return Empty() + var publisher: @MainActor () -> AsyncStream = { + AsyncStream { continuation in + Task { + for await state in debouncedPublisher(createStatePublisher()).values { + continuation.yield(state) + } } - .eraseToAnyPublisher() + } } @_spi(KlaviyoPrivate) - public struct PrivateState { + public struct PrivateState: Sendable { public var email: String? public var anonymousId: String? public var phoneNumber: String? @@ -45,16 +45,21 @@ public struct StateChangePublisher { } @_spi(KlaviyoPrivate) - public static func internalStatePublisher() -> AnyPublisher { - createStatePublisher() - .map { state in - PrivateState( - email: state.email, - anonymousId: state.anonymousId, - phoneNumber: state.phoneNumber, - externalId: state.externalId, - pushToken: state.pushTokenData?.pushToken) + @MainActor + public static func internalStatePublisher() -> AsyncStream { + let publisher = StateChangePublisher.createStatePublisher() + return AsyncStream { continuation in + Task { + for await state in publisher + .subscribe(on: DispatchQueue.main).values { + continuation.yield(PrivateState( + email: state.email, + anonymousId: state.anonymousId, + phoneNumber: state.phoneNumber, + externalId: state.externalId, + pushToken: state.pushTokenData?.pushToken)) + } } - .eraseToAnyPublisher() + } } } diff --git a/Sources/KlaviyoSwift/StateManagement/StateManagement.swift b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift index ac9e6abb..7606e8f4 100644 --- a/Sources/KlaviyoSwift/StateManagement/StateManagement.swift +++ b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift @@ -11,9 +11,9 @@ // Licensed under the MIT License. See LICENSE file in the project root for full license information. // -import AnyCodable import Foundation import KlaviyoCore +import KlaviyoSDKDependencies import UIKit import UserNotifications @@ -29,34 +29,34 @@ enum RetryInfo: Equatable { case retryWithBackoff(requestCount: Int, totalRetryCount: Int, currentBackoff: Int) } -enum KlaviyoAction: Equatable { +enum KlaviyoAction: Equatable, Sendable { /// Sets the API key to state. If the state is already initialized then the push token is moved over to the company with the API key provided in this action. /// Loads the state from disk and carries over existing items from the queue. This emits `completeInitialization` at the end with the state loaded from disk. - case initialize(String) + case initialize(String, AppContextInfo) /// after the SDK is initialized, creates an initial state from existing state from disk (if it exists) and queues up any tasks that are pending case completeInitialization(KlaviyoState) /// if initialized, set the email else queue it up - case setEmail(String) + case setEmail(String, AppContextInfo) /// if initialized set the phone number else queue it up - case setPhoneNumber(String) + case setPhoneNumber(String, AppContextInfo) /// if initialized set the external id else queue it up - case setExternalId(String) + case setExternalId(String, AppContextInfo) /// call when a new push token needs to be set. If this token is the same we don't perform a network request to register the token - case setPushToken(String, PushEnablement) + case setPushToken(String, PushEnablement, PushBackground, AppContextInfo) /// call this to sync the user's local push notification authorization setting with the user's profile on the Klaviyo back-end. - case setPushEnablement(PushEnablement) + case setPushEnablement(PushEnablement, PushBackground, AppContextInfo) /// call to set the app badge count as well as update the stored value in the User Defaults suite case setBadgeCount(Int) /// called when the user wants to reset the existing profile from state - case resetProfile + case resetProfile(AppContextInfo) /// dequeues requests that completed and contuinues to flush other requests if they exist. case deQueueCompletedResults(KlaviyoRequest) @@ -65,7 +65,7 @@ enum KlaviyoAction: Equatable { case networkConnectivityChanged(Reachability.NetworkStatus) /// flushes the queue say when the app is foregrounded or we come back to having network from not having - case flushQueue + case flushQueue(AppContextInfo) /// picks up in flight requests and sends them out. handles errors and if no errors emits a `dequeCompletedResults` case sendRequest @@ -83,10 +83,10 @@ enum KlaviyoAction: Equatable { case requestFailed(KlaviyoRequest, RetryInfo) /// when there is an event to be sent to klaviyo it's added to the queue - case enqueueEvent(Event) + case enqueueEvent(Event, AppContextInfo) /// when there is an profile to be sent to klaviyo it's added to the queue - case enqueueProfile(Profile) + case enqueueProfile(Profile, AppContextInfo) /// when setting individual profile props case setProfileProperty(Profile.ProfileKey, AnyEncodable) @@ -105,7 +105,7 @@ enum KlaviyoAction: Equatable { var requiresInitialization: Bool { switch self { // if event metric is opened push we DON'T require initilization in all other event metric cases we DO. - case let .enqueueEvent(event) where event.metric.name == ._openedPush: + case let .enqueueEvent(event, _) where event.metric.name == ._openedPush: return false case .setEmail, .setPhoneNumber, .setExternalId, .setPushToken, .setPushEnablement, .enqueueProfile, .setProfileProperty, .setBadgeCount, .resetProfile, .resetStateAndDequeue, .enqueueEvent, .fetchForms, .handleFormsResponse: @@ -117,22 +117,30 @@ enum KlaviyoAction: Equatable { } } -struct RequestId {} -struct FlushTimer {} +enum CancelIds { + case request + case timer +} -struct KlaviyoReducer: ReducerProtocol { +struct KlaviyoReducer: Reducer { typealias State = KlaviyoState typealias Action = KlaviyoAction - func reduce(into state: inout KlaviyoState, action: KlaviyoAction) -> EffectTask { + var body: any Reducer { + Reduce { state, action in + reduce(into: &state, action: action) + } + } + + func reduce(into state: inout State, action: Action) -> Effect { if action.requiresInitialization, case .uninitialized = state.initalizationState { - environment.emitDeveloperWarning("SDK must be initialized before usage.") + environment.logger.error("SDK must be initialized before usage.") return .none } switch action { - case let .initialize(apiKey): + case let .initialize(apiKey, appContextInfo): if case .initialized = state.initalizationState { guard apiKey != state.apiKey else { return .none @@ -148,7 +156,7 @@ struct KlaviyoReducer: ReducerProtocol { state.enqueueRequest(request: request) } state.apiKey = apiKey - state.reset() + state.reset(preserveTokenData: true, appContextInfo: appContextInfo) } guard case .uninitialized = state.initalizationState else { return .none @@ -186,72 +194,81 @@ struct KlaviyoReducer: ReducerProtocol { return .run { send in for request in pendingRequests { switch request { - case let .event(event): - await send(.enqueueEvent(event)) - case let .profile(profile): - await send(.enqueueProfile(profile)) - case let .pushToken(token, enablement): - await send(.setPushToken(token, enablement)) - case let .setEmail(email): - await send(.setEmail(email)) - case let .setExternalId(externalId): - await send(.setExternalId(externalId)) - case let .setPhoneNumber(phoneNumber): - await send(.setPhoneNumber(phoneNumber)) + case let .event(event, appContextInfo): + await send(.enqueueEvent(event, appContextInfo)) + case let .profile(profile, appContextInfo): + await send(.enqueueProfile(profile, appContextInfo)) + case let .pushToken(token, enablement, background, appContextInfo): + await send(.setPushToken(token, enablement, background, appContextInfo)) + case let .setEmail(email, appContextInfo): + await send(.setEmail(email, appContextInfo)) + case let .setExternalId(externalId, appContextInfo): + await send(.setExternalId(externalId, appContextInfo)) + case let .setPhoneNumber(phoneNumber, appContextInfo): + await send(.setPhoneNumber(phoneNumber, appContextInfo)) } } await send(.start) } - .merge(with: environment.appLifeCycle.lifeCycleEvents().map(\.transformToKlaviyoAction).eraseToEffect()) - .merge(with: klaviyoSwiftEnvironment.stateChangePublisher().eraseToEffect()) + .merge(with: .run { send in + let lifeCyclePublisher = await MainActor.run { klaviyoSwiftEnvironment.lifeCyclePublisher() + } + for await action in lifeCyclePublisher { + await send(action) + } + }) + .merge(with: .run { _ in + let publisher = await MainActor.run { klaviyoSwiftEnvironment.stateChangePublisher() + } + for await state in publisher { + saveKlaviyoState(state: state) + } + }) - case let .setEmail(email): + case let .setEmail(email, appContextInfo): guard case .initialized = state.initalizationState else { - state.pendingRequests.append(.setEmail(email)) + state.pendingRequests.append(.setEmail(email, appContextInfo)) return .none } - state.updateEmail(email: email) + state.updateEmail(email: email, appContextInfo: appContextInfo) return .none - case let .setPhoneNumber(phoneNumber): + case let .setPhoneNumber(phoneNumber, appContextInfo): guard case .initialized = state.initalizationState else { - state.pendingRequests.append(.setPhoneNumber(phoneNumber)) + state.pendingRequests.append(.setPhoneNumber(phoneNumber, appContextInfo)) return .none } - state.updatePhoneNumber(phoneNumber: phoneNumber) + state.updatePhoneNumber(phoneNumber: phoneNumber, appContextInfo: appContextInfo) return .none - case let .setExternalId(externalId): + case let .setExternalId(externalId, appContextInfo): guard case .initialized = state.initalizationState else { - state.pendingRequests.append(.setExternalId(externalId)) + state.pendingRequests.append(.setExternalId(externalId, appContextInfo)) return .none } - state.updateExternalId(externalId: externalId) + state.updateExternalId(externalId: externalId, appContextInfo: appContextInfo) return .none - case let .setPushToken(pushToken, enablement): + case let .setPushToken(pushToken, enablement, background, appContextInfo): guard case .initialized = state.initalizationState, let apiKey = state.apiKey, let anonymousId = state.anonymousId else { - state.pendingRequests.append(.pushToken(pushToken, enablement)) + state.pendingRequests.append(.pushToken(pushToken, enablement, background, appContextInfo)) return .none } - if !state.shouldSendTokenUpdate(newToken: pushToken, enablement: enablement) { + if !state.shouldSendTokenUpdate(newToken: pushToken, enablement: enablement, appContextInfo: appContextInfo, pushBackground: background) { return .none } - - let request = state.buildTokenRequest(apiKey: apiKey, anonymousId: anonymousId, pushToken: pushToken, enablement: enablement) + let request = state.buildTokenRequest(apiKey: apiKey, anonymousId: anonymousId, pushToken: pushToken, enablement: enablement, background: background, appContextInfo: appContextInfo) state.enqueueRequest(request: request) return .none - case let .setPushEnablement(enablement): + case let .setPushEnablement(enablement, background, appContextInfo): guard let pushToken = state.pushTokenData?.pushToken else { return .none } - return .run { send in - await send(KlaviyoAction.setPushToken(pushToken, enablement)) - } + return .send(KlaviyoAction.setPushToken(pushToken, enablement, background, appContextInfo)) - case .flushQueue: + case let .flushQueue(appContextInfo): guard case .initialized = state.initalizationState else { return .none } @@ -271,7 +288,7 @@ struct KlaviyoReducer: ReducerProtocol { } } if state.pendingProfile != nil { - state.enqueueProfileOrTokenRequest() + state.enqueueProfileOrTokenRequest(appConextInfo: appContextInfo) } if state.queue.isEmpty { @@ -281,39 +298,38 @@ struct KlaviyoReducer: ReducerProtocol { state.requestsInFlight.append(contentsOf: state.queue) state.queue.removeAll() state.flushing = true - return .task { - .sendRequest - } + return .send(.sendRequest) case .stop: guard case .initialized = state.initalizationState else { return .none } - return EffectPublisher.cancel(ids: [RequestId.self, FlushTimer.self]) - .concatenate(with: .run(operation: { send in - await send(.cancelInFlightRequests) - })) - + return Effect.cancel(id: CancelIds.request) + .concatenate(with: Effect.cancel(id: CancelIds.timer)) + .concatenate(with: .send(.cancelInFlightRequests)) case .start: guard case .initialized = state.initalizationState else { return .none } + let flushInterval = state.flushInterval return .merge([ .run { send in let settings = await environment.getNotificationSettings() - await send(KlaviyoAction.setPushEnablement(settings)) + let background = await klaviyoSwiftEnvironment.getBackgroundSetting() + let appContextInfo = await environment.appContextInfo() + await send(KlaviyoAction.setPushEnablement(settings, background, appContextInfo)) let autoclearing = await environment.getBadgeAutoClearingSetting() if autoclearing { await send(KlaviyoAction.setBadgeCount(0)) } }, - environment.timer(state.flushInterval) - .map { _ in - KlaviyoAction.flushQueue + .run { send in + for await _ in environment.timer(flushInterval) { + await send(KlaviyoAction.flushQueue(environment.appContextInfo())) } - .eraseToEffect() - .cancellable(id: FlushTimer.self, cancelInFlight: true) + } + .cancellable(id: CancelIds.timer, cancelInFlight: true) ]) case let .deQueueCompletedResults(completedRequest): @@ -335,7 +351,7 @@ struct KlaviyoReducer: ReducerProtocol { state.flushing = false return .none } - return .task { .sendRequest }.cancellable(id: RequestId.self) + return .send(.sendRequest).cancellable(id: CancelIds.request) case .sendRequest: guard case .initialized = state.initalizationState else { @@ -354,9 +370,9 @@ struct KlaviyoReducer: ReducerProtocol { if case let .retry(attempts) = retryInfo { numAttempts = attempts } - return .run { [numAttempts] send in - let result = await environment.klaviyoAPI.send(request, numAttempts) + let networkSesion = await klaviyoSwiftEnvironment.networkSession() + let result = await environment.klaviyoAPI.send(networkSesion, request, numAttempts) switch result { case let .success(data): do { @@ -368,9 +384,9 @@ struct KlaviyoReducer: ReducerProtocol { break } } catch let error as DecodingError { - environment.emitDeveloperWarning("Error decoding JSON response: \(error.localizedDescription)") + environment.logger.error("Error decoding JSON response: \(error.localizedDescription)") } catch { - environment.emitDeveloperWarning("An unexpected error occurred: \(error.localizedDescription)") + environment.logger.error("An unexpected error occurred: \(error.localizedDescription)") } await send(.deQueueCompletedResults(request)) @@ -379,9 +395,9 @@ struct KlaviyoReducer: ReducerProtocol { } } catch: { error, send in // For now assuming this is cancellation since nothing else can throw AFAICT - environment.emitDeveloperWarning("Unknown error thrown during request processing \(error)") + environment.logger.error("Unknown error thrown during request processing \(error)") await send(.cancelInFlightRequests) - }.cancellable(id: RequestId.self) + }.cancellable(id: CancelIds.request) case .cancelInFlightRequests: state.flushing = false @@ -396,20 +412,21 @@ struct KlaviyoReducer: ReducerProtocol { switch networkStatus { case .notReachable: state.flushInterval = Double.infinity - return EffectPublisher.cancel(ids: [RequestId.self, FlushTimer.self]) - .concatenate(with: .run { send in - await send(.cancelInFlightRequests) - }) + return Effect.cancel(id: CancelIds.request) + .concatenate(with: Effect.cancel(id: CancelIds.timer)) + .concatenate(with: .send(.cancelInFlightRequests)) case .reachableViaWiFi: state.flushInterval = StateManagementConstants.wifiFlushInterval case .reachableViaWWAN: state.flushInterval = StateManagementConstants.cellularFlushInterval } - return environment.timer(state.flushInterval) - .map { _ in - KlaviyoAction.flushQueue - }.eraseToEffect() - .cancellable(id: FlushTimer.self, cancelInFlight: true) + let flushInterval = state.flushInterval + return .run { send in + for await _ in environment.timer(flushInterval) { + await send(KlaviyoAction.flushQueue(environment.appContextInfo())) + } + } + .cancellable(id: CancelIds.timer, cancelInFlight: true) case let .requestFailed(request, retryInfo): var exceededRetries = false @@ -431,12 +448,12 @@ struct KlaviyoReducer: ReducerProtocol { state.requestsInFlight = [] return .none - case var .enqueueEvent(event): + case .enqueueEvent(var event, let appContextInfo): guard case .initialized = state.initalizationState, let apiKey = state.apiKey, let anonymousId = state.anonymousId else { - state.pendingRequests.append(.event(event)) + state.pendingRequests.append(.event(event, appContextInfo)) return .none } @@ -453,11 +470,11 @@ struct KlaviyoReducer: ReducerProtocol { value: event.value, time: event.time, uniqueId: event.uniqueId, - pushToken: state.pushTokenData?.pushToken)) + pushToken: state.pushTokenData?.pushToken, + appContextInfo: appContextInfo)) let endpoint = KlaviyoEndpoint.createEvent(payload) - let request = KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) - + let request = KlaviyoRequest(apiKey: apiKey, endpoint: endpoint, uuid: environment.uuid().uuidString) state.enqueueRequest(request: request) /* @@ -465,17 +482,17 @@ struct KlaviyoReducer: ReducerProtocol { we don't miss any user engagement events. In all other cases we will flush the queue using the flush intervals defined above in `StateManagementConstants` */ - return event.metric.name == ._openedPush ? .task { .flushQueue } : .none + return event.metric.name == ._openedPush ? .send(.flushQueue(appContextInfo)) : .none - case let .enqueueProfile(profile): + case let .enqueueProfile(profile, appContextInfo): guard case .initialized = state.initalizationState else { - state.pendingRequests.append(.profile(profile)) + state.pendingRequests.append(.profile(profile, appContextInfo)) return .none } let pushTokenData = state.pushTokenData - state.reset(preserveTokenData: false) + state.reset(preserveTokenData: false, appContextInfo: appContextInfo) state.updateStateWithProfile(profile: profile) guard let anonymousId = state.anonymousId, let apiKey = state.apiKey @@ -495,14 +512,14 @@ struct KlaviyoReducer: ReducerProtocol { pushToken: tokenData.pushToken, enablement: tokenData.pushEnablement.rawValue, background: tokenData.pushBackground.rawValue, - profile: profilePayload) + profile: profilePayload, appContextInfo: appContextInfo) request = KlaviyoRequest( apiKey: apiKey, - endpoint: KlaviyoEndpoint.registerPushToken(payload)) + endpoint: .registerPushToken(payload), uuid: environment.uuid().uuidString) } else { request = KlaviyoRequest( apiKey: apiKey, - endpoint: KlaviyoEndpoint.createProfile(CreateProfilePayload(data: profilePayload))) + endpoint: .createProfile(CreateProfilePayload(data: profilePayload)), uuid: environment.uuid().uuidString) } state.enqueueRequest(request: request) @@ -510,15 +527,15 @@ struct KlaviyoReducer: ReducerProtocol { case let .setBadgeCount(count): return .run { _ in - _ = klaviyoSwiftEnvironment.setBadgeCount(count) + _ = await klaviyoSwiftEnvironment.setBadgeCount(count) } - case .resetProfile: + case let .resetProfile(appContextInfo): guard case .initialized = state.initalizationState else { return .none } - state.reset() + state.reset(appContextInfo: appContextInfo) return .none case let .setProfileProperty(key, value): @@ -540,7 +557,7 @@ struct KlaviyoReducer: ReducerProtocol { } } - return .task { .deQueueCompletedResults(request) } + return .send(.deQueueCompletedResults(request)) case .fetchForms: guard case .initialized = state.initalizationState, @@ -549,7 +566,7 @@ struct KlaviyoReducer: ReducerProtocol { return .none } - let request = KlaviyoRequest(apiKey: apiKey, endpoint: .fetchForms) + let request = KlaviyoRequest(apiKey: apiKey, endpoint: .fetchForms, uuid: environment.uuid().uuidString) state.enqueueRequest(request: request) return .none @@ -580,9 +597,9 @@ struct KlaviyoReducer: ReducerProtocol { } extension Store where State == KlaviyoState, Action == KlaviyoAction { - static let production = Store( - initialState: KlaviyoState(queue: [], requestsInFlight: []), - reducer: KlaviyoReducer()) + static let production = Store(initialState: KlaviyoState(queue: [])) { + KlaviyoReducer() + } } extension Event { diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Cancellation.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Cancellation.swift deleted file mode 100644 index a5cdcfcf..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Cancellation.swift +++ /dev/null @@ -1,332 +0,0 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -// -// Cancellation.swift -// Pulled from https://github.com/pointfreeco/swift-composable-architecture -// -// Created by Noah Durell on 12/7/22. -// - -import Foundation - -import Combine -import Foundation - -extension EffectPublisher { - /// Turns an effect into one that is capable of being canceled. - /// - /// To turn an effect into a cancellable one you must provide an identifier, which is used in - /// ``EffectPublisher/cancel(id:)-6hzsl`` to identify which in-flight effect should be canceled. - /// Any hashable value can be used for the identifier, such as a string, but you can add a bit of - /// protection against typos by defining a new type for the identifier: - /// - /// ```swift - /// struct LoadUserID {} - /// - /// case .reloadButtonTapped: - /// // Start a new effect to load the user - /// return self.apiClient.loadUser() - /// .map(Action.userResponse) - /// .cancellable(id: LoadUserID.self, cancelInFlight: true) - /// - /// case .cancelButtonTapped: - /// // Cancel any in-flight requests to load the user - /// return .cancel(id: LoadUserID.self) - /// ``` - /// - /// - Parameters: - /// - id: The effect's identifier. - /// - cancelInFlight: Determines if any in-flight effect with the same identifier should be - /// canceled before starting this new one. - /// - Returns: A new effect that is capable of being canceled by an identifier. - func cancellable(id: AnyHashable, cancelInFlight: Bool = false) -> Self { - switch self.operation { - case .none: - return .none - case let .publisher(publisher): - return Self( - operation: .publisher( - Deferred { - () - -> Publishers.HandleEvents< - Publishers.PrefixUntilOutput< - AnyPublisher, PassthroughSubject - > - > in - _cancellablesLock.lock() - defer { _cancellablesLock.unlock() } - - let id = _CancelToken(id: id) - if cancelInFlight { - _cancellationCancellables[id]?.forEach { $0.cancel() } - } - - let cancellationSubject = PassthroughSubject() - - var cancellationCancellable: AnyCancellable! - cancellationCancellable = AnyCancellable { - _cancellablesLock.sync { - cancellationSubject.send(()) - cancellationSubject.send(completion: .finished) - _cancellationCancellables[id]?.remove(cancellationCancellable) - if _cancellationCancellables[id]?.isEmpty == .some(true) { - _cancellationCancellables[id] = nil - } - } - } - - return publisher.prefix(untilOutputFrom: cancellationSubject) - .handleEvents( - receiveSubscription: { _ in - _ = _cancellablesLock.sync { - _cancellationCancellables[id, default: []].insert( - cancellationCancellable - ) - } - }, - receiveCompletion: { _ in cancellationCancellable.cancel() }, - receiveCancel: cancellationCancellable.cancel - ) - } - .eraseToAnyPublisher() - ) - ) - case let .run(priority, operation): - return Self( - operation: .run(priority) { send in - await withTaskCancellation(id: id, cancelInFlight: cancelInFlight) { - await operation(send) - } - } - ) - } - } - - /// Turns an effect into one that is capable of being canceled. - /// - /// A convenience for calling ``EffectPublisher/cancellable(id:cancelInFlight:)-29q60`` with a - /// static type as the effect's unique identifier. - /// - /// - Parameters: - /// - id: A unique type identifying the effect. - /// - cancelInFlight: Determines if any in-flight effect with the same identifier should be - /// canceled before starting this new one. - /// - Returns: A new effect that is capable of being canceled by an identifier. - func cancellable(id: Any.Type, cancelInFlight: Bool = false) -> Self { - self.cancellable(id: ObjectIdentifier(id), cancelInFlight: cancelInFlight) - } - - /// An effect that will cancel any currently in-flight effect with the given identifier. - /// - /// - Parameter id: An effect identifier. - /// - Returns: A new effect that will cancel any currently in-flight effect with the given - /// identifier. - static func cancel(id: AnyHashable) -> Self { - .fireAndForget { - _cancellablesLock.sync { - _cancellationCancellables[.init(id: id)]?.forEach { $0.cancel() } - } - } - } - - /// An effect that will cancel any currently in-flight effect with the given identifier. - /// - /// A convenience for calling ``EffectPublisher/cancel(id:)-6hzsl`` with a static type as the - /// effect's unique identifier. - /// - /// - Parameter id: A unique type identifying the effect. - /// - Returns: A new effect that will cancel any currently in-flight effect with the given - /// identifier. - static func cancel(id: Any.Type) -> Self { - .cancel(id: ObjectIdentifier(id)) - } - - /// An effect that will cancel multiple currently in-flight effects with the given identifiers. - /// - /// - Parameter ids: An array of effect identifiers. - /// - Returns: A new effect that will cancel any currently in-flight effects with the given - /// identifiers. - static func cancel(ids: [AnyHashable]) -> Self { - .merge(ids.map(EffectPublisher.cancel(id:))) - } - - /// An effect that will cancel multiple currently in-flight effects with the given identifiers. - /// - /// A convenience for calling ``EffectPublisher/cancel(ids:)-1cqqx`` with a static type as the - /// effect's unique identifier. - /// - /// - Parameter ids: An array of unique types identifying the effects. - /// - Returns: A new effect that will cancel any currently in-flight effects with the given - /// identifiers. - static func cancel(ids: [Any.Type]) -> Self { - .merge(ids.map(EffectPublisher.cancel(id:))) - } -} - -/// Execute an operation with a cancellation identifier. -/// -/// If the operation is in-flight when `Task.cancel(id:)` is called with the same identifier, or -/// operation will be cancelled. -/// -/// ``` -/// enum CancelID.self {} -/// -/// await withTaskCancellation(id: CancelID.self) { -/// // ... -/// } -/// ``` -/// -/// ### Debouncing tasks -/// -/// When paired with a clock, this function can be used to debounce a unit of async work by -/// specifying the `cancelInFlight`, which will automatically cancel any in-flight work with the -/// same identifier: -/// -/// ```swift -/// @Dependency(\.continuousClock) var clock -/// enum CancelID {} -/// -/// // ... -/// -/// return .task { -/// await withTaskCancellation(id: CancelID.self, cancelInFlight: true) { -/// try await self.clock.sleep(for: .seconds(0.3)) -/// return await .debouncedResponse( -/// TaskResult { try await environment.request() } -/// ) -/// } -/// } -/// ``` -/// -/// - Parameters: -/// - id: A unique identifier for the operation. -/// - cancelInFlight: Determines if any in-flight operation with the same identifier should be -/// canceled before starting this new one. -/// - operation: An async operation. -/// - Throws: An error thrown by the operation. -/// - Returns: A value produced by operation. -func withTaskCancellation( - id: AnyHashable, - cancelInFlight: Bool = false, - operation: @Sendable @escaping () async throws -> T -) async rethrows -> T { - let id = _CancelToken(id: id) - let (cancellable, task) = _cancellablesLock.sync { () -> (AnyCancellable, Task) in - if cancelInFlight { - _cancellationCancellables[id]?.forEach { $0.cancel() } - } - let task = Task { try await operation() } - let cancellable = AnyCancellable { task.cancel() } - _cancellationCancellables[id, default: []].insert(cancellable) - return (cancellable, task) - } - defer { - _cancellablesLock.sync { - _cancellationCancellables[id]?.remove(cancellable) - if _cancellationCancellables[id]?.isEmpty == .some(true) { - _cancellationCancellables[id] = nil - } - } - } - do { - return try await task.cancellableValue - } catch { - return try Result.failure(error)._rethrowGet() - } -} - -/// Execute an operation with a cancellation identifier. -/// -/// A convenience for calling ``withTaskCancellation(id:cancelInFlight:operation:)-4dtr6`` with a -/// static type as the operation's unique identifier. -/// -/// - Parameters: -/// - id: A unique type identifying the operation. -/// - cancelInFlight: Determines if any in-flight operation with the same identifier should be -/// canceled before starting this new one. -/// - operation: An async operation. -/// - Throws: An error thrown by the operation. -/// - Returns: A value produced by operation. -func withTaskCancellation( - id: Any.Type, - cancelInFlight: Bool = false, - operation: @Sendable @escaping () async throws -> T -) async rethrows -> T { - try await withTaskCancellation( - id: ObjectIdentifier(id), - cancelInFlight: cancelInFlight, - operation: operation - ) -} - -extension Task where Success == Never, Failure == Never { - /// Cancel any currently in-flight operation with the given identifier. - /// - /// - Parameter id: An identifier. - static func cancel(id: ID) { - _cancellablesLock.sync { _cancellationCancellables[.init(id: id)]?.forEach { $0.cancel() } } - } - - /// Cancel any currently in-flight operation with the given identifier. - /// - /// A convenience for calling `Task.cancel(id:)` with a static type as the operation's unique - /// identifier. - /// - /// - Parameter id: A unique type identifying the operation. - static func cancel(id: Any.Type) { - self.cancel(id: ObjectIdentifier(id)) - } -} - -struct _CancelToken: Hashable { - let id: AnyHashable - let discriminator: ObjectIdentifier - - init(id: AnyHashable) { - self.id = id - self.discriminator = ObjectIdentifier(type(of: id.base)) - } -} - -var _cancellationCancellables: [_CancelToken: Set] = [:] -let _cancellablesLock = NSRecursiveLock() - -@rethrows -private protocol _ErrorMechanism { - associatedtype Output - func get() throws -> Output -} - -extension _ErrorMechanism { - func _rethrowError() rethrows -> Never { - _ = try _rethrowGet() - fatalError() - } - - func _rethrowGet() rethrows -> Output { - return try get() - } -} - -extension Result: _ErrorMechanism {} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ConcurrencySupport.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ConcurrencySupport.swift deleted file mode 100644 index 6cad8bbf..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ConcurrencySupport.swift +++ /dev/null @@ -1,356 +0,0 @@ -// -// ConcurrencySupport.swift -// -// -// Created by Noah Durell on 12/7/22. -// - -import Foundation - -extension AsyncStream { - - /// Constructs and returns a stream along with its backing continuation. - /// - /// This is handy for immediately escaping the continuation from an async stream, which typically - /// requires multiple steps: - /// - /// ```swift - /// var _continuation: AsyncStream.Continuation! - /// let stream = AsyncStream { continuation = $0 } - /// let continuation = _continuation! - /// - /// // vs. - /// - /// let (stream, continuation) = AsyncStream.streamWithContinuation() - /// ``` - /// - /// This tool is usually used for tests where we need to supply an async sequence to a dependency - /// endpoint and get access to its continuation so that we can emulate the dependency - /// emitting data. For example, suppose you have a dependency exposing an async sequence for - /// listening to notifications. To test this you can use `streamWithContinuation`: - /// - /// ```swift - /// let notifications = AsyncStream.streamWithContinuation() - /// - /// let store = TestStore( - /// initialState: Feature.State(), - /// reducer: Feature() - /// ) - /// - /// store.dependencies.notifications = { notifications.stream } - /// - /// await store.send(.task) - /// notifications.continuation.yield("Hello") // Simulate notification being posted - /// await store.receive(.notification("Hello")) { - /// $0.message = "Hello" - /// } - /// ``` - /// - /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only use - /// > this helper to test features that do not subscribe multiple times to the dependency - /// > endpoint. - /// - /// - Parameters: - /// - elementType: The type of element the `AsyncStream` produces. - /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By - /// default, the stream buffers an unlimited number of elements. You can also set the policy to - /// buffer a specified number of oldest or newest elements. - /// - Returns: An `AsyncStream`. - static func streamWithContinuation( - _ elementType: Element.Type = Element.self, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) -> (stream: Self, continuation: Continuation) { - var continuation: Continuation! - return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) - } - - /// An `AsyncStream` that never emits and never completes unless cancelled. - static var never: Self { - Self { _ in } - } - - static var finished: Self { - Self { $0.finish() } - } -} - -extension AsyncThrowingStream where Failure == Error { - /// Initializes an `AsyncThrowingStream` from any `AsyncSequence`. - /// - /// - Parameters: - /// - sequence: An `AsyncSequence`. - /// - limit: The maximum number of elements to hold in the buffer. By default, this value is - /// unlimited. Use a `Continuation.BufferingPolicy` to buffer a specified number of oldest or - /// newest elements. - init( - _ sequence: S, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) where S.Element == Element { - self.init(bufferingPolicy: limit) { (continuation: Continuation) in - let task = Task { - do { - for try await element in sequence { - continuation.yield(element) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = - { _ in - task.cancel() - } - // NB: This explicit cast is needed to work around a compiler bug in Swift 5.5.2 - as @Sendable (Continuation.Termination) -> Void - } - } - - /// Constructs and returns a stream along with its backing continuation. - /// - /// This is handy for immediately escaping the continuation from an async stream, which typically - /// requires multiple steps: - /// - /// ```swift - /// var _continuation: AsyncThrowingStream.Continuation! - /// let stream = AsyncThrowingStream { continuation = $0 } - /// let continuation = _continuation! - /// - /// // vs. - /// - /// let (stream, continuation) = AsyncThrowingStream.streamWithContinuation() - /// ``` - /// - /// This tool is usually used for tests where we need to supply an async sequence to a dependency - /// endpoint and get access to its continuation so that we can emulate the dependency - /// emitting data. For example, suppose you have a dependency exposing an async sequence for - /// listening to notifications. To test this you can use `streamWithContinuation`: - /// - /// ```swift - /// let notifications = AsyncThrowingStream.streamWithContinuation() - /// - /// let store = TestStore( - /// initialState: Feature.State(), - /// reducer: Feature() - /// ) - /// - /// store.dependencies.notifications = { notifications.stream } - /// - /// await store.send(.task) - /// notifications.continuation.yield("Hello") // Simulate a notification being posted - /// await store.receive(.notification("Hello")) { - /// $0.message = "Hello" - /// } - /// ``` - /// - /// > Warning: ⚠️ `AsyncStream` does not support multiple subscribers, therefore you can only use - /// > this helper to test features that do not subscribe multiple times to the dependency - /// > endpoint. - /// - /// - Parameters: - /// - elementType: The type of element the `AsyncThrowingStream` produces. - /// - limit: A Continuation.BufferingPolicy value to set the stream’s buffering behavior. By - /// default, the stream buffers an unlimited number of elements. You can also set the policy to - /// buffer a specified number of oldest or newest elements. - /// - Returns: An `AsyncThrowingStream`. - static func streamWithContinuation( - _ elementType: Element.Type = Element.self, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) -> (stream: Self, continuation: Continuation) { - var continuation: Continuation! - return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) - } - - /// An `AsyncThrowingStream` that never emits and never completes unless cancelled. - static var never: Self { - Self { _ in } - } - - static var finished: Self { - Self { $0.finish() } - } -} - -extension Task where Failure == Never { - /// An async function that never returns. - static func never() async throws -> Success { - for await element in AsyncStream.never { - return element - } - throw _Concurrency.CancellationError() - } -} - -extension Task where Success == Never, Failure == Never { - /// An async function that never returns. - static func never() async throws { - for await _ in AsyncStream.never {} - throw _Concurrency.CancellationError() - } -} - -/// A generic wrapper for isolating a mutable value to an actor. -/// -/// This type is most useful when writing tests for when you want to inspect what happens inside -/// an effect. For example, suppose you have a feature such that when a button is tapped you -/// track some analytics: -/// -/// ```swift -/// @Dependency(\.analytics) var analytics -/// -/// func reduce(into state: inout State, action: Action) -> EffectTask { -/// switch action { -/// case .buttonTapped: -/// return .fireAndForget { try await self.analytics.track("Button Tapped") } -/// } -/// } -/// ``` -/// -/// Then, in tests we can construct an analytics client that appends events to a mutable array -/// rather than actually sending events to an analytics server. However, in order to do this in -/// a safe way we should use an actor, and ``ActorIsolated`` makes this easy: -/// -/// ```swift -/// @MainActor -/// func testAnalytics() async { -/// let store = TestStore(…) -/// -/// let events = ActorIsolated<[String]>([]) -/// store.dependencies.analytics = AnalyticsClient( -/// track: { event in -/// await events.withValue { $0.append(event) } -/// } -/// ) -/// -/// await store.send(.buttonTapped) -/// -/// await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } -/// } -/// ``` -@dynamicMemberLookup -final actor ActorIsolated { - /// The actor-isolated value. - var value: Value - - /// Initializes actor-isolated state around a value. - /// - /// - Parameter value: A value to isolate in an actor. - init(_ value: Value) { - self.value = value - } - - subscript(dynamicMember keyPath: KeyPath) -> Subject { - self.value[keyPath: keyPath] - } - - /// Perform an operation with isolated access to the underlying value. - /// - /// Useful for inspecting an actor-isolated value for a test assertion: - /// - /// ```swift - /// let didOpenSettings = ActorIsolated(false) - /// store.dependencies.openSettings = { await didOpenSettings.setValue(true) } - /// - /// await store.send(.settingsButtonTapped) - /// - /// await didOpenSettings.withValue { XCTAssertTrue($0) } - /// ``` - /// - /// - Parameters: operation: An operation to be performed on the actor with the underlying value. - /// - Returns: The result of the operation. - func withValue( - _ operation: @Sendable (inout Value) async throws -> T - ) async rethrows -> T { - var value = self.value - defer { self.value = value } - return try await operation(&value) - } - - /// Overwrite the isolated value with a new value. - /// - /// Useful for setting an actor-isolated value when a tested dependency runs. - /// - /// ```swift - /// let didOpenSettings = ActorIsolated(false) - /// store.dependencies.openSettings = { await didOpenSettings.setValue(true) } - /// - /// await store.send(.settingsButtonTapped) - /// - /// await didOpenSettings.withValue { XCTAssertTrue($0) } - /// ``` - /// - /// - Parameter newValue: The value to replace the current isolated value with. - func setValue(_ newValue: Value) { - self.value = newValue - } -} - -/// A generic wrapper for turning any non-`Sendable` type into a `Sendable` one, in an unchecked -/// manner. -/// -/// Sometimes we need to use types that should be sendable but have not yet been audited for -/// sendability. If we feel confident that the type is truly sendable, and we don't want to blanket -/// disable concurrency warnings for a module via `@preconcurrency import`, then we can selectively -/// make that single type sendable by wrapping it in ``UncheckedSendable``. -/// -/// > Note: By wrapping something in ``UncheckedSendable`` you are asking the compiler to trust -/// you that the type is safe to use from multiple threads, and the compiler cannot help you find -/// potential race conditions in your code. -@dynamicMemberLookup -@propertyWrapper -struct UncheckedSendable: @unchecked Sendable { - /// The unchecked value. - var value: Value - - init(_ value: Value) { - self.value = value - } - - init(wrappedValue: Value) { - self.value = wrappedValue - } - - var wrappedValue: Value { - _read { yield self.value } - _modify { yield &self.value } - } - - var projectedValue: Self { - get { self } - set { self = newValue } - } - - subscript(dynamicMember keyPath: KeyPath) -> Subject { - self.value[keyPath: keyPath] - } - - subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { - _read { yield self.value[keyPath: keyPath] } - _modify { yield &self.value[keyPath: keyPath] } - } -} - -extension UncheckedSendable: Equatable where Value: Equatable {} -extension UncheckedSendable: Hashable where Value: Hashable {} - -extension UncheckedSendable: Decodable where Value: Decodable { - init(from decoder: Decoder) throws { - do { - let container = try decoder.singleValueContainer() - self.init(wrappedValue: try container.decode(Value.self)) - } catch { - self.init(wrappedValue: try Value(from: decoder)) - } - } -} - -extension UncheckedSendable: Encodable where Value: Encodable { - func encode(to encoder: Encoder) throws { - do { - var container = encoder.singleValueContainer() - try container.encode(self.wrappedValue) - } catch { - try self.wrappedValue.encode(to: encoder) - } - } -} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Effect.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Effect.swift deleted file mode 100644 index 0511f87e..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Effect.swift +++ /dev/null @@ -1,491 +0,0 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -// -// Effect.swift -// -// Misc items pulled from https://github.com/pointfreeco/swift-composable-architecture related to the store. -// Created by Noah Durell on 12/6/22. -// - -import Combine -import Foundation - -/// A type that encapsulates a unit of work that can be run in the outside world, and can feed -/// actions back to the ``Store``. -/// -/// Effects are the perfect place to do side effects, such as network requests, saving/loading -/// from disk, creating timers, interacting with dependencies, and more. They are returned from -/// reducers so that the ``Store`` can perform the effects after the reducer is done running. -/// -/// There are 2 distinct ways to create an `Effect`: one using Swift's native concurrency tools, and -/// the other using Apple's Combine framework: -/// -/// * If using Swift's native structured concurrency tools then there are 3 main ways to create an -/// effect, depending on if you want to emit one single action back into the system, or any number -/// of actions, or just execute some work without emitting any actions: -/// * ``EffectPublisher/task(priority:operation:catch:file:fileID:line:)`` -/// * ``EffectPublisher/run(priority:operation:catch:file:fileID:line:)`` -/// * ``EffectPublisher/fireAndForget(priority:_:)`` -/// * If using Combine in your application, in particular for the dependencies of your feature -/// then you can create effects by making use of any of Combine's operators, and then erasing the -/// publisher type to ``EffectPublisher`` with either `eraseToEffect` or `catchToEffect`. Note that -/// the Combine interface to ``EffectPublisher`` is considered soft deprecated, and you should -/// eventually port to Swift's native concurrency tools. -/// -/// > Important: ``Store`` is not thread safe, and so all effects must receive values on the same -/// thread. This is typically the main thread, **and** if the store is being used to drive UI then -/// it must receive values on the main thread. -/// > -/// > This is only an issue if using the Combine interface of ``EffectPublisher`` as mentioned -/// above. If you are using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` -/// functions on ``EffectTask``, then threading is automatically handled for you. -struct EffectPublisher { - @usableFromInline - enum Operation { - case none - case publisher(AnyPublisher) - case run(TaskPriority? = nil, @Sendable (Send) async -> Void) - } - - @usableFromInline - let operation: Operation - - @usableFromInline - init(operation: Operation) { - self.operation = operation - } -} - -// MARK: - Creating Effects - -extension EffectPublisher { - /// An effect that does nothing and completes immediately. Useful for situations where you must - /// return an effect, but you don't need to do anything. - @inlinable - static var none: Self { - Self(operation: .none) - } -} - -/// A convenience type alias for referring to an effect that can never fail, like the kind of -/// ``EffectPublisher`` returned by a reducer after processing an action. -/// -/// Instead of specifying `Never` as `Failure`: -/// -/// ```swift -/// func reduce(into state: inout State, action: Action) -> EffectPublisher { … } -/// ``` -/// -/// You can specify a single generic: -/// -/// ```swift -/// func reduce(into state: inout State, action: Action) -> EffectTask { … } -/// ``` -typealias EffectTask = EffectPublisher - -extension EffectPublisher where Failure == Never { - /// Wraps an asynchronous unit of work in an effect. - /// - /// This function is useful for executing work in an asynchronous context and capturing the result - /// in an ``EffectTask`` so that the reducer, a non-asynchronous context, can process it. - /// - /// For example, if your dependency exposes an `async` function, you can use - /// ``task(priority:operation:catch:file:fileID:line:)`` to provide an asynchronous context for - /// invoking that endpoint: - /// - /// ```swift - /// struct Feature: ReducerProtocol { - /// struct State { … } - /// enum FeatureAction { - /// case factButtonTapped - /// case faceResponse(TaskResult) - /// } - /// @Dependency(\.numberFact) var numberFact - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .factButtonTapped: - /// return .task { [number = state.number] in - /// await .factResponse(TaskResult { try await self.numberFact.fetch(number) }) - /// } - /// - /// case .factResponse(.success(fact)): - /// // do something with fact - /// - /// case .factResponse(.failure): - /// // handle error - /// - /// ... - /// } - /// } - /// } - /// ``` - /// - /// The above code sample makes use of ``TaskResult`` in order to automatically bundle the success - /// or failure of the `numberFact` endpoint into a single type that can be sent in an action. - /// - /// The closure provided to ``task(priority:operation:catch:file:fileID:line:)`` is allowed to - /// throw, but any non-cancellation errors thrown will cause a runtime warning when run in the - /// simulator or on a device, and will cause a test failure in tests. To catch non-cancellation - /// errors use the `catch` trailing closure. - /// - /// - Parameters: - /// - priority: Priority of the underlying task. If `nil`, the priority will come from - /// `Task.currentPriority`. - /// - operation: The operation to execute. - /// - catch: An error handler, invoked if the operation throws an error other than - /// `CancellationError`. - /// - Returns: An effect wrapping the given asynchronous work. - static func task( - priority: TaskPriority? = nil, - operation: @escaping @Sendable () async throws -> Action, - catch handler: (@Sendable (Error) async -> Action)? = nil, - file: StaticString = #file, - fileID: StaticString = #fileID, - line: UInt = #line - ) -> Self { - return Self( - operation: .run(priority) { send in - do { - try await send(operation()) - } catch is CancellationError { - return - } catch { - guard let handler = handler else { - #if DEBUG -// var errorDump = "" -// customDump(error, to: &errorDump, indent: 4) - runtimeWarn( - """ - An "EffectTask.task" returned from "\(fileID):\(line)" threw an unhandled error. … - - \(error) - - All non-cancellation errors must be explicitly handled via the "catch" parameter \ - on "EffectTask.task", or via a "do" block. - """, - file: file, - line: line - ) - #endif - return - } - await send(handler(error)) - } - } - ) - } - - /// Wraps an asynchronous unit of work that can emit any number of times in an effect. - /// - /// This effect is similar to ``task(priority:operation:catch:file:fileID:line:)`` except it is - /// capable of emitting 0 or more times, not just once. - /// - /// For example, if you had an async stream in a dependency client: - /// - /// ```swift - /// struct EventsClient { - /// var events: () -> AsyncStream - /// } - /// ``` - /// - /// Then you could attach to it in a `run` effect by using `for await` and sending each action of - /// the stream back into the system: - /// - /// ```swift - /// case .startButtonTapped: - /// return .run { send in - /// for await event in self.events() { - /// send(.event(event)) - /// } - /// } - /// ``` - /// - /// See ``Send`` for more information on how to use the `send` argument passed to `run`'s closure. - /// - /// The closure provided to ``run(priority:operation:catch:file:fileID:line:)`` is allowed to - /// throw, but any non-cancellation errors thrown will cause a runtime warning when run in the - /// simulator or on a device, and will cause a test failure in tests. To catch non-cancellation - /// errors use the `catch` trailing closure. - /// - /// - Parameters: - /// - priority: Priority of the underlying task. If `nil`, the priority will come from - /// `Task.currentPriority`. - /// - operation: The operation to execute. - /// - catch: An error handler, invoked if the operation throws an error other than - /// `CancellationError`. - /// - Returns: An effect wrapping the given asynchronous work. - static func run( - priority: TaskPriority? = nil, - operation: @escaping @Sendable (Send) async throws -> Void, - catch handler: (@Sendable (Error, Send) async -> Void)? = nil, - file: StaticString = #file, - fileID: StaticString = #fileID, - line: UInt = #line - ) -> Self { - return Self( - operation: .run(priority) { send in - do { - try await operation(send) - } catch is CancellationError { - return - } catch { - guard let handler = handler else { - #if DEBUG -// var errorDump = "" -// customDump(error, to: &errorDump, indent: 4) - runtimeWarn( - """ - An "EffectTask.run" returned from "\(fileID):\(line)" threw an unhandled error. … - - \(error) - - All non-cancellation errors must be explicitly handled via the "catch" parameter \ - on "EffectTask.run", or via a "do" block. - """, - file: file, - line: line - ) - #endif - return - } - await handler(error, send) - } - } - ) - } - - /// Creates an effect that executes some work in the real world that doesn't need to feed data - /// back into the store. If an error is thrown, the effect will complete and the error will be - /// ignored. - /// - /// This effect is handy for executing some asynchronous work that your feature doesn't need to - /// react to. One such example is analytics: - /// - /// ```swift - /// case .buttonTapped: - /// return .fireAndForget { - /// try self.analytics.track("Button Tapped") - /// } - /// ``` - /// - /// The closure provided to ``fireAndForget(priority:_:)`` is allowed to throw, and any error - /// thrown will be ignored. - /// - /// - Parameters: - /// - priority: Priority of the underlying task. If `nil`, the priority will come from - /// `Task.currentPriority`. - /// - work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - static func fireAndForget( - priority: TaskPriority? = nil, - _ work: @escaping @Sendable () async throws -> Void - ) -> Self { - Self.run(priority: priority) { _ in try? await work() } - } -} - -/// A type that can send actions back into the system when used from -/// ``EffectPublisher/run(priority:operation:catch:file:fileID:line:)``. -/// -/// This type implements [`callAsFunction`][callAsFunction] so that you invoke it as a function -/// rather than calling methods on it: -/// -/// ```swift -/// return .run { send in -/// send(.started) -/// defer { send(.finished) } -/// for await event in self.events { -/// send(.event(event)) -/// } -/// } -/// ``` -/// -/// You can also send actions with animation: -/// -/// ```swift -/// send(.started, animation: .spring()) -/// defer { send(.finished, animation: .default) } -/// ``` -/// -/// See ``EffectPublisher/run(priority:operation:catch:file:fileID:line:)`` for more information on how to -/// use this value to construct effects that can emit any number of times in an asynchronous -/// context. -/// -/// [callAsFunction]: https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622 -@MainActor -struct Send { - let send: @MainActor (Action) -> Void - - init(send: @escaping @MainActor (Action) -> Void) { - self.send = send - } - - /// Sends an action back into the system from an effect. - /// - /// - Parameter action: An action. - func callAsFunction(_ action: Action) { - guard !Task.isCancelled else { return } - self.send(action) - } -} - -// MARK: - Composing Effects - -extension EffectPublisher { - /// Merges a variadic list of effects together into a single effect, which runs the effects at the - /// same time. - /// - /// - Parameter effects: A list of effects. - /// - Returns: A new effect - @inlinable - static func merge(_ effects: Self...) -> Self { - Self.merge(effects) - } - - /// Merges a sequence of effects together into a single effect, which runs the effects at the same - /// time. - /// - /// - Parameter effects: A sequence of effects. - /// - Returns: A new effect - @inlinable - static func merge(_ effects: S) -> Self where S.Element == Self { - effects.reduce(.none) { $0.merge(with: $1) } - } - - /// Merges this effect and another into a single effect that runs both at the same time. - /// - /// - Parameter other: Another effect. - /// - Returns: An effect that runs this effect and the other at the same time. - @inlinable - func merge(with other: Self) -> Self { - switch (self.operation, other.operation) { - case (_, .none): - return self - case (.none, _): - return other - case (.publisher, .publisher), (.run, .publisher), (.publisher, .run): - return Self(operation: .publisher(Publishers.Merge(self, other).eraseToAnyPublisher())) - case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): - return Self( - operation: .run { send in - await withTaskGroup(of: Void.self) { group in - group.addTask(priority: lhsPriority) { - await lhsOperation(send) - } - group.addTask(priority: rhsPriority) { - await rhsOperation(send) - } - } - } - ) - } - } - - /// Concatenates a variadic list of effects together into a single effect, which runs the effects - /// one after the other. - /// - /// - Parameter effects: A variadic list of effects. - /// - Returns: A new effect - @inlinable - static func concatenate(_ effects: Self...) -> Self { - Self.concatenate(effects) - } - - /// Concatenates a collection of effects together into a single effect, which runs the effects one - /// after the other. - /// - /// - Parameter effects: A collection of effects. - /// - Returns: A new effect - @inlinable - static func concatenate(_ effects: C) -> Self where C.Element == Self { - effects.reduce(.none) { $0.concatenate(with: $1) } - } - - /// Concatenates this effect and another into a single effect that first runs this effect, and - /// after it completes or is cancelled, runs the other. - /// - /// - Parameter other: Another effect. - /// - Returns: An effect that runs this effect, and after it completes or is cancelled, runs the - /// other. - @inlinable - @_disfavoredOverload - func concatenate(with other: Self) -> Self { - switch (self.operation, other.operation) { - case (_, .none): - return self - case (.none, _): - return other - case (.publisher, .publisher), (.run, .publisher), (.publisher, .run): - return Self( - operation: .publisher( - Publishers.Concatenate(prefix: self, suffix: other).eraseToAnyPublisher() - ) - ) - case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): - return Self( - operation: .run { send in - if let lhsPriority = lhsPriority { - await Task(priority: lhsPriority) { await lhsOperation(send) }.cancellableValue - } else { - await lhsOperation(send) - } - if let rhsPriority = rhsPriority { - await Task(priority: rhsPriority) { await rhsOperation(send) }.cancellableValue - } else { - await rhsOperation(send) - } - } - ) - } - } - - /// Transforms all elements from the upstream effect with a provided closure. - /// - /// - Parameter transform: A closure that transforms the upstream effect's action to a new action. - /// - Returns: A publisher that uses the provided closure to map elements from the upstream effect - /// to new elements that it then publishes. - @inlinable - func map(_ transform: @escaping (Action) -> T) -> EffectPublisher { - switch self.operation { - case .none: - return .none - case let .publisher(publisher): - let transform = { action in - transform(action) - } - return .init(operation: .publisher(publisher.map(transform).eraseToAnyPublisher())) - case let .run(priority, operation): - return .init( - operation: .run(priority) { send in - await operation( - Send { action in - send(transform(action)) - } - ) - } - ) - } - } -} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift deleted file mode 100644 index 17a8d210..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift +++ /dev/null @@ -1,199 +0,0 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -// -// Misc.swift -// Misc items pulled from https://github.com/pointfreeco/swift-composable-architecture to get the store working. -// -// Created by Noah Durell on 12/6/22. -// -import Foundation -import Combine -#if canImport(os) - import os -#endif - -final class Box { - var wrappedValue: Wrapped - - init(wrappedValue: Wrapped) { - self.wrappedValue = wrappedValue - } - - var boxedValue: Wrapped { - _read { yield self.wrappedValue } - _modify { yield &self.wrappedValue } - } -} - -@_transparent -@usableFromInline -@inline(__always) -func runtimeWarn( - _ message: @autoclosure () -> String, - category: String? = "", - file: StaticString? = nil, - line: UInt? = nil -) { - #if DEBUG - let message = message() - let category = category ?? "Runtime Warning" - #if canImport(os) - os_log( - .fault, - dso: dso, - log: OSLog(subsystem: "com.apple.runtime-issues", category: category), - "%@", - message - ) - #endif - #endif -} - -#if canImport(os) - import os - - // NB: Xcode runtime warnings offer a much better experience than traditional assertions and - // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. - // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. - // - // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc - @usableFromInline - let dso = { () -> UnsafeMutableRawPointer in - let count = _dyld_image_count() - for i in 0.. String { - func debugCaseOutputHelp(_ value: Any) -> String { - let mirror = Mirror(reflecting: value) - switch mirror.displayStyle { - case .enum: - guard let child = mirror.children.first else { - let childOutput = "\(value)" - return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" - } - let childOutput = debugCaseOutputHelp(child.value) - return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" - case .tuple: - return mirror.children.map { label, value in - let childOutput = debugCaseOutputHelp(value) - return - "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" - } - .joined(separator: ", ") - default: - return "" - } - } - - return (value as? CustomDebugStringConvertible)?.debugDescription - ?? "\(typeName(type(of: value)))\(debugCaseOutputHelp(value))" -} - -private func isUnlabeledArgument(_ label: String) -> Bool { - label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil -} - -@usableFromInline -func typeName(_ type: Any.Type) -> String { - var name = _typeName(type, qualified: true) - if let index = name.firstIndex(of: ".") { - name.removeSubrange(...index) - } - let sanitizedName = - name - .replacingOccurrences( - of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, - with: "", - options: .regularExpression - ) - return sanitizedName -} - -#endif - -extension NSRecursiveLock { - @inlinable @discardableResult - func sync(work: () -> R) -> R { - self.lock() - defer { self.unlock() } - return work() - } -} - -extension UnsafeMutablePointer where Pointee == os_unfair_lock_s { - @inlinable @discardableResult - func sync(_ work: () -> R) -> R { - os_unfair_lock_lock(self) - defer { os_unfair_lock_unlock(self) } - return work() - } -} - -extension Task where Failure == Error { - var cancellableValue: Success { - get async throws { - try await withTaskCancellationHandler { - try await self.value - } onCancel: { - self.cancel() - } - } - } -} - -extension Task where Failure == Never { - @usableFromInline - var cancellableValue: Success { - get async { - await withTaskCancellationHandler { - await self.value - } onCancel: { - self.cancel() - } - } - } -} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Publisher.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Publisher.swift deleted file mode 100644 index 1113a729..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Publisher.swift +++ /dev/null @@ -1,544 +0,0 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -// -// Publisher.swift -// Pulled from https://github.com/pointfreeco/swift-composable-architecture -// -// Created by Noah Durell on 12/7/22. -// - -import Foundation -import Combine - -@available(iOS, deprecated: 9999.0) -@available(macOS, deprecated: 9999.0) -@available(tvOS, deprecated: 9999.0) -@available(watchOS, deprecated: 9999.0) -extension EffectPublisher: Publisher { - typealias Output = Action - - func receive( - subscriber: S - ) where S.Input == Action, S.Failure == Failure { - self.publisher.subscribe(subscriber) - } - - var publisher: AnyPublisher { - switch self.operation { - case .none: - return Empty().eraseToAnyPublisher() - case let .publisher(publisher): - return publisher - case let .run(priority, operation): - return .create { subscriber in - let task = Task(priority: priority) { @MainActor in - defer { subscriber.send(completion: .finished) } - let send = Send { subscriber.send($0) } - await operation(send) - } - return AnyCancellable { - task.cancel() - } - } - } - } -} - -extension EffectPublisher { - /// Initializes an effect that wraps a publisher. - /// - /// > Important: This Combine interface has been soft-deprecated in favor of Swift concurrency. - /// > Prefer performing asynchronous work directly in - /// > ``EffectPublisher/run(priority:operation:catch:file:fileID:line:)`` by adopting a - /// > non-Combine interface, or by iterating over the publisher's asynchronous sequence of - /// > `values`: - /// > - /// > ```swift - /// > return .run { send in - /// > for await value in publisher.values { - /// > send(.response(value)) - /// > } - /// > } - /// > ``` - /// - /// - Parameter publisher: A publisher. - @available( - iOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - init(_ publisher: P) where P.Output == Output, P.Failure == Failure { - self.operation = .publisher(publisher.eraseToAnyPublisher()) - } - - /// Initializes an effect that immediately emits the value passed in. - /// - /// - Parameter value: The value that is immediately emitted by the effect. - @available(iOS, deprecated: 9999.0, message: "Wrap the value in 'EffectTask.task', instead.") - @available(macOS, deprecated: 9999.0, message: "Wrap the value in 'EffectTask.task', instead.") - @available(tvOS, deprecated: 9999.0, message: "Wrap the value in 'EffectTask.task', instead.") - @available(watchOS, deprecated: 9999.0, message: "Wrap the value in 'EffectTask.task', instead.") - init(value: Action) { - self.init(Just(value).setFailureType(to: Failure.self)) - } - - /// Initializes an effect that immediately fails with the error passed in. - /// - /// - Parameter error: The error that is immediately emitted by the effect. - @available( - iOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - init(error: Failure) { - // NB: Ideally we'd return a `Fail` publisher here, but due to a bug in iOS 13 that publisher - // can crash when used with certain combinations of operators such as `.retry.catch`. The - // bug was fixed in iOS 14, but to remain compatible with iOS 13 and higher we need to do - // a little trickery to fail in a slightly different way. - self.init( - Deferred { - Future { $0(.failure(error)) } - } - ) - } - - /// Creates an effect that can supply a single value asynchronously in the future. - /// - /// This can be helpful for converting APIs that are callback-based into ones that deal with - /// ``EffectPublisher``s. - /// - /// For example, to create an effect that delivers an integer after waiting a second: - /// - /// ```swift - /// EffectPublisher.future { callback in - /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - /// callback(.success(42)) - /// } - /// } - /// ``` - /// - /// Note that you can only deliver a single value to the `callback`. If you send more they will be - /// discarded: - /// - /// ```swift - /// EffectPublisher.future { callback in - /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - /// callback(.success(42)) - /// callback(.success(1729)) // Will not be emitted by the effect - /// } - /// } - /// ``` - /// - /// If you need to deliver more than one value to the effect, you should use the - /// ``EffectPublisher`` initializer that accepts a ``Subscriber`` value. - /// - /// - Parameter attemptToFulfill: A closure that takes a `callback` as an argument which can be - /// used to feed it `Result` values. - @available(iOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - @available(macOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - @available(tvOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - @available(watchOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - static func future( - _ attemptToFulfill: @escaping (@escaping (Result) -> Void) -> Void - ) -> Self { - return Deferred { - Future(attemptToFulfill) - }.eraseToEffect() - } - - /// Initializes an effect that lazily executes some work in the real world and synchronously sends - /// that data back into the store. - /// - /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: - /// - /// ```swift - /// EffectPublisher.result { - /// let fileUrl = URL( - /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( - /// .documentDirectory, .userDomainMask, true - /// )[0] - /// ) - /// .appendingPathComponent("user.json") - /// - /// let result = Result { - /// let data = try Data(contentsOf: fileUrl) - /// return try JSONDecoder().decode(User.self, from: $0) - /// } - /// - /// return result - /// } - /// ``` - /// - /// - Parameter attemptToFulfill: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - @available(iOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - @available(macOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - @available(tvOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - @available(watchOS, deprecated: 9999.0, message: "Use 'EffectTask.task', instead.") - static func result(_ attemptToFulfill: @escaping () -> Result) -> Self { - .future { $0(attemptToFulfill()) } - } - - /// Initializes an effect from a callback that can send as many values as it wants, and can send - /// a completion. - /// - /// This initializer is useful for bridging callback APIs, delegate APIs, and manager APIs to the - /// ``EffectPublisher`` type. One can wrap those APIs in an Effect so that its events are sent - /// through the effect, which allows the reducer to handle them. - /// - /// For example, one can create an effect to ask for access to `MPMediaLibrary`. It can start by - /// sending the current status immediately, and then if the current status is `notDetermined` it - /// can request authorization, and once a status is received it can send that back to the effect: - /// - /// ```swift - /// EffectPublisher.run { subscriber in - /// subscriber.send(MPMediaLibrary.authorizationStatus()) - /// - /// guard MPMediaLibrary.authorizationStatus() == .notDetermined else { - /// subscriber.send(completion: .finished) - /// return AnyCancellable {} - /// } - /// - /// MPMediaLibrary.requestAuthorization { status in - /// subscriber.send(status) - /// subscriber.send(completion: .finished) - /// } - /// return AnyCancellable { - /// // Typically clean up resources that were created here, but this effect doesn't - /// // have any. - /// } - /// } - /// ``` - /// - /// - Parameter work: A closure that accepts a ``Subscriber`` value and returns a cancellable. - /// When the ``EffectPublisher`` is completed, the cancellable will be used to clean up any - /// resources created when the effect was started. - @available( - iOS, deprecated: 9999.0, message: "Use the async version of 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, message: "Use the async version of 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, message: "Use the async version of 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, message: "Use the async version of 'Effect.run', instead." - ) - static func run( - _ work: @escaping (EffectPublisher.Subscriber) -> Cancellable - ) -> Self { - return AnyPublisher.create { subscriber in - work(subscriber) - } - .eraseToEffect() - } - - /// Creates an effect that executes some work in the real world that doesn't need to feed data - /// back into the store. If an error is thrown, the effect will complete and the error will be - /// ignored. - /// - /// - Parameter work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - @available(iOS, deprecated: 9999.0, message: "Use the async version, instead.") - @available(macOS, deprecated: 9999.0, message: "Use the async version, instead.") - @available(tvOS, deprecated: 9999.0, message: "Use the async version, instead.") - @available(watchOS, deprecated: 9999.0, message: "Use the async version, instead.") - static func fireAndForget(_ work: @escaping () throws -> Void) -> Self { - // NB: Ideally we'd return a `Deferred` wrapping an `Empty(completeImmediately: true)`, but - // due to a bug in iOS 13.2 that publisher will never complete. The bug was fixed in - // iOS 13.3, but to remain compatible with iOS 13.2 and higher we need to do a little - // trickery to make sure the deferred publisher completes. - return Deferred { () -> Publishers.CompactMap.Publisher, Action> in - try? work() - return Just(nil) - .setFailureType(to: Failure.self) - .compactMap { $0 } - } - .eraseToEffect() - } -} - -extension EffectPublisher where Failure == Error { - /// Initializes an effect that lazily executes some work in the real world and synchronously sends - /// that data back into the store. - /// - /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: - /// - /// ```swift - /// EffectPublisher.catching { - /// let fileUrl = URL( - /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( - /// .documentDirectory, .userDomainMask, true - /// )[0] - /// ) - /// .appendingPathComponent("user.json") - /// - /// let data = try Data(contentsOf: fileUrl) - /// return try JSONDecoder().decode(User.self, from: $0) - /// } - /// ``` - /// - /// - Parameter work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - @available( - iOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Throw and catch errors directly in 'EffectTask.task' and 'EffectTask.run', instead." - ) - static func catching(_ work: @escaping () throws -> Action) -> Self { - .future { $0(Result { try work() }) } - } -} - -extension Publisher { - /// Turns any publisher into an ``EffectPublisher``. - /// - /// This can be useful for when you perform a chain of publisher transformations in a reducer, and - /// you need to convert that publisher to an effect so that you can return it from the reducer: - /// - /// ```swift - /// case .buttonTapped: - /// return fetchUser(id: 1) - /// .filter(\.isAdmin) - /// .eraseToEffect() - /// ``` - /// - /// - Returns: An effect that wraps `self`. - @available( - iOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - func eraseToEffect() -> EffectPublisher { - EffectPublisher(self) - } - - /// Turns any publisher into an ``EffectPublisher``. - /// - /// This is a convenience operator for writing ``EffectPublisher/eraseToEffect()`` followed by - /// ``EffectPublisher/map(_:)-28ghh`. - /// - /// ```swift - /// case .buttonTapped: - /// return fetchUser(id: 1) - /// .filter(\.isAdmin) - /// .eraseToEffect(ProfileAction.adminUserFetched) - /// ``` - /// - /// - Parameters: - /// - transform: A mapping function that converts `Output` to another type. - /// - Returns: An effect that wraps `self` after mapping `Output` values. - @available( - iOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - func eraseToEffect( - _ transform: @escaping (Output) -> T - ) -> EffectPublisher { - self.map(transform) - .eraseToEffect() - } - - /// Turns any publisher into an ``EffectTask`` that cannot fail by wrapping its output and failure - /// in a result. - /// - /// This can be useful when you are working with a failing API but want to deliver its data to an - /// action that handles both success and failure. - /// - /// ```swift - /// case .buttonTapped: - /// return self.apiClient.fetchUser(id: 1) - /// .catchToEffect() - /// .map(ProfileAction.userResponse) - /// ``` - /// - /// - Returns: An effect that wraps `self`. - @available( - iOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - func catchToEffect() -> EffectTask> { - self.catchToEffect { $0 } - } - - /// Turns any publisher into an ``EffectTask`` that cannot fail by wrapping its output and failure - /// into a result and then applying passed in function to it. - /// - /// This is a convenience operator for writing ``EffectPublisher/eraseToEffect()`` followed by - /// ``EffectPublisher/map(_:)-28ghh`. - /// - /// ```swift - /// case .buttonTapped: - /// return self.apiClient.fetchUser(id: 1) - /// .catchToEffect(ProfileAction.userResponse) - /// ``` - /// - /// - Parameters: - /// - transform: A mapping function that converts `Result` to another type. - /// - Returns: An effect that wraps `self`. - @available( - iOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: "Iterate over 'Publisher.values' in an 'EffectTask.run', instead." - ) - func catchToEffect( - _ transform: @escaping (Result) -> T - ) -> EffectTask { - let transform = { action in - transform(action) - } - return - self - .map { transform(.success($0)) } - .catch { Just(transform(.failure($0))) } - .eraseToEffect() - } - - /// Turns any publisher into an ``EffectPublisher`` for any output and failure type by ignoring - /// all output and any failure. - /// - /// This is useful for times you want to fire off an effect but don't want to feed any data back - /// into the system. It can automatically promote an effect to your reducer's domain. - /// - /// ```swift - /// case .buttonTapped: - /// return analyticsClient.track("Button Tapped") - /// .fireAndForget() - /// ``` - /// - /// - Parameters: - /// - outputType: An output type. - /// - failureType: A failure type. - /// - Returns: An effect that never produces output or errors. - @available( - iOS, deprecated: 9999.0, - message: - "Iterate over 'Publisher.values' in the static version of 'Effect.fireAndForget', instead." - ) - @available( - macOS, deprecated: 9999.0, - message: - "Iterate over 'Publisher.values' in the static version of 'Effect.fireAndForget', instead." - ) - @available( - tvOS, deprecated: 9999.0, - message: - "Iterate over 'Publisher.values' in the static version of 'Effect.fireAndForget', instead." - ) - @available( - watchOS, deprecated: 9999.0, - message: - "Iterate over 'Publisher.values' in the static version of 'Effect.fireAndForget', instead." - ) - func fireAndForget( - outputType: NewOutput.Type = NewOutput.self, - failureType: NewFailure.Type = NewFailure.self - ) -> EffectPublisher { - return - self - .flatMap { _ in Empty() } - .catch { _ in Empty() } - .eraseToEffect() - } -} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ReducerProtocol.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ReducerProtocol.swift deleted file mode 100644 index c57c375b..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/ReducerProtocol.swift +++ /dev/null @@ -1,308 +0,0 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -// -// ReducerProtocol.swift -// Pulled from https://github.com/pointfreeco/swift-composable-architecture -// -// Created by Noah Durell on 12/6/22. -// -#if compiler(>=5.7) - /// A protocol that describes how to evolve the current state of an application to the next state, - /// given an action, and describes what ``EffectTask``s should be executed later by the store, if - /// any. - /// - /// Conform types to this protocol to represent the domain, logic and behavior for your feature. - /// The domain is specified by the "state" and "actions", which can be nested types inside the - /// conformance: - /// - /// ```swift - /// struct Feature: ReducerProtocol { - /// struct State { - /// var count = 0 - /// } - /// enum Action { - /// case decrementButtonTapped - /// case incrementButtonTapped - /// } - /// - /// // ... - /// } - /// ``` - /// - /// The logic of your feature is implemented by mutating the feature's current state when an action - /// comes into the system. This is most easily done by implementing the - /// ``ReducerProtocol/reduce(into:action:)-8yinq`` method of the protocol. - /// - /// ```swift - /// struct Feature: ReducerProtocol { - /// // ... - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .decrementButtonTapped: - /// state.count -= 1 - /// return .none - /// - /// case .incrementButtonTapped: - /// state.count += 1 - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// The `reduce` method's first responsibility is to mutate the feature's current state given an - /// action. Its second responsibility is to return effects that will be executed asynchronously - /// and feed their data back into the system. Currently `Feature` does not need to run any effects, - /// and so ``EffectPublisher/none`` is returned. - /// - /// If the feature does need to do effectful work, then more would need to be done. For example, - /// suppose the feature has the ability to start and stop a timer, and with each tick of the timer - /// the `count` will be incremented. That could be done like so: - /// - /// ```swift - /// struct Feature: ReducerProtocol { - /// struct State { - /// var count = 0 - /// } - /// enum Action { - /// case decrementButtonTapped - /// case incrementButtonTapped - /// case startTimerButtonTapped - /// case stopTimerButtonTapped - /// case timerTick - /// } - /// enum TimerID {} - /// - /// func reduce(into state: inout State, action: Action) -> EffectTask { - /// switch action { - /// case .decrementButtonTapped: - /// state.count -= 1 - /// return .none - /// - /// case .incrementButtonTapped: - /// state.count += 1 - /// return .none - /// - /// case .startTimerButtonTapped: - /// return .run { send in - /// while true { - /// try await Task.sleep(for: .seconds(1)) - /// await send(.timerTick) - /// } - /// } - /// .cancellable(TimerID.self) - /// - /// case .stopTimerButtonTapped: - /// return .cancel(TimerID.self) - /// - /// case .timerTick: - /// state.count += 1 - /// return .none - /// } - /// } - /// } - /// ``` - /// - /// > Note: This sample emulates a timer by performing an infinite loop with a `Task.sleep` - /// inside. This is simple to do, but is also inaccurate since small imprecisions can accumulate. - /// It would be better to inject a clock into the feature so that you could use its `timer` - /// method. Read the and articles for more - /// information. - /// - /// That is the basics of implementing a feature as a conformance to ``ReducerProtocol``. There are - /// actually two ways to define a reducer: - /// - /// 1. You can either implement the ``reduce(into:action:)-8yinq`` method, as shown above, which - /// is given direct mutable access to application ``State`` whenever an ``Action`` is fed into - /// the system, and returns an ``EffectTask`` that can communicate with the outside world and - /// feed additional ``Action``s back into the system. - /// - /// 2. Or you can implement the ``body-swift.property-7foai`` property, which combines one or - /// more reducers together. - /// - /// At most one of these requirements should be implemented. If a conformance implements both - /// requirements, only ``reduce(into:action:)-8yinq`` will be called by the ``Store``. If your - /// reducer assembles a body from other reducers _and_ has additional business logic it needs to - /// layer onto the feature, introduce this logic into the body instead, either with ``Reduce``: - /// - /// ```swift - /// var body: some ReducerProtocol { - /// Reduce { state, action in - /// // extra logic - /// } - /// Activity() - /// Profile() - /// Settings() - /// } - /// ``` - /// - /// …or moving the extra logic to a method that is wrapped in ``Reduce``: - /// - /// ```swift - /// var body: some ReducerProtocol { - /// Reduce(self.core) - /// Activity() - /// Profile() - /// Settings() - /// } - /// - /// func core(state: inout State, action: Action) -> EffectTask { - /// // extra logic - /// } - /// ``` - /// - /// If you are implementing a custom reducer operator that transforms an existing reducer, - /// _always_ invoke the ``reduce(into:action:)-8yinq`` method, never the - /// ``body-swift.property-7foai``. For example, this operator that logs all actions sent to the - /// reducer: - /// - /// ```swift - /// extension ReducerProtocol { - /// func logActions() -> some ReducerProtocol { - /// Reduce { state, action in - /// print("Received action: \(action)") - /// return self.reduce(into: &state, action: action) - /// } - /// } - /// } - /// ``` - /// - protocol ReducerProtocol { - /// A type that holds the current state of the reducer. - associatedtype State - - /// A type that holds all possible actions that cause the ``State`` of the reducer to change - /// and/or kick off a side ``EffectTask`` that can communicate with the outside world. - associatedtype Action - - func reduce(into state: inout State, action: Action) -> EffectTask - } -#else - /// A protocol that describes how to evolve the current state of an application to the next state, - /// given an action, and describes what ``EffectTask``s should be executed later by the store, if - /// any. - /// - /// There are two ways to define a reducer: - /// - /// 1. You can either implement the ``reduce(into:action:)-8yinq`` method, which is given direct - /// mutable access to application ``State`` whenever an ``Action`` is fed into the system, - /// and returns an ``EffectTask`` that can communicate with the outside world and feed - /// additional ``Action``s back into the system. - /// - /// 2. Or you can implement the ``body-swift.property-7foai`` property, which combines one or - /// more reducers together. - /// - /// At most one of these requirements should be implemented. If a conformance implements both - /// requirements, only ``reduce(into:action:)-8yinq`` will be called by the ``Store``. If your - /// reducer assembles a body from other reducers _and_ has additional business logic it needs to - /// layer onto the feature, introduce this logic into the body instead, either with ``Reduce``: - /// - /// ```swift - /// var body: some ReducerProtocol { - /// Reduce { state, action in - /// // extra logic - /// } - /// Activity() - /// Profile() - /// Settings() - /// } - /// ``` - /// - /// ...or with a separate, dedicated conformance: - /// - /// ```swift - /// var body: some ReducerProtocol { - /// Core() - /// Activity() - /// Profile() - /// Settings() - /// } - /// struct Core: ReducerProtocol { - /// // extra logic - /// } - /// ``` - /// - /// If you are implementing a custom reducer operator that transforms an existing reducer, - /// _always_ invoke the ``reduce(into:action:)-8yinq`` method, never the - /// ``body-swift.property-7foai``. For example, this operator that logs all actions sent to the - /// reducer: - /// - /// ```swift - /// extension ReducerProtocol { - /// func logActions() -> some ReducerProtocol { - /// Reduce { state, action in - /// print("Received action: \(action)") - /// return self.reduce(into: &state, action: action) - /// } - /// } - /// } - /// ``` - protocol ReducerProtocol { - /// A type that holds the current state of the reducer. - associatedtype State - - /// A type that holds all possible actions that cause the ``State`` of the reducer to change - /// and/or kick off a side ``EffectTask`` that can communicate with the outside world. - associatedtype Action - - /// Evolves the current state of an reducer to the next state. - /// - /// Implement this requirement for "primitive" reducers, or reducers that work on leaf node - /// features. To define a reducer by combining the logic of other reducers together, implement - /// the ``body-swift.property-7foai`` requirement instead. - /// - /// - Parameters: - /// - state: The current state of the reducer. - /// - action: An action that can cause the state of the reducer to change, and/or kick off - /// a side effect that can communicate with the outside world. - /// - Returns: An effect that can communicate with the outside world and feed actions back into - /// the system. - func reduce(into state: inout State, action: Action) -> EffectTask - } -#endif - -// NB: This is available only in Swift 5.7.1 due to the following bug: -// https://github.com/apple/swift/issues/60550 -#if swift(>=5.7.1) - /// A convenience for constraining a ``ReducerProtocol`` conformance. Available only in Swift - /// 5.7.1. - /// - /// This allows you to specify the `body` of a ``ReducerProtocol`` conformance like so: - /// - /// ```swift - /// var body: some ReducerProtocolOf { - /// // ... - /// } - /// ``` - /// - /// …instead of the more verbose: - /// - /// ```swift - /// var body: some ReducerProtocol { - /// // ... - /// } - /// ``` - typealias ReducerProtocolOf = ReducerProtocol -#endif diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Store.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Store.swift deleted file mode 100644 index dd776338..00000000 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Store.swift +++ /dev/null @@ -1,411 +0,0 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -// -// Store.swift -// Pulled from https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/ReducerProtocol.swift -// with minimal modifications (scoping, dependencies and reducer protocol removed). -// -// Created by Noah Durell on 12/6/22. -// -import Combine -import Foundation - -/// A store represents the runtime that powers the application. It is the object that you will pass -/// around to views that need to interact with the application. -/// -/// You will typically construct a single one of these at the root of your application: -/// -/// ```swift -/// @main -/// struct MyApp: App { -/// var body: some Scene { -/// WindowGroup { -/// RootView( -/// store: Store( -/// initialState: AppReducer.State(), -/// reducer: AppReducer() -/// ) -/// ) -/// } -/// } -/// } -/// ``` -/// -/// …and then use the ``scope(state:action:)`` method to derive more focused stores that can be -/// passed to subviews. -/// -/// ### Scoping -/// -/// The most important operation defined on ``Store`` is the ``scope(state:action:)`` method, which -/// allows you to transform a store into one that deals with child state and actions. This is -/// necessary for passing stores to subviews that only care about a small portion of the entire -/// application's domain. -/// -/// For example, if an application has a tab view at its root with tabs for activity, search, and -/// profile, then we can model the domain like this: -/// -/// ```swift -/// struct State { -/// var activity: Activity.State -/// var profile: Profile.State -/// var search: Search.State -/// } -/// -/// enum Action { -/// case activity(Activity.Action) -/// case profile(Profile.Action) -/// case search(Search.Action) -/// } -/// ``` -/// -/// We can construct a view for each of these domains by applying ``scope(state:action:)`` to a -/// store that holds onto the full app domain in order to transform it into a store for each -/// sub-domain: -/// -/// ```swift -/// struct AppView: View { -/// let store: StoreOf -/// -/// var body: some View { -/// TabView { -/// ActivityView(store: self.store.scope(state: \.activity, action: App.Action.activity)) -/// .tabItem { Text("Activity") } -/// -/// SearchView(store: self.store.scope(state: \.search, action: App.Action.search)) -/// .tabItem { Text("Search") } -/// -/// ProfileView(store: self.store.scope(state: \.profile, action: App.Action.profile)) -/// .tabItem { Text("Profile") } -/// } -/// } -/// } -/// ``` -/// -/// ### Thread safety -/// -/// The `Store` class is not thread-safe, and so all interactions with an instance of ``Store`` -/// (including all of its scopes and derived ``ViewStore``s) must be done on the same thread the -/// store was created on. Further, if the store is powering a SwiftUI or UIKit view, as is -/// customary, then all interactions must be done on the _main_ thread. -/// -/// The reason stores are not thread-safe is due to the fact that when an action is sent to a store, -/// a reducer is run on the current state, and this process cannot be done from multiple threads. -/// It is possible to make this process thread-safe by introducing locks or queues, but this -/// introduces new complications: -/// -/// * If done simply with `DispatchQueue.main.async` you will incur a thread hop even when you are -/// already on the main thread. This can lead to unexpected behavior in UIKit and SwiftUI, where -/// sometimes you are required to do work synchronously, such as in animation blocks. -/// -/// * It is possible to create a scheduler that performs its work immediately when on the main -/// thread and otherwise uses `DispatchQueue.main.async` (_e.g._, see Combine Schedulers' -/// [UIScheduler][uischeduler]). -/// -/// This introduces a lot more complexity, and should probably not be adopted without having a very -/// good reason. -/// -/// This is why we require all actions be sent from the same thread. This requirement is in the same -/// spirit of how `URLSession` and other Apple APIs are designed. Those APIs tend to deliver their -/// outputs on whatever thread is most convenient for them, and then it is your responsibility to -/// dispatch back to the main queue if that's what you need. The Composable Architecture makes you -/// responsible for making sure to send actions on the main thread. If you are using an effect that -/// may deliver its output on a non-main thread, you must explicitly perform `.receive(on:)` in -/// order to force it back on the main thread. -/// -/// This approach makes the fewest number of assumptions about how effects are created and -/// transformed, and prevents unnecessary thread hops and re-dispatching. It also provides some -/// testing benefits. If your effects are not responsible for their own scheduling, then in tests -/// all of the effects would run synchronously and immediately. You would not be able to test how -/// multiple in-flight effects interleave with each other and affect the state of your application. -/// However, by leaving scheduling out of the ``Store`` we get to test these aspects of our effects -/// if we so desire, or we can ignore if we prefer. We have that flexibility. -/// -/// [uischeduler]: https://github.com/pointfreeco/combine-schedulers/blob/main/Sources/CombineSchedulers/UIScheduler.swift -/// -/// #### Thread safety checks -/// -/// The store performs some basic thread safety checks in order to help catch mistakes. Stores -/// constructed via the initializer ``init(initialState:reducer:)`` are assumed to run -/// only on the main thread, and so a check is executed immediately to make sure that is the case. -/// Further, all actions sent to the store and all scopes (see ``scope(state:action:)``) of the -/// store are also checked to make sure that work is performed on the main thread. -final class Store { - private var bufferedActions: [Action] = [] - var effectCancellables: [UUID: AnyCancellable] = [:] - private var isSending = false - var parentCancellable: AnyCancellable? -#if swift(>=5.7) - private let reducer: any ReducerProtocol -#else - private let reducer: (inout State, Action) -> EffectTask -#endif - var state: CurrentValueSubject - #if DEBUG - private let mainThreadChecksEnabled: Bool - #endif - - /// Initializes a store from an initial state and a reducer. - /// - /// - Parameters: - /// - initialState: The state to start the application in. - /// - reducer: The reducer that powers the business logic of the application. - convenience init( - initialState: R.State, - reducer: R - ) where R.State == State, R.Action == Action { - self.init( - initialState: initialState, - reducer: reducer, - mainThreadChecksEnabled: true - ) - } - - func send( - _ action: Action, - originatingFrom originatingAction: Action? = nil - ) -> Task? { - self.threadCheck(status: .send(action, originatingAction: originatingAction)) - - self.bufferedActions.append(action) - guard !self.isSending else { return nil } - - self.isSending = true - var currentState = self.state.value - let tasks = Box<[Task]>(wrappedValue: []) - defer { - withExtendedLifetime(self.bufferedActions) { - self.bufferedActions.removeAll() - } - self.state.value = currentState - self.isSending = false - if !self.bufferedActions.isEmpty { - if let task = self.send( - self.bufferedActions.removeLast(), originatingFrom: originatingAction - ) { - tasks.wrappedValue.append(task) - } - } - } - - var index = self.bufferedActions.startIndex - while index < self.bufferedActions.endIndex { - defer { index += 1 } - let action = self.bufferedActions[index] - #if swift(>=5.7) - let effect = self.reducer.reduce(into: ¤tState, action: action) - #else - let effect = self.reducer(¤tState, action) - #endif - - switch effect.operation { - case .none: - break - case let .publisher(publisher): - var didComplete = false - let boxedTask = Box?>(wrappedValue: nil) - let uuid = UUID() - let effectCancellable = - publisher - .handleEvents( - receiveCancel: { [weak self] in - self?.threadCheck(status: .effectCompletion(action)) - self?.effectCancellables[uuid] = nil - } - ) - .sink( - receiveCompletion: { [weak self] _ in - self?.threadCheck(status: .effectCompletion(action)) - boxedTask.wrappedValue?.cancel() - didComplete = true - self?.effectCancellables[uuid] = nil - }, - receiveValue: { [weak self] effectAction in - guard let self = self else { return } - if let task = self.send(effectAction, originatingFrom: action) { - tasks.wrappedValue.append(task) - } - } - ) - - if !didComplete { - let task = Task { @MainActor in - for await _ in AsyncStream.never {} - effectCancellable.cancel() - } - boxedTask.wrappedValue = task - tasks.wrappedValue.append(task) - self.effectCancellables[uuid] = effectCancellable - } - case let .run(priority, operation): - tasks.wrappedValue.append( - Task(priority: priority) { - await operation( - Send { - if let task = self.send($0, originatingFrom: action) { - tasks.wrappedValue.append(task) - } - } - ) - } - ) - } - } - - guard !tasks.wrappedValue.isEmpty else { return nil } - return Task { @MainActor in - await withTaskCancellationHandler { - var index = tasks.wrappedValue.startIndex - while index < tasks.wrappedValue.endIndex { - defer { index += 1 } - await tasks.wrappedValue[index].value - } - } onCancel: { - var index = tasks.wrappedValue.startIndex - while index < tasks.wrappedValue.endIndex { - defer { index += 1 } - tasks.wrappedValue[index].cancel() - } - } - } - } - - private enum ThreadCheckStatus { - case effectCompletion(Action) - case `init` - case scope - case send(Action, originatingAction: Action?) - } - - @inline(__always) - private func threadCheck(status: ThreadCheckStatus) { - #if DEBUG - guard self.mainThreadChecksEnabled && !Thread.isMainThread - else { return } - - switch status { - case let .effectCompletion(action): - runtimeWarn( - """ - An effect completed on a non-main thread. … - - Effect returned from: - \(debugCaseOutput(action)) - - Make sure to use ".receive(on:)" on any effects that execute on background threads to \ - receive their output on the main thread. - - The "Store" class is not thread-safe, and so all interactions with an instance of \ - "Store" (including all of its scopes and derived view stores) must be done on the main \ - thread. - """ - ) - - case .`init`: - runtimeWarn( - """ - A store initialized on a non-main thread. … - - The "Store" class is not thread-safe, and so all interactions with an instance of \ - "Store" (including all of its scopes and derived view stores) must be done on the main \ - thread. - """ - ) - - case .scope: - runtimeWarn( - """ - "Store.scope" was called on a non-main thread. … - - The "Store" class is not thread-safe, and so all interactions with an instance of \ - "Store" (including all of its scopes and derived view stores) must be done on the main \ - thread. - """ - ) - - case let .send(action, originatingAction: nil): - runtimeWarn( - """ - "ViewStore.send" was called on a non-main thread with: \(debugCaseOutput(action)) … - - The "Store" class is not thread-safe, and so all interactions with an instance of \ - "Store" (including all of its scopes and derived view stores) must be done on the main \ - thread. - """ - ) - - case let .send(action, originatingAction: .some(originatingAction)): - runtimeWarn( - """ - An effect published an action on a non-main thread. … - - Effect published: - \(debugCaseOutput(action)) - - Effect returned from: - \(debugCaseOutput(originatingAction)) - - Make sure to use ".receive(on:)" on any effects that execute on background threads to \ - receive their output on the main thread. - - The "Store" class is not thread-safe, and so all interactions with an instance of \ - "Store" (including all of its scopes and derived view stores) must be done on the main \ - thread. - """ - ) - } - #endif - } - - init( - initialState: R.State, - reducer: R, - mainThreadChecksEnabled: Bool - ) where R.State == State, R.Action == Action { - self.state = CurrentValueSubject(initialState) - #if swift(>=5.7) - self.reducer = reducer - #else - self.reducer = reducer.reduce - #endif - #if DEBUG - self.mainThreadChecksEnabled = mainThreadChecksEnabled - #endif - self.threadCheck(status: .`init`) - } -} - -/// A convenience type alias for referring to a store of a given reducer's domain. -/// -/// Instead of specifying two generics: -/// -/// ```swift -/// let store: Store -/// ``` -/// -/// You can specify a single generic: -/// -/// ```swift -/// let store: StoreOf -/// ``` -typealias StoreOf = Store diff --git a/Sources/KlaviyoSwiftExtension/KlaviyoExtension.swift b/Sources/KlaviyoSwiftExtension/KlaviyoExtension.swift index f1609062..1d21f7d1 100644 --- a/Sources/KlaviyoSwiftExtension/KlaviyoExtension.swift +++ b/Sources/KlaviyoSwiftExtension/KlaviyoExtension.swift @@ -5,7 +5,7 @@ // Created by Ajay Subramanya on 6/23/23. // import Foundation -import UserNotifications +@preconcurrency import UserNotifications public enum KlaviyoExtensionSDK { /// Call this method when you receive a rich push notification in the notification service extension. @@ -22,7 +22,7 @@ public enum KlaviyoExtensionSDK { public static func handleNotificationServiceDidReceivedRequest( request: UNNotificationRequest, bestAttemptContent: UNMutableNotificationContent, - contentHandler: @escaping (UNNotificationContent) -> Void, + contentHandler: @Sendable @escaping (UNNotificationContent) -> Void, fallbackMediaType: String = "jpeg") { // 1a. get the rich media url from the push notification payload guard let imageURLString = bestAttemptContent.userInfo["rich-media"] as? String else { @@ -73,7 +73,7 @@ public enum KlaviyoExtensionSDK { /// note that in the case of failure the closure will still be called but with `nil`. private static func downloadMedia( for urlString: String, - completion: @escaping (URL?) -> Void) { + completion: @Sendable @escaping (URL?) -> Void) { guard let imageURL = URL(string: urlString) else { completion(nil) return @@ -106,7 +106,7 @@ public enum KlaviyoExtensionSDK { localFilePathWithTypeString: String, completion: @escaping (UNNotificationAttachment?) -> Void) { let localFileURLWithType: URL - if #available(iOS 16.0, *) { + if #available(iOS 16.0, macOS 13.0, *) { localFileURLWithType = URL(filePath: localFilePathWithTypeString) } else { localFileURLWithType = URL(fileURLWithPath: localFilePathWithTypeString) diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift index a52a4f18..bc794379 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift @@ -10,13 +10,14 @@ import Combine import Foundation import WebKit -class JSTestWebViewModel: KlaviyoWebViewModeling { +@MainActor +class JSTestWebViewModel: @preconcurrency KlaviyoWebViewModeling { let url: URL let loadScripts: [String: WKUserScript]? /// Publishes scripts for the `WKWebView` to execute. - private var continuation: AsyncStream<(script: String, callback: ((Result) -> Void)?)>.Continuation? - lazy var scriptStream: AsyncStream<(script: String, callback: ((Result) -> Void)?)> = AsyncStream { [weak self] continuation in + private var continuation: AsyncStream<(script: String, callback: (@Sendable (Result) -> Void)?)>.Continuation? + lazy var scriptStream: AsyncStream<(script: String, callback: (@Sendable (Result) -> Void)?)> = AsyncStream { [weak self] continuation in self?.continuation = continuation } diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift index f73d4a1c..5eadedc4 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift @@ -83,7 +83,8 @@ class KlaviyoWebViewController: UIViewController, WKUIDelegate { Task { [weak self] in guard let self else { return } - for await (script, callback) in self.viewModel.scriptStream { + let scriptStream = self.viewModel.scriptStream + for await (script, callback) in scriptStream { do { let result = try await self.webView.evaluateJavaScript(script) callback?(.success(result)) @@ -132,6 +133,7 @@ extension KlaviyoWebViewController: WKScriptMessageHandler { // MARK: - Previews #if DEBUG +@MainActor func createKlaviyoWebPreview(viewModel: KlaviyoWebViewModeling) -> UIViewController { let viewController = KlaviyoWebViewController(viewModel: viewModel) diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift index 963cba92..7971a63d 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift @@ -14,8 +14,8 @@ class KlaviyoWebViewModel: KlaviyoWebViewModeling { let loadScripts: [String: WKUserScript]? /// Publishes scripts for the `WKWebView` to execute. - private var continuation: AsyncStream<(script: String, callback: ((Result) -> Void)?)>.Continuation? - lazy var scriptStream: AsyncStream<(script: String, callback: ((Result) -> Void)?)> = AsyncStream { [weak self] continuation in + private var continuation: AsyncStream < (script: String, callback: (@Sendable (Result) -> Void)?)>.Continuation? + lazy var scriptStream: AsyncStream < (script: String, callback: (@Sendable (Result) -> Void)?)> = AsyncStream { [weak self] continuation in self?.continuation = continuation } diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift index 5c34cfac..fa67b0a8 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift @@ -16,7 +16,7 @@ protocol KlaviyoWebViewModeling { var loadScripts: [String: WKUserScript]? { get } /// Streams scripts for the ``WKWebView`` to execute. - var scriptStream: AsyncStream<(script: String, callback: ((Result) -> Void)?)> { get } + var scriptStream: AsyncStream < (script: String, callback: (@Sendable (Result) -> Void)?)> { get } func handleNavigationEvent(_ event: WKNavigationEvent) func handleScriptMessage(_ message: WKScriptMessage) diff --git a/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift b/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift index d4074acb..aa873af1 100644 --- a/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift +++ b/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift @@ -9,12 +9,19 @@ import Combine import KlaviyoCore import XCTest +@MainActor class ArchivalUtilsTests: XCTestCase { + #if swift(>=6) + nonisolated(unsafe) var dataToWrite: Data? + nonisolated(unsafe) var wroteToFile = false + nonisolated(unsafe) var removedFile = false + #else var dataToWrite: Data? var wroteToFile = false var removedFile = false + #endif - override func setUpWithError() throws { + override func setUp() async throws { environment = KlaviyoEnvironment.test() environment.fileClient.write = { [weak self] data, _ in self?.wroteToFile = true @@ -33,7 +40,7 @@ class ArchivalUtilsTests: XCTestCase { } func testArchiveUnarchive() throws { - archiveQueue(queue: SAMPLE_DATA, to: TEST_URL) + archiveQueue(fileClient: environment.fileClient, queue: SAMPLE_DATA, to: TEST_URL) XCTAssert(wroteToFile) XCTAssertEqual(ARCHIVED_RETURNED_DATA, dataToWrite) @@ -41,7 +48,7 @@ class ArchivalUtilsTests: XCTestCase { func testArchiveFails() throws { environment.archiverClient.archivedData = { _, _ in throw FakeFileError.fake } - archiveQueue(queue: SAMPLE_DATA, to: TEST_URL) + archiveQueue(fileClient: environment.fileClient, queue: SAMPLE_DATA, to: TEST_URL) XCTAssertFalse(wroteToFile) XCTAssertNil(dataToWrite) @@ -49,14 +56,14 @@ class ArchivalUtilsTests: XCTestCase { func testArchiveWriteFails() throws { environment.fileClient.write = { _, _ in throw FakeFileError.fake } - archiveQueue(queue: SAMPLE_DATA, to: TEST_URL) + archiveQueue(fileClient: environment.fileClient, queue: SAMPLE_DATA, to: TEST_URL) XCTAssertFalse(wroteToFile) XCTAssertNil(dataToWrite) } func testUnarchive() throws { - let archiveResult = unarchiveFromFile(fileURL: TEST_URL) + let archiveResult = unarchiveFromFile(fileClient: environment.fileClient, fileURL: TEST_URL) XCTAssertEqual(SAMPLE_DATA, archiveResult) XCTAssertTrue(removedFile) @@ -65,7 +72,7 @@ class ArchivalUtilsTests: XCTestCase { func testUnarchiveInvalidData() throws { environment.dataFromUrl = { _ in throw FakeFileError.fake } - let archiveResult = unarchiveFromFile(fileURL: TEST_URL) + let archiveResult = unarchiveFromFile(fileClient: environment.fileClient, fileURL: TEST_URL) XCTAssertNil(archiveResult) } @@ -73,7 +80,7 @@ class ArchivalUtilsTests: XCTestCase { func testUnarchiveUnarchiveFails() throws { environment.archiverClient.unarchivedMutableArray = { _ in throw FakeFileError.fake } - let archiveResult = unarchiveFromFile(fileURL: TEST_URL) + let archiveResult = unarchiveFromFile(fileClient: environment.fileClient, fileURL: TEST_URL) XCTAssertNil(archiveResult) } @@ -87,7 +94,7 @@ class ArchivalUtilsTests: XCTestCase { } return false } - let archiveResult = unarchiveFromFile(fileURL: TEST_URL) + let archiveResult = unarchiveFromFile(fileClient: environment.fileClient, fileURL: TEST_URL) XCTAssertEqual(SAMPLE_DATA, archiveResult) XCTAssertFalse(removedFile) @@ -95,13 +102,14 @@ class ArchivalUtilsTests: XCTestCase { func testUnarchiveWhereFileDoesNotExist() throws { environment.fileClient.fileExists = { _ in false } - let archiveResult = unarchiveFromFile(fileURL: TEST_URL) + let archiveResult = unarchiveFromFile(fileClient: environment.fileClient, fileURL: TEST_URL) XCTAssertNil(archiveResult) XCTAssertFalse(removedFile) } } +@MainActor class ArchivalSystemTest: XCTestCase { let TEST_URL = filePathForData(apiKey: "foo", data: "people") @@ -112,8 +120,8 @@ class ArchivalSystemTest: XCTestCase { /* This will attempt to actually archive and unarchive a queue. */ func testArchiveUnarchive() { - archiveQueue(queue: SAMPLE_DATA, to: TEST_URL) - let result = unarchiveFromFile(fileURL: filePathForData(apiKey: "foo", data: "people")) + archiveQueue(fileClient: environment.fileClient, queue: SAMPLE_DATA, to: TEST_URL) + let result = unarchiveFromFile(fileClient: environment.fileClient, fileURL: filePathForData(apiKey: "foo", data: "people")) XCTAssertEqual(SAMPLE_DATA, result) } diff --git a/Tests/KlaviyoCoreTests/EncodableTests.swift b/Tests/KlaviyoCoreTests/EncodableTests.swift index a88cc7e8..e3cfb2f5 100644 --- a/Tests/KlaviyoCoreTests/EncodableTests.swift +++ b/Tests/KlaviyoCoreTests/EncodableTests.swift @@ -5,36 +5,39 @@ // Created by Noah Durell on 11/14/22. // -import KlaviyoCore +@testable import KlaviyoCore import SnapshotTesting import XCTest +@MainActor final class EncodableTests: XCTestCase { let testEncoder = KlaviyoEnvironment.encoder - override func setUpWithError() throws { + @MainActor + override func setUp() async throws { environment = KlaviyoEnvironment.test() testEncoder.outputFormatting = .prettyPrinted.union(.sortedKeys) } func testProfilePayload() throws { let payload = CreateProfilePayload(data: .test) - assertSnapshot(matching: payload, as: .json(KlaviyoEnvironment.encoder)) + assertSnapshot(of: payload, as: .json(KlaviyoEnvironment.encoder)) } - func testEventPayload() throws { - let payloadData = CreateEventPayload.Event(name: "test", properties: SAMPLE_PROPERTIES, anonymousId: "anon-id") + @MainActor + func testEventPayload() async throws { + let payloadData = CreateEventPayload.Event(name: "test", properties: SAMPLE_PROPERTIES, anonymousId: "anon-id", pushToken: "", appContextInfo: AppContextInfo.test) let createEventPayload = CreateEventPayload(data: payloadData) - assertSnapshot(matching: createEventPayload, as: .json(KlaviyoEnvironment.encoder)) + assertSnapshot(of: createEventPayload, as: .json(KlaviyoEnvironment.encoder)) } - func testTokenPayload() throws { - let tokenPayload = PushTokenPayload( + func testTokenPayload() async throws { + let tokenPayload = await PushTokenPayload( pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) - assertSnapshot(matching: tokenPayload, as: .json(KlaviyoEnvironment.encoder)) + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo"), appContextInfo: environment.appContextInfo()) + assertSnapshot(of: tokenPayload, as: .json(KlaviyoEnvironment.encoder)) } func testUnregisterTokenPayload() throws { @@ -43,16 +46,17 @@ final class EncodableTests: XCTestCase { email: "foo", phoneNumber: "foo", anonymousId: "foo") - assertSnapshot(matching: tokenPayload, as: .json) + assertSnapshot(of: tokenPayload, as: .json) } - func testKlaviyoRequest() throws { - let tokenPayload = PushTokenPayload( + func testKlaviyoRequest() async throws { + let tokenPayload = await PushTokenPayload( pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) - let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) - assertSnapshot(matching: request, as: .json) + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo"), + appContextInfo: environment.appContextInfo()) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload), uuid: environment.uuid().uuidString) + assertSnapshot(of: request, as: .json) } } diff --git a/Tests/KlaviyoCoreTests/FileUtilsTests.swift b/Tests/KlaviyoCoreTests/FileUtilsTests.swift index 3251bafe..3c05dc4f 100644 --- a/Tests/KlaviyoCoreTests/FileUtilsTests.swift +++ b/Tests/KlaviyoCoreTests/FileUtilsTests.swift @@ -8,12 +8,19 @@ import KlaviyoCore import XCTest +@MainActor class FileUtilsTests: XCTestCase { + #if swift(>=6) + nonisolated(unsafe) var dataToWrite: Data? + nonisolated(unsafe) var wroteToFile = false + nonisolated(unsafe) var removedFile = false + #else var dataToWrite: Data? var wroteToFile = false var removedFile = false + #endif - override func setUpWithError() throws { + override func setUp() async throws { environment = KlaviyoEnvironment.test() environment.fileClient.write = { [weak self] data, _ in self?.wroteToFile = true @@ -43,6 +50,6 @@ class FileUtilsTests: XCTestCase { environment.fileClient.removeItem = { _ in throw FakeFileError.fake } - XCTAssertFalse(removeFile(at: TEST_URL)) + XCTAssertFalse(removeFile(fileClient: environment.fileClient, at: TEST_URL)) } } diff --git a/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift b/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift index 2245b924..8c4f18bf 100644 --- a/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift +++ b/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift @@ -11,8 +11,10 @@ import XCTest @MainActor final class KlaviyoAPITests: XCTestCase { - override func setUpWithError() throws { + var networkSession: NetworkSession! + override func setUp() async throws { environment = KlaviyoEnvironment.test() + networkSession = NetworkSession.test() } func testInvalidURL() async throws { @@ -20,11 +22,11 @@ final class KlaviyoAPITests: XCTestCase { await sendAndAssert(with: KlaviyoRequest( apiKey: "foo", - endpoint: .createProfile(CreateProfilePayload(data: .test))) - ) { result in + endpoint: .createProfile(CreateProfilePayload(data: .test)), uuid: environment.uuid().uuidString), + networkSession: networkSession) { result in switch result { case let .failure(error): - assertSnapshot(matching: error, as: .description) + assertSnapshot(of: error, as: .description) default: XCTFail("Expected url failure") } @@ -34,12 +36,12 @@ final class KlaviyoAPITests: XCTestCase { func testEncodingError() async throws { environment.encodeJSON = { _ in throw EncodingError.invalidValue("foo", .init(codingPath: [], debugDescription: "invalid")) } - let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) - await sendAndAssert(with: request) { result in + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test)), uuid: environment.uuid().uuidString) + await sendAndAssert(with: request, networkSession: networkSession) { result in switch result { case let .failure(error): - assertSnapshot(matching: error, as: .dump) + assertSnapshot(of: error, as: .dump) default: XCTFail("Expected encoding error.") } @@ -47,15 +49,15 @@ final class KlaviyoAPITests: XCTestCase { } func testNetworkError() async throws { - environment.networkSession = { NetworkSession.test(data: { _ in + networkSession = NetworkSession.test(data: { _ in throw NSError(domain: "network error", code: 0) - }) } - let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) - await sendAndAssert(with: request) { result in + }) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test)), uuid: environment.uuid().uuidString) + await sendAndAssert(with: request, networkSession: networkSession) { result in switch result { case let .failure(error): - assertSnapshot(matching: error, as: .dump) + assertSnapshot(of: error, as: .dump) default: XCTFail("Expected failure here.") } @@ -63,15 +65,15 @@ final class KlaviyoAPITests: XCTestCase { } func testInvalidStatusCode() async throws { - environment.networkSession = { NetworkSession.test(data: { _ in + networkSession = NetworkSession.test(data: { _ in (Data(), .non200Response) - }) } - let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) - await sendAndAssert(with: request) { result in + }) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test)), uuid: environment.uuid().uuidString) + await sendAndAssert(with: request, networkSession: networkSession) { result in switch result { case let .failure(error): - assertSnapshot(matching: error, as: .dump) + assertSnapshot(of: error, as: .dump) default: XCTFail("Expected failure here.") } @@ -79,16 +81,16 @@ final class KlaviyoAPITests: XCTestCase { } func testSuccessfulResponseWithProfile() async throws { - environment.networkSession = { NetworkSession.test(data: { request in - assertSnapshot(matching: request, as: .dump) + networkSession = NetworkSession.test(data: { request in + assertSnapshot(of: request, as: .dump) return (Data(), .validResponse) - }) } - let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) - await sendAndAssert(with: request) { result in + }) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test)), uuid: environment.uuid().uuidString) + await sendAndAssert(with: request, networkSession: networkSession) { result in switch result { case let .success(data): - assertSnapshot(matching: data, as: .dump) + assertSnapshot(of: data, as: .dump) default: XCTFail("Expected failure here.") } @@ -96,15 +98,15 @@ final class KlaviyoAPITests: XCTestCase { } func testSuccessfulResponseWithEvent() async throws { - environment.networkSession = { NetworkSession.test(data: { request in - assertSnapshot(matching: request, as: .dump) + networkSession = NetworkSession.test(data: { request in + assertSnapshot(of: request, as: .dump) return (Data(), .validResponse) - }) } - let request = KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(CreateEventPayload(data: CreateEventPayload.Event(name: "test")))) - await sendAndAssert(with: request) { result in + }) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(CreateEventPayload(data: CreateEventPayload.Event(name: "test", appContextInfo: .test))), uuid: environment.uuid().uuidString) + await sendAndAssert(with: request, networkSession: networkSession) { result in switch result { case let .success(data): - assertSnapshot(matching: data, as: .dump) + assertSnapshot(of: data, as: .dump) default: XCTFail("Expected failure here.") } @@ -112,16 +114,16 @@ final class KlaviyoAPITests: XCTestCase { } func testSuccessfulResponseWithStoreToken() async throws { - environment.networkSession = { NetworkSession.test(data: { request in - assertSnapshot(matching: request, as: .dump) + let networkSession = NetworkSession.test(data: { request in + assertSnapshot(of: request, as: .dump) return (Data(), .validResponse) - }) } - let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test)) - await sendAndAssert(with: request) { result in + }) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test), uuid: environment.uuid().uuidString) + await sendAndAssert(with: request, networkSession: networkSession) { result in switch result { case let .success(data): - assertSnapshot(matching: data, as: .dump) + assertSnapshot(of: data, as: .dump) default: XCTFail("Expected failure here.") } @@ -129,8 +131,9 @@ final class KlaviyoAPITests: XCTestCase { } func sendAndAssert(with request: KlaviyoRequest, + networkSession: NetworkSession, assertion: (Result) -> Void) async { - let result = await KlaviyoAPI().send(request, 0) + let result = await KlaviyoAPI().send(networkSession, request, 0) assertion(result) } } diff --git a/Tests/KlaviyoCoreTests/NetworkSessionTests.swift b/Tests/KlaviyoCoreTests/NetworkSessionTests.swift index 0423d516..3ef6181b 100644 --- a/Tests/KlaviyoCoreTests/NetworkSessionTests.swift +++ b/Tests/KlaviyoCoreTests/NetworkSessionTests.swift @@ -5,31 +5,37 @@ // Created by Noah Durell on 11/18/22. // -import KlaviyoCore +@testable import KlaviyoCore import SnapshotTesting import XCTest @MainActor class NetworkSessionTests: XCTestCase { - override func setUpWithError() throws { + override func setUp() async throws { environment = KlaviyoEnvironment.test() } - func testDefaultUserAgent() { - assertSnapshot(matching: NetworkSession.defaultUserAgent, as: .dump) + override func tearDown() async throws { + urlSession = nil } - func testCreateEmphemeralSesionHeaders() { - assertSnapshot(matching: createEmphemeralSession().configuration.httpAdditionalHeaders, as: .dump) + func testDefaultUserAgent() async { + let userAgent = await defaultUserAgent() + assertSnapshot(of: userAgent, as: .dump) + } + + func testCreateEmphemeralSesionHeaders() async { + let userAgent = await defaultUserAgent() + assertSnapshot(of: createEmphemeralSession(userAgent: userAgent).configuration.httpAdditionalHeaders, as: .dump) } func testSessionDataTask() async throws { URLProtocolOverrides.protocolClasses = [SimpleMockURLProtocol.self] let session = NetworkSession.production - let sampleRequest = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test)) + let sampleRequest = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test), uuid: environment.uuid().uuidString) let (data, response) = try await session.data(sampleRequest.urlRequest()) - assertSnapshot(matching: data, as: .dump) - assertSnapshot(matching: response, as: .dump) + assertSnapshot(of: data, as: .dump) + assertSnapshot(of: response, as: .dump) } } diff --git a/Tests/KlaviyoCoreTests/SimpleMockURLProtocol.swift b/Tests/KlaviyoCoreTests/SimpleMockURLProtocol.swift index d58fdc8b..f80bbaa4 100644 --- a/Tests/KlaviyoCoreTests/SimpleMockURLProtocol.swift +++ b/Tests/KlaviyoCoreTests/SimpleMockURLProtocol.swift @@ -7,6 +7,7 @@ import Foundation +@MainActor open class SimpleMockURLProtocol: URLProtocol { static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? override open func startLoading() { diff --git a/Tests/KlaviyoCoreTests/TestUtils.swift b/Tests/KlaviyoCoreTests/TestUtils.swift index 427bef74..a290a4ab 100644 --- a/Tests/KlaviyoCoreTests/TestUtils.swift +++ b/Tests/KlaviyoCoreTests/TestUtils.swift @@ -5,17 +5,17 @@ // Created by Ajay Subramanya on 8/15/24. // -import AnyCodable import Combine import Foundation import KlaviyoCore +import KlaviyoSDKDependencies enum FakeFileError: Error { case fake } let ARCHIVED_RETURNED_DATA = Data() -let SAMPLE_DATA: NSMutableArray = [ +@MainActor let SAMPLE_DATA: NSMutableArray = [ [ "properties": [ "foo": "bar" @@ -61,7 +61,7 @@ let TEST_FAILURE_JSON_INVALID_EMAIL = """ } """ -let SAMPLE_PROPERTIES = [ +@MainActor let SAMPLE_PROPERTIES = [ "Blob": "blob", "Stuff": 2, "Hello": [ @@ -69,12 +69,14 @@ let SAMPLE_PROPERTIES = [ ] ] as [String: Any] +@MainActor extension ArchiverClient { static let test = ArchiverClient( archivedData: { _, _ in ARCHIVED_RETURNED_DATA }, unarchivedMutableArray: { _ in SAMPLE_DATA }) } +@MainActor extension KlaviyoEnvironment { static var lastLog: String? static var test = { @@ -83,10 +85,8 @@ extension KlaviyoEnvironment { fileClient: FileClient.test, dataFromUrl: { _ in TEST_RETURN_DATA }, logger: LoggerClient.test, - appLifeCycle: AppLifeCycleEvents.test, notificationCenterPublisher: { _ in Empty().eraseToAnyPublisher() }, getNotificationSettings: { .authorized }, - getBackgroundSetting: { .available }, getBadgeAutoClearingSetting: { true }, startReachability: {}, stopReachability: {}, @@ -94,18 +94,21 @@ extension KlaviyoEnvironment { randomInt: { 0 }, raiseFatalError: { _ in }, emitDeveloperWarning: { _ in }, - networkSession: { NetworkSession.test() }, apiURL: { "dead_beef" }, encodeJSON: { _ in TEST_RETURN_DATA }, decoder: DataDecoder(jsonDecoder: TestJSONDecoder()), uuid: { UUID(uuidString: "00000000-0000-0000-0000-000000000001")! }, date: { Date(timeIntervalSince1970: 1_234_567_890) }, timeZone: { "EST" }, - appContextInfo: { AppContextInfo.test }, + klaviyoAPI: KlaviyoAPI.test(), - timer: { _ in Just(Date()).eraseToAnyPublisher() }, - SDKName: { __klaviyoSwiftName }, - SDKVersion: { __klaviyoSwiftVersion }) + timer: { _ in AsyncStream { + continuation in + continuation.yield(Date()) + continuation.finish() + } + }, + appContextInfo: { AppContextInfo.test }) } } @@ -117,10 +120,12 @@ extension FileClient { libraryDirectory: { TEST_URL }) } +@MainActor extension KlaviyoAPI { - static let test = { KlaviyoAPI(send: { _, _ in .success(TEST_RETURN_DATA) }) } + static let test = { KlaviyoAPI(send: { _, _, _ in .success(TEST_RETURN_DATA) }) } } +@MainActor extension LoggerClient { static var lastLoggedMessage: String? static let test = LoggerClient { message in @@ -128,24 +133,26 @@ extension LoggerClient { } } +@MainActor extension AppLifeCycleEvents { - static let test = Self(lifeCycleEvents: { Empty().eraseToAnyPublisher() }) + static let test = Self(lifeCycleEvents: { _, _, _, _ in Empty().eraseToAnyPublisher() }) } +@MainActor extension NetworkSession { static let successfulRepsonse = HTTPURLResponse(url: TEST_URL, statusCode: 200, httpVersion: nil, headerFields: nil)! - static let DEFAULT_CALLBACK: (URLRequest) async throws -> (Data, URLResponse) = { _ in + static let DEFAULT_CALLBACK: @Sendable (URLRequest) async throws -> (Data, URLResponse) = { _ in (Data(), successfulRepsonse) } - static func test(data: @escaping (URLRequest) async throws -> (Data, URLResponse) = DEFAULT_CALLBACK) -> NetworkSession { + static func test(data: @Sendable @escaping (URLRequest) async throws -> (Data, URLResponse) = DEFAULT_CALLBACK) -> NetworkSession { NetworkSession(data: data) } } class TestJSONDecoder: JSONDecoder, @unchecked Sendable { override func decode(_: T.Type, from _: Data) throws -> T where T: Decodable { - AppLifeCycleEvents.test as! T + AppContextInfo.test as! T } } @@ -159,7 +166,10 @@ extension AppContextInfo { osName: "iOS", manufacturer: "Orange", deviceModel: "jPhone 1,1", - deviceId: "fe-fi-fo-fum") + deviceId: "fe-fi-fo-fum", + environment: "debug", + klaviyoSdk: "swift", + sdkVersion: "4.0.0") } extension URLResponse { @@ -172,7 +182,7 @@ extension PushTokenPayload { pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: ProfilePayload(properties: [:], anonymousId: "anon-id")) + profile: ProfilePayload(properties: [:], anonymousId: "anon-id"), appContextInfo: AppContextInfo.test) } extension ProfilePayload { diff --git a/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift b/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift index 2f1511ec..ce443190 100644 --- a/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift +++ b/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift @@ -12,39 +12,39 @@ import XCTest let TIMEOUT_NANOSECONDS: UInt64 = 10_000_000_000 // 10 seconds +@MainActor class APIRequestErrorHandlingTests: XCTestCase { - @MainActor override func setUp() async throws { environment = KlaviyoEnvironment.test() } // MARK: - http error - @MainActor func testSendRequestHttpFailureDequesRequest() async throws { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - environment.klaviyoAPI.send = { _, _ in .failure(.httpError(500, TEST_RETURN_DATA)) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.httpError(500, TEST_RETURN_DATA)) } _ = await store.send(.sendRequest) - await store.receive(.deQueueCompletedResults(request)) { $0.flushing = false $0.requestsInFlight = [] } } - @MainActor func testSendRequestHttpFailureForPhoneNumberResetsStateAndDequesRequest() async throws { var initialState = INITIALIZED_TEST_STATE_INVALID_PHONE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.httpError(400, TEST_FAILURE_JSON_INVALID_PHONE_NUMBER.data(using: .utf8)!)) } + let failureJson = TEST_FAILURE_JSON_INVALID_PHONE_NUMBER.data(using: .utf8)! + environment.klaviyoAPI.send = { _, _, _ in .failure(.httpError(400, failureJson)) } _ = await store.send(.sendRequest) @@ -60,14 +60,16 @@ class APIRequestErrorHandlingTests: XCTestCase { } } - @MainActor func testSendRequestHttpFailureForEmailResetsStateAndDequesRequest() async throws { var initialState = INITIALIZED_TEST_STATE_INVALID_EMAIL() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.httpError(400, TEST_FAILURE_JSON_INVALID_EMAIL.data(using: .utf8)!)) } + let failureJson = TEST_FAILURE_JSON_INVALID_EMAIL.data(using: .utf8)! + environment.klaviyoAPI.send = { _, _, _ in .failure(.httpError(400, failureJson)) } _ = await store.send(.sendRequest) @@ -85,15 +87,16 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - network error - @MainActor func testSendRequestFailureIncrementsRetryCount() async throws { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "new_token", enablement: .authorized) + let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "new_token", enablement: .authorized, background: .available, appContextInfo: .test) initialState.requestsInFlight = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } _ = await store.send(.sendRequest) @@ -105,16 +108,17 @@ class APIRequestErrorHandlingTests: XCTestCase { } } - @MainActor func testSendRequestFailureWithBackoff() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retryWithBackoff(requestCount: 1, totalRetryCount: 1, currentBackoff: 1) let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "new_token", enablement: .authorized) + let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "new_token", enablement: .authorized, background: .available, appContextInfo: .test) initialState.requestsInFlight = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } _ = await store.send(.sendRequest) @@ -126,22 +130,23 @@ class APIRequestErrorHandlingTests: XCTestCase { } } - @MainActor func testSendRequestMaxRetries() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retry(ErrorHandlingConstants.maxRetries) let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - var request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "new_token", enablement: .authorized) + var request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "new_token", enablement: .authorized, background: .available, appContextInfo: .test) request2.uuid = "foo" initialState.requestsInFlight = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } _ = await store.send(.sendRequest) - await store.receive(.requestFailed(request, .retry(ErrorHandlingConstants.maxRetries + 1)), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.requestFailed(request, .retry(ErrorHandlingConstants.maxRetries + 1))) { $0.flushing = false $0.queue = [request2] $0.requestsInFlight = [] @@ -151,20 +156,21 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - internal error - @MainActor func testSendRequestInternalError() async throws { // NOTE: should really happen but putting this in for possible future cases and test coverage var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.internalError("internal error!")) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.internalError("internal error!")) } _ = await store.send(.sendRequest) - await store.receive(.deQueueCompletedResults(request), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.deQueueCompletedResults(request)) { $0.flushing = false $0.queue = [] $0.requestsInFlight = [] @@ -174,19 +180,20 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - internal request error - @MainActor func testSendRequestInternalRequestError() async throws { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.internalRequestError(KlaviyoAPIError.internalError("foo"))) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.internalRequestError(KlaviyoAPIError.internalError("foo"))) } _ = await store.send(.sendRequest) - await store.receive(.deQueueCompletedResults(request), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.deQueueCompletedResults(request)) { $0.flushing = false $0.queue = [] $0.requestsInFlight = [] @@ -196,15 +203,16 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - unknown error - @MainActor func testSendRequestUnknownError() async throws { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.unknownError(KlaviyoAPIError.internalError("foo"))) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.unknownError(KlaviyoAPIError.internalError("foo"))) } _ = await store.send(.sendRequest) @@ -223,13 +231,15 @@ class APIRequestErrorHandlingTests: XCTestCase { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.dataEncodingError(request)) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.dataEncodingError(request)) } _ = await store.send(.sendRequest) - await store.receive(.deQueueCompletedResults(request), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.deQueueCompletedResults(request)) { $0.flushing = false $0.queue = [] $0.requestsInFlight = [] @@ -239,14 +249,15 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - invalid data - @MainActor func testSendRequestInvalidData() async throws { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.invalidData) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.invalidData) } _ = await store.send(.sendRequest) @@ -260,17 +271,17 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - rate limit error - @MainActor func testRateLimitErrorWithExistingRetry() async throws { var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(backOff: 30)) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.rateLimitError(backOff: 30)) } _ = await store.send(.sendRequest) - await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 30)), timeout: TIMEOUT_NANOSECONDS) { $0.flushing = false $0.queue = [request] @@ -279,15 +290,16 @@ class APIRequestErrorHandlingTests: XCTestCase { } } - @MainActor func testRateLimitErrorWithExistingBackoffRetry() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 4) let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(backOff: 30)) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.rateLimitError(backOff: 30)) } _ = await store.send(.sendRequest) @@ -299,19 +311,20 @@ class APIRequestErrorHandlingTests: XCTestCase { } } - @MainActor func testRetryWithRetryAfter() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retryWithBackoff(requestCount: 3, totalRetryCount: 3, currentBackoff: 4) let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(backOff: 20)) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.rateLimitError(backOff: 20)) } _ = await store.send(.sendRequest) - await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 4, totalRetryCount: 4, currentBackoff: 20)), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 4, totalRetryCount: 4, currentBackoff: 20))) { $0.flushing = false $0.queue = [request] $0.requestsInFlight = [] @@ -321,19 +334,20 @@ class APIRequestErrorHandlingTests: XCTestCase { // MARK: - Missing or invalid response - @MainActor func testMissingOrInvalidResponse() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 4) let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) initialState.requestsInFlight = [request] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .failure(.missingOrInvalidResponse(nil)) } + environment.klaviyoAPI.send = { _, _, _ in .failure(.missingOrInvalidResponse(nil)) } _ = await store.send(.sendRequest) - await store.receive(.deQueueCompletedResults(request), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.deQueueCompletedResults(request)) { $0.flushing = false $0.queue = [] $0.requestsInFlight = [] diff --git a/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift b/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift index 782ee5df..d7995510 100644 --- a/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift +++ b/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift @@ -6,38 +6,41 @@ // @testable import KlaviyoSwift -import Combine +@preconcurrency import Combine // Will figure out a better way for this... import Foundation import KlaviyoCore import XCTest -class AppLifeCycleEventsTests: XCTestCase { +@MainActor +class AppLifeCycleEventsTests: XCTestCase, Sendable { + #if swift(>=6) + nonisolated(unsafe) let passThroughSubject = PassthroughSubject() + #else let passThroughSubject = PassthroughSubject() + #endif - func getFilteredNotificaitonPublished(name: Notification.Name) -> (Notification.Name) -> AnyPublisher { + func getFilteredNotificaitonPublished(name: Notification.Name) -> @Sendable (Notification.Name) -> AnyPublisher { // returns passthrough if it's match other return nothing - { [weak self] notificationName in + { [self] notificationName in if name == notificationName { - return self!.passThroughSubject.eraseToAnyPublisher() + return passThroughSubject.eraseToAnyPublisher() } else { return Empty().eraseToAnyPublisher() } } } - @MainActor - override func setUp() { + override func setUp() async throws { environment = KlaviyoEnvironment.test() } // MARK: - App Terminate - @MainActor func testAppTerminateStopsReachability() { environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: UIApplication.willTerminateNotification) let expection = XCTestExpectation(description: "Stop reachability is called.") - environment.stopReachability = { expection.fulfill() } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } + let cancellable = AppLifeCycleEvents() + .lifeCycleEvents(environment.notificationCenterPublisher, {}, { expection.fulfill() }, { .notReachable }).sink { _ in } passThroughSubject.send(Notification(name: UIApplication.willTerminateNotification.self)) @@ -50,7 +53,7 @@ class AppLifeCycleEventsTests: XCTestCase { let stopActionExpection = XCTestExpectation(description: "Stop action is received.") stopActionExpection.assertForOverFulfill = true var receivedAction: KlaviyoAction? - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, {}, { .notReachable }).sink { action in receivedAction = action.transformToKlaviyoAction stopActionExpection.fulfill() } @@ -67,8 +70,7 @@ class AppLifeCycleEventsTests: XCTestCase { func testAppBackgroundStopsReachability() { environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: UIApplication.didEnterBackgroundNotification) let expection = XCTestExpectation(description: "Stop reachability is called.") - environment.stopReachability = { expection.fulfill() } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, { expection.fulfill() }, { .notReachable }).sink { _ in } passThroughSubject.send(Notification(name: UIApplication.didEnterBackgroundNotification.self)) @@ -81,7 +83,7 @@ class AppLifeCycleEventsTests: XCTestCase { let stopActionExpection = XCTestExpectation(description: "Stop action is received.") stopActionExpection.assertForOverFulfill = true var receivedAction: KlaviyoAction? - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, {}, { .notReachable }).sink { action in receivedAction = action.transformToKlaviyoAction stopActionExpection.fulfill() } @@ -98,15 +100,18 @@ class AppLifeCycleEventsTests: XCTestCase { func testAppBecomesActiveStartsReachibility() { environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: UIApplication.didBecomeActiveNotification) let expection = XCTestExpectation(description: "Start reachability is called.") + #if swift(>=6) + nonisolated(unsafe) var count = 0 + #else var count = 0 - environment.startReachability = { + #endif + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, { if count == 0 { count += 1 } else { expection.fulfill() } - } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } + }, {}, { .notReachable }).sink { _ in } passThroughSubject.send(Notification(name: UIApplication.didBecomeActiveNotification.self)) @@ -119,7 +124,7 @@ class AppLifeCycleEventsTests: XCTestCase { let stopActionExpection = XCTestExpectation(description: "Stop action is received.") stopActionExpection.assertForOverFulfill = true var receivedAction: KlaviyoAction? - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, {}, { .notReachable }).sink { action in receivedAction = action.transformToKlaviyoAction stopActionExpection.fulfill() } @@ -135,8 +140,7 @@ class AppLifeCycleEventsTests: XCTestCase { environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: UIApplication.didBecomeActiveNotification) let expection = XCTestExpectation(description: "Start reachability is called.") expection.assertForOverFulfill = true - environment.startReachability = { expection.fulfill() } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, { expection.fulfill() }, {}, { .notReachable }).sink { _ in } wait(for: [expection], timeout: 0.1) XCTAssertEqual(1, expection.expectedFulfillmentCount) @@ -148,11 +152,10 @@ class AppLifeCycleEventsTests: XCTestCase { func testReachabilityStartFailureIsHandled() { environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: UIApplication.didBecomeActiveNotification) let expection = XCTestExpectation(description: "Start reachability is called.") - environment.startReachability = { + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, { expection.fulfill() throw KlaviyoAPIError.internalError("foo") - } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } + }, {}, { .notReachable }).sink { _ in } passThroughSubject.send(Notification(name: UIApplication.didBecomeActiveNotification.self)) @@ -166,11 +169,10 @@ class AppLifeCycleEventsTests: XCTestCase { func testReachabilityNotificationStatusHandled() { let expection = XCTestExpectation(description: "Reachability status is accessed") environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: ReachabilityChangedNotification) - environment.reachabilityStatus = { + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, {}, { expection.fulfill() return .reachableViaWWAN - } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } + }).sink { _ in } passThroughSubject.send(Notification(name: ReachabilityChangedNotification, object: Reachability())) @@ -181,16 +183,19 @@ class AppLifeCycleEventsTests: XCTestCase { func testReachabilityStatusNilThenNotNil() { let expection = XCTestExpectation(description: "Reachability status is accessed") environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: ReachabilityChangedNotification) + #if swift(>=6) + nonisolated(unsafe) var count = 0 + #else var count = 0 - environment.reachabilityStatus = { + #endif + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, {}, { if count == 0 { count += 1 return nil } expection.fulfill() return .reachableViaWWAN - } - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in + }).sink { _ in XCTFail() } receiveValue: { _ in } @@ -206,7 +211,7 @@ class AppLifeCycleEventsTests: XCTestCase { environment.notificationCenterPublisher = getFilteredNotificaitonPublished(name: ReachabilityChangedNotification) let reachabilityAction = XCTestExpectation(description: "Reachabilty changed is received.") var receivedAction: KlaviyoAction? - let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in + let cancellable = AppLifeCycleEvents().lifeCycleEvents(environment.notificationCenterPublisher, {}, {}, environment.reachabilityStatus).sink { action in receivedAction = action.transformToKlaviyoAction reachabilityAction.fulfill() } diff --git a/Tests/KlaviyoSwiftTests/EncodableTests.swift b/Tests/KlaviyoSwiftTests/EncodableTests.swift index c5961ce1..8453eb4f 100644 --- a/Tests/KlaviyoSwiftTests/EncodableTests.swift +++ b/Tests/KlaviyoSwiftTests/EncodableTests.swift @@ -12,21 +12,24 @@ import Foundation import SnapshotTesting import XCTest +@MainActor final class EncodableTests: XCTestCase { let testEncoder = KlaviyoEnvironment.encoder - override func setUpWithError() throws { + @MainActor + override func setUp() async throws { environment = KlaviyoEnvironment.test() testEncoder.outputFormatting = .prettyPrinted.union(.sortedKeys) } - func testKlaviyoState() throws { + func testKlaviyoState() async throws { let tokenPayload = PushTokenPayload( pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) - let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload), uuid: KlaviyoEnvironment.test().uuid().uuidString) + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo"), + appContextInfo: .test) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload), uuid: environment.uuid().uuidString) let klaviyoState = KlaviyoState( email: "foo", anonymousId: "foo", @@ -35,9 +38,9 @@ final class EncodableTests: XCTestCase { pushToken: "foo", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: KlaviyoEnvironment.test().appContextInfo())), + deviceData: .init(context: AppContextInfo.test)), queue: [request], requestsInFlight: [request]) - assertSnapshot(matching: klaviyoState, as: .json(KlaviyoEnvironment.encoder)) + assertSnapshot(of: klaviyoState, as: .json(KlaviyoEnvironment.encoder)) } } diff --git a/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift index 3a237063..e1e0de3e 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift @@ -5,6 +5,7 @@ // Created by Noah Durell on 2/21/23. // +@testable import KlaviyoSDKDependencies @testable import KlaviyoSwift import Foundation import KlaviyoCore @@ -12,6 +13,7 @@ import XCTest // MARK: - KlaviyoSDKTests +@MainActor class KlaviyoSDKTests: XCTestCase { // MARK: Properties @@ -19,13 +21,11 @@ class KlaviyoSDKTests: XCTestCase { // MARK: Setup - override func setUpWithError() throws { + override func setUp() async throws { klaviyo = KlaviyoSDK() environment = KlaviyoEnvironment.test() - } - - override func tearDown() async throws { - environment = KlaviyoEnvironment.test() + klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() + store = Store.test } func setupActionAssertion(expectedAction: KlaviyoAction, file: StaticString = #filePath, line: UInt = #line) -> XCTestExpectation { @@ -33,7 +33,7 @@ class KlaviyoSDKTests: XCTestCase { klaviyoSwiftEnvironment.send = { action in XCTAssertEqual(action, expectedAction, file: file, line: line) expectation.fulfill() - return nil + return StoreTask(rawValue: .none) } return expectation } @@ -47,7 +47,7 @@ class KlaviyoSDKTests: XCTestCase { // MARK: test initialize func testInitializeSDk() throws { - let expectation = setupActionAssertion(expectedAction: .initialize(TEST_API_KEY)) + let expectation = setupActionAssertion(expectedAction: .initialize(TEST_API_KEY, .test)) klaviyo.initialize(with: TEST_API_KEY) @@ -72,7 +72,7 @@ class KlaviyoSDKTests: XCTestCase { phoneNumber: "+15555551212", firstName: "John", lastName: "Smith") - let expectation = setupActionAssertion(expectedAction: .enqueueProfile(profile)) + let expectation = setupActionAssertion(expectedAction: .enqueueProfile(profile, .test)) klaviyo.set(profile: profile) @@ -83,7 +83,7 @@ class KlaviyoSDKTests: XCTestCase { func testCreateEvent() throws { let event = Event(name: .openedAppMetric) - let expectation = setupActionAssertion(expectedAction: .enqueueEvent(event)) + let expectation = setupActionAssertion(expectedAction: .enqueueEvent(event, .test)) klaviyo.create(event: event) @@ -95,7 +95,7 @@ class KlaviyoSDKTests: XCTestCase { "Total Price": 10.99, "Items Purchased": ["Hot Dog", "Fries", "Shake"] ], value: 10.99) - let expectation = setupActionAssertion(expectedAction: .enqueueEvent(event)) + let expectation = setupActionAssertion(expectedAction: .enqueueEvent(event, .test)) klaviyo.create(event: event) @@ -107,7 +107,7 @@ class KlaviyoSDKTests: XCTestCase { func testSetPushToken() throws { let tokenData = "mytoken".data(using: .utf8)! let strToken = tokenData.reduce("") { $0 + String(format: "%02.2hhx", $1) } - let expectation = setupActionAssertion(expectedAction: .setPushToken(strToken, .authorized)) + let expectation = setupActionAssertion(expectedAction: .setPushToken(strToken, .authorized, .available, .test)) klaviyo.set(pushToken: tokenData) @@ -117,7 +117,7 @@ class KlaviyoSDKTests: XCTestCase { // MARK: test set external id func testSetExternalId() throws { - let expectation = setupActionAssertion(expectedAction: .setExternalId("foo")) + let expectation = setupActionAssertion(expectedAction: .setExternalId("foo", .test)) _ = klaviyo.set(externalId: "foo") @@ -133,7 +133,7 @@ class KlaviyoSDKTests: XCTestCase { "foo": "bar" ] ]] - let expectation = setupActionAssertion(expectedAction: .enqueueEvent(.init(name: ._openedPush, properties: push_body))) + let expectation = setupActionAssertion(expectedAction: .enqueueEvent(.init(name: ._openedPush, properties: push_body), .test)) let response = try UNNotificationResponse.with(userInfo: push_body) let handled = klaviyo.handle(notificationResponse: response) { callback.fulfill() @@ -167,8 +167,17 @@ class KlaviyoSDKTests: XCTestCase { // MARK: test property getters - func testPropertyGetters() throws { - klaviyoSwiftEnvironment.state = { KlaviyoState(email: "foo@foo.com", phoneNumber: "555BLOB", externalId: "my_test_id", pushTokenData: .init(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())), queue: []) } + func testPropertyGetters() async throws { + let state = await KlaviyoState(email: "foo@foo.com", + phoneNumber: "555BLOB", + externalId: "my_test_id", + pushTokenData: .init(pushToken: "blobtoken", + pushEnablement: .authorized, + pushBackground: .available, + deviceData: .init(context: environment.appContextInfo())), + queue: []) + klaviyoSwiftEnvironment.state = { state } + let klaviyo = KlaviyoSDK() XCTAssertEqual("foo@foo.com", klaviyo.email) XCTAssertEqual("555BLOB", klaviyo.phoneNumber) diff --git a/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift index 8c656358..b610f05d 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift @@ -6,12 +6,13 @@ // @testable import KlaviyoSwift -import AnyCodable import Foundation import KlaviyoCore +import KlaviyoSDKDependencies import SnapshotTesting import XCTest +@MainActor final class KlaviyoStateTests: XCTestCase { let TEST_EVENT = [ "event": "$opened_push", @@ -75,7 +76,7 @@ final class KlaviyoStateTests: XCTestCase { environment.fileClient.fileExists = { _ in false } environment.archiverClient.unarchivedMutableArray = { _ in [] } let state = loadKlaviyoStateFromDisk(apiKey: "foo") - assertSnapshot(matching: state, as: .dump) + assertSnapshot(of: state, as: .dump) } func testStateFileExistsInvalidData() throws { @@ -91,7 +92,7 @@ final class KlaviyoStateTests: XCTestCase { } let state = loadKlaviyoStateFromDisk(apiKey: "foo") - assertSnapshot(matching: state, as: .dump) + assertSnapshot(of: state, as: .dump) } func testStateFileExistsInvalidJSON() throws { @@ -106,7 +107,7 @@ final class KlaviyoStateTests: XCTestCase { } let state = loadKlaviyoStateFromDisk(apiKey: "foo") - assertSnapshot(matching: state, as: .dump) + assertSnapshot(of: state, as: .dump) } func testValidStateFileExists() throws { @@ -114,32 +115,32 @@ final class KlaviyoStateTests: XCTestCase { true } environment.dataFromUrl = { _ in + // ND: This doesn't actually get used anymore... try! JSONEncoder().encode(KlaviyoState( apiKey: "foo", anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [])) } - let state = loadKlaviyoStateFromDisk(apiKey: "foo") - assertSnapshot(matching: state, as: .dump) + assertSnapshot(of: state, as: .dump) } func testFullKlaviyoStateEncodingDecodingIsEqual() throws { let event = Event.test - let createEventPayload = CreateEventPayload(data: CreateEventPayload.Event(name: event.metric.name.value)) - let eventRequest = KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(createEventPayload)) + let createEventPayload = CreateEventPayload(data: CreateEventPayload.Event(name: event.metric.name.value, appContextInfo: .test)) + let eventRequest = KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(createEventPayload), uuid: environment.uuid().uuidString) let profile = Profile.test let payload = CreateProfilePayload(data: profile.toAPIModel(anonymousId: "foo")) - let profileRequest = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(payload)) + let profileRequest = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(payload), uuid: environment.uuid().uuidString) let tokenPayload = PushTokenPayload( pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) - let tokenRequest = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo"), appContextInfo: .test) + let tokenRequest = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload), uuid: environment.uuid().uuidString) let state = KlaviyoState(apiKey: "key", queue: [tokenRequest, eventRequest, profileRequest]) diff --git a/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift b/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift index 99baadea..a22660a0 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift @@ -4,37 +4,38 @@ // // Created by Noah Durell on 9/30/22. // -import AnyCodable import Combine import CombineSchedulers import KlaviyoCore +import KlaviyoSDKDependencies import XCTest @_spi(KlaviyoPrivate) @testable import KlaviyoSwift let ARCHIVED_RETURNED_DATA = Data() +@MainActor extension ArchiverClient { static let test = ArchiverClient( archivedData: { _, _ in ARCHIVED_RETURNED_DATA }, unarchivedMutableArray: { _ in SAMPLE_DATA }) } +@MainActor extension AppLifeCycleEvents { - static let test = Self(lifeCycleEvents: { Empty().eraseToAnyPublisher() }) + static let test = Self(lifeCycleEvents: { _, _, _, _ in Empty().eraseToAnyPublisher() }) } +@MainActor extension KlaviyoEnvironment { static var lastLog: String? - static var test = { + @MainActor static var test = { KlaviyoEnvironment( archiverClient: ArchiverClient.test, fileClient: FileClient.test, dataFromUrl: { _ in TEST_RETURN_DATA }, logger: LoggerClient.test, - appLifeCycle: AppLifeCycleEvents.test, notificationCenterPublisher: { _ in Empty().eraseToAnyPublisher() }, getNotificationSettings: { .authorized }, - getBackgroundSetting: { .available }, getBadgeAutoClearingSetting: { true }, startReachability: {}, stopReachability: {}, @@ -42,18 +43,18 @@ extension KlaviyoEnvironment { randomInt: { 0 }, raiseFatalError: { _ in }, emitDeveloperWarning: { _ in }, - networkSession: { NetworkSession.test() }, apiURL: { "dead_beef" }, encodeJSON: { _ in TEST_RETURN_DATA }, decoder: DataDecoder(jsonDecoder: TestJSONDecoder()), uuid: { UUID(uuidString: "00000000-0000-0000-0000-000000000001")! }, date: { Date(timeIntervalSince1970: 1_234_567_890) }, timeZone: { "EST" }, - appContextInfo: { AppContextInfo.test }, klaviyoAPI: KlaviyoAPI.test(), - timer: { _ in Just(Date()).eraseToAnyPublisher() }, - SDKName: { __klaviyoSwiftName }, - SDKVersion: { __klaviyoSwiftVersion }) + timer: { _ in AsyncStream { continuation in + continuation.yield(Date()) + continuation.finish() + } }, + appContextInfo: { AppContextInfo.test }) } } @@ -69,20 +70,22 @@ class InvalidJSONDecoder: JSONDecoder, @unchecked Sendable { } } -struct KlaviyoTestReducer: ReducerProtocol { - var reducer: (inout KlaviyoSwift.KlaviyoState, KlaviyoAction) -> EffectTask = { _, _ in .none } - - func reduce(into state: inout KlaviyoSwift.KlaviyoState, action: KlaviyoSwift.KlaviyoAction) -> KlaviyoSwift.EffectTask { +struct KlaviyoTestReducer: Reducer { + func reduce(into state: inout KlaviyoSwift.KlaviyoState, action: KlaviyoSwift.KlaviyoAction) -> KlaviyoSDKDependencies.Effect { reducer(&state, action) } + var reducer: (inout KlaviyoSwift.KlaviyoState, KlaviyoAction) -> Effect = { _, _ in .none } + typealias State = KlaviyoState typealias Action = KlaviyoAction } extension Store where State == KlaviyoState, Action == KlaviyoAction { - static let test = Store(initialState: .test, reducer: KlaviyoTestReducer()) + static let test = Store(initialState: .test) { + KlaviyoTestReducer() + } } extension FileClient { @@ -94,23 +97,24 @@ extension FileClient { } extension KlaviyoAPI { - static let test = { KlaviyoAPI(send: { _, _ in .success(TEST_RETURN_DATA) }) } + @MainActor static let test = { KlaviyoAPI(send: { _, _, _ in .success(TEST_RETURN_DATA) }) } } extension LoggerClient { - static var lastLoggedMessage: String? - static let test = LoggerClient { message in + @MainActor static var lastLoggedMessage: String? + @MainActor static let test = LoggerClient { message in lastLoggedMessage = message } } +@MainActor extension NetworkSession { static let successfulRepsonse = HTTPURLResponse(url: TEST_URL, statusCode: 200, httpVersion: nil, headerFields: nil)! - static let DEFAULT_CALLBACK: (URLRequest) async throws -> (Data, URLResponse) = { _ in + static let DEFAULT_CALLBACK: @Sendable (URLRequest) async throws -> (Data, URLResponse) = { _ in (Data(), successfulRepsonse) } - static func test(data: @escaping (URLRequest) async throws -> (Data, URLResponse) = DEFAULT_CALLBACK) -> NetworkSession { + static func test(data: @Sendable @escaping (URLRequest) async throws -> (Data, URLResponse) = DEFAULT_CALLBACK) -> NetworkSession { NetworkSession(data: data) } } @@ -125,7 +129,10 @@ extension AppContextInfo { osName: "iOS", manufacturer: "Orange", deviceModel: "jPhone 1,1", - deviceId: "fe-fi-fo-fum") + deviceId: "fe-fi-fo-fum", + environment: "debug", + klaviyoSdk: "swift", + sdkVersion: "4.0.0") } extension StateChangePublisher { @@ -164,3 +171,38 @@ extension UNNotificationResponse { return response } } + +// Simplistic equality for testing. +extension KlaviyoAPIError: Equatable { + public static func ==(lhs: KlaviyoCore.KlaviyoAPIError, rhs: KlaviyoCore.KlaviyoAPIError) -> Bool { + switch (lhs, rhs) { + case let (.dataEncodingError(lhsReq), .dataEncodingError(rhsReq)): + return lhsReq == rhsReq + case let (.httpError(lhsCode, _), .httpError(rhsCode, _)): + return lhsCode == rhsCode + case let (.rateLimitError(backOff: lhsBackOff), .rateLimitError(backOff: rhsBackoff)): + return lhsBackOff == rhsBackoff + case (.missingOrInvalidResponse, .missingOrInvalidResponse): + return true + case (.networkError, .networkError): + return true + case (.internalError, .internalError): + return true + case (.internalRequestError, .internalRequestError): + return true + case (.unknownError, .unknownError): + return true + case (.invalidData, .invalidData): + return true + default: + return false + } + } +} + +extension TestStore where Action == KlaviyoAction, State == KlaviyoState { + static let testStore = { initialState in TestStore(initialState: initialState) { + KlaviyoReducer() + } + } +} diff --git a/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift b/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift index 7ac9e32c..c99b8033 100644 --- a/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift +++ b/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift @@ -1,170 +1,71 @@ -// -// StateChangePublisherTests.swift -// -// -// Created by Noah Durell on 12/21/22. -// - -import Combine -import CombineSchedulers -import Foundation import XCTest @_spi(KlaviyoPrivate) @testable import KlaviyoSwift +import Combine import KlaviyoCore -final class StateChangePublisherTests: XCTestCase { - @MainActor - override func setUpWithError() throws { +@MainActor +class StateChangePublisherTests: XCTestCase { + private var cancellables: Set = [] + + override func setUp() async throws { + cancellables = [] environment = KlaviyoEnvironment.test() + klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() } - @MainActor - func testStateChangePublisher() throws { - let savedCalledExpectation = XCTestExpectation(description: "Save called on initialization") - // Third call set email should trigger again - let setEmailSaveExpectation = XCTestExpectation(description: "Set email should be saved.") - - var count = 0 - environment.fileClient.write = { _, _ in - if count == 0 { - savedCalledExpectation.fulfill() - } else if count == 1 { - setEmailSaveExpectation.fulfill() - } - count += 1 - } - let testScheduler = DispatchQueue.test - StateChangePublisher.debouncedPublisher = { publisher in - publisher - .debounce(for: .seconds(1), scheduler: testScheduler) - .eraseToAnyPublisher() - } - let initializationReducer = { (state: inout KlaviyoState, action: KlaviyoAction) -> EffectTask in - switch action { - case .initialize: - state.initalizationState = .initialized - return StateChangePublisher().publisher().eraseToEffect() - case let .setEmail(email): - state.email = email - return .none - default: - return .none - } - } - - let reducer = KlaviyoTestReducer(reducer: initializationReducer) - let test = Store(initialState: .test, reducer: reducer) - klaviyoSwiftEnvironment.send = { - test.send($0) - } - - klaviyoSwiftEnvironment.statePublisher = { - test.state.eraseToAnyPublisher() - } - - testScheduler.run() - @MainActor func runDebouncedEffect() { - _ = klaviyoSwiftEnvironment.send(.initialize("foo")) - testScheduler.run() - // This should not trigger a save since in our reducer it does not change the state. - _ = klaviyoSwiftEnvironment.send(.setPushToken("foo", .authorized)) - _ = klaviyoSwiftEnvironment.send(.setEmail("foo")) - } - runDebouncedEffect() - testScheduler.advance(by: .seconds(2.0)) - - wait(for: [savedCalledExpectation, setEmailSaveExpectation], timeout: 2.0) - - XCTAssertEqual(count, 2) + override func tearDown() async throws { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } - @MainActor - func testStateChangeDuplicateAreRemoved() throws { - let savedCalledExpectation = XCTestExpectation(description: "Save called on initialization") - savedCalledExpectation.assertForOverFulfill = true + func testPublisherCallsEmitsOnlyOnce() async { + // Create a mock state to publish + let mockState = INITIALIZED_TEST_STATE() - environment.fileClient.write = { _, _ in - savedCalledExpectation.fulfill() - } - let initializationReducer = { (state: inout KlaviyoState, action: KlaviyoAction) -> EffectTask in - switch action { - case .initialize: - state.initalizationState = .initialized - return StateChangePublisher.test.publisher().eraseToEffect() - case .flushQueue: - return .none - default: - return .none - } - } - - let reducer = KlaviyoTestReducer(reducer: initializationReducer) - let test = Store(initialState: .test, reducer: reducer) - klaviyoSwiftEnvironment.send = { - test.send($0) - } + // Use a PassthroughSubject to simulate the statePublisher + let stateSubject = PassthroughSubject() + // Mock the klaviyoSwiftEnvironment to use the stateSubject as the publisher klaviyoSwiftEnvironment.statePublisher = { - test.state.eraseToAnyPublisher() - } - - @MainActor func runDebouncedEffect() { - _ = klaviyoSwiftEnvironment.send(.initialize("foo")) - _ = klaviyoSwiftEnvironment.send(.flushQueue) - _ = klaviyoSwiftEnvironment.send(.flushQueue) - } - - runDebouncedEffect() - - wait(for: [savedCalledExpectation], timeout: 1.0) - } - - func testQuickStateUpdatesTriggerOnlyOneSaves() throws { - let savedCalledExpectation = XCTestExpectation(description: "Save called on initialization") - var count = 0 - environment.fileClient.write = { _, _ in - if count == 1 { - savedCalledExpectation.fulfill() - } - count += 1 + stateSubject.eraseToAnyPublisher() } + // Use a TestScheduler to control time let testScheduler = DispatchQueue.test + + // Override the debouncedPublisher to use the test scheduler StateChangePublisher.debouncedPublisher = { publisher in publisher .debounce(for: .seconds(1), scheduler: testScheduler) .eraseToAnyPublisher() } - let initializationReducer = { (state: inout KlaviyoState, action: KlaviyoAction) -> EffectTask in - switch action { - case .initialize: - state.initalizationState = .initialized - return StateChangePublisher().publisher().eraseToEffect() - case let .setEmail(email): - state.email = email - return .none - default: - return .none + + let expectation = XCTestExpectation(description: "Publisher emits once") + expectation.expectedFulfillmentCount = 1 + var count = 0 + + Task { + for await _ in StateChangePublisher().publisher() { + count += 1 + expectation.fulfill() } } - let reducer = KlaviyoTestReducer(reducer: initializationReducer) - let test = Store(initialState: .test, reducer: reducer) - klaviyoSwiftEnvironment.send = { - test.send($0) - } + // Send the mock state + stateSubject.send(mockState) - klaviyoSwiftEnvironment.statePublisher = { - test.state.eraseToAnyPublisher() - } - _ = klaviyoSwiftEnvironment.send(.initialize("foo")) - testScheduler.run() - for i in 0...10 { - _ = klaviyoSwiftEnvironment.send(.setEmail("foo\(i)")) - } - testScheduler.advance(by: 1.0) - wait(for: [savedCalledExpectation], timeout: 1.0) + // Advance time to trigger the debounced emission + await testScheduler.advance(by: .seconds(1.1)) + + // Send the mock state again (should not cause a new emission) + stateSubject.send(mockState) + + // Advance time again to process any pending events + await testScheduler.advance(by: .seconds(1.1)) + + // Wait for expectation or timeout + await fulfillment(of: [expectation], timeout: 5.0) - XCTAssertEqual(count, 2) + XCTAssertEqual(count, 1) } } diff --git a/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift b/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift index de96dc2d..8ea2597c 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift @@ -10,8 +10,8 @@ import Foundation import KlaviyoCore import XCTest +@MainActor class StateManagementEdgeCaseTests: XCTestCase { - @MainActor override func setUp() async throws { environment = KlaviyoEnvironment.test() klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() @@ -19,10 +19,9 @@ class StateManagementEdgeCaseTests: XCTestCase { // MARK: - initialization - @MainActor func testInitializeWhileInitializing() async throws { let initialState = KlaviyoState(queue: [], requestsInFlight: []) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) store.exhaustivity = .off environment.fileClient.fileExists = { _ in @@ -33,35 +32,33 @@ class StateManagementEdgeCaseTests: XCTestCase { let apiKey = "fake-key" // Avoids a warning in xcode despite the result being discardable. - _ = await store.send(.initialize(apiKey)) { + _ = await store.send(.initialize(apiKey, .test)) { $0.apiKey = apiKey $0.initalizationState = .initializing } // Should be no state change here. - _ = await store.send(.initialize(apiKey)) + _ = await store.send(.initialize(apiKey, .test)) } - @MainActor func testInitializeAfterInitialized() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Using the same key shouldn't do much - _ = await store.send(.initialize(initialState.apiKey!)) + _ = await store.send(KlaviyoAction.initialize(initialState.apiKey!, AppContextInfo.test)) let newApiKey = "new-api-key" // Using a new key should update the key and generate two requests - _ = await store.send(.initialize(newApiKey)) { + _ = await store.send(.initialize(newApiKey, .test)) { $0.queue = [$0.buildUnregisterRequest(apiKey: $0.apiKey!, anonymousId: $0.anonymousId!, pushToken: $0.pushTokenData!.pushToken), - $0.buildTokenRequest(apiKey: newApiKey, anonymousId: $0.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement)] + $0.buildTokenRequest(apiKey: newApiKey, anonymousId: $0.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement, background: $0.pushTokenData!.pushBackground, appContextInfo: .test)] $0.apiKey = newApiKey } } // MARK: - Send Request - @MainActor func testSendRequestBeforeInitialization() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -69,14 +66,13 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: true) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Shouldn't really happen but getting more coverage... _ = await store.send(.sendRequest) } // MARK: - Complete Initialization - @MainActor func testCompleteInitializationWhileAlreadyInitialized() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -88,12 +84,13 @@ class StateManagementEdgeCaseTests: XCTestCase { email: "foo@foo.com", phoneNumber: "1800-blobs4u", externalId: "external-id", queue: [], requestsInFlight: [], initalizationState: .initialized, - flushing: true), reducer: KlaviyoReducer()) + flushing: true)) { + KlaviyoReducer() + } // Shouldn't really happen but getting more coverage... _ = await store.send(.completeInitialization(initialState)) } - @MainActor func testCompleteInitializationWithExistingIdentifiers() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -105,24 +102,25 @@ class StateManagementEdgeCaseTests: XCTestCase { email: "foo@foo.com", phoneNumber: "1800-blobs4u", externalId: "external-id", queue: [], requestsInFlight: [], initalizationState: .initializing, - flushing: true), reducer: KlaviyoReducer()) + flushing: true)) { + KlaviyoReducer() + } // Attempting to get more coverage _ = await store.send(.completeInitialization(initialState)) { $0.initalizationState = .initialized $0.anonymousId = "foo" } await store.receive(.start) - await store.receive(.flushQueue) - await store.receive(.setPushEnablement(PushEnablement.authorized)) + await store.receive(.setPushEnablement(PushEnablement.authorized, .available, .test)) await store.receive(.setBadgeCount(0)) + await store.receive(.flushQueue(.test)) } // MARK: - Set Email - @MainActor func testSetEmailUninitializedDoesNotAddToPendingRequest() async throws { let expection = XCTestExpectation(description: "fatal error expected") - environment.emitDeveloperWarning = { _ in + environment.logger.error = { _ in // Would really fatalError - not happening because we can't do that in tests so we fake it. expection.fulfill() } @@ -133,14 +131,13 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setEmail("test@blob.com")) + _ = await store.send(.setEmail("test@blob.com", .test)) await fulfillment(of: [expection]) } - @MainActor func testSetEmailMissingAnonymousIdStillSetsEmail() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -148,32 +145,29 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .initialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setEmail("test@blob.com")) { + _ = await store.send(.setEmail("test@blob.com", .test)) { $0.email = "test@blob.com" } } - @MainActor func testSetEmptyEmail() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setEmail("")) + _ = await store.send(.setEmail("", .test)) } - @MainActor func testSetEmailWithWhiteSpace() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setEmail(" ")) + _ = await store.send(.setEmail(" ", .test)) } // MARK: - Set External Id - @MainActor func testSetExternalIdUninitializedDoesNotAddToPendingRequest() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -182,12 +176,11 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setExternalId("external-blob-id")) + _ = await store.send(.setExternalId("external-blob-id", .test)) } - @MainActor func testSetExternalIdMissingAnonymousIdStillSetsExternalId() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -195,32 +188,29 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .initialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setExternalId("external-blob-id")) { + _ = await store.send(.setExternalId("external-blob-id", .test)) { $0.externalId = "external-blob-id" } } - @MainActor func testSetEmptyExternalId() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setExternalId("")) + _ = await store.send(.setExternalId("", .test)) } - @MainActor func testSetExternalIdWithWhiteSpaces() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setExternalId("")) + _ = await store.send(.setExternalId("", .test)) } // MARK: - Set Phone number - @MainActor func testSetPhoneNumberUninitializedDoesNotAddToPendingRequest() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -229,44 +219,40 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setPhoneNumber("1-800-Blobs4u")) + _ = await store.send(.setPhoneNumber("1-800-Blobs4u", .test)) } - @MainActor func testSetPhoneNumberMissingApiKeyStillSetsPhoneNumber() async throws { let initialState = KlaviyoState(anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setPhoneNumber("1-800-Blobs4u")) { + _ = await store.send(.setPhoneNumber("1-800-Blobs4u", .test)) { $0.phoneNumber = "1-800-Blobs4u" } } - @MainActor func testSetEmptyPhoneNumber() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setPhoneNumber("")) + _ = await store.send(.setPhoneNumber("", .test)) } - @MainActor func testSetPhoneNumberWithWhiteSpaces() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setPhoneNumber("")) + _ = await store.send(.setPhoneNumber("", .test)) } // MARK: - Set Push Token - @MainActor func testSetPushTokenUninitializedDoesNotAddToPendingRequest() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -275,12 +261,11 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setPushToken("blob_token", .authorized)) + _ = await store.send(.setPushToken("blob_token", .authorized, .available, .test)) } - @MainActor func testSetPushTokenWithMissingAnonymousId() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -288,17 +273,16 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .initialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Impossible case really but we want coverage - _ = await store.send(.setPushToken("blob_token", .authorized)) { - $0.pendingRequests = [.pushToken("blob_token", .authorized)] + _ = await store.send(.setPushToken("blob_token", .authorized, .available, .test)) { + $0.pendingRequests = [.pushToken("blob_token", .authorized, .available, .test)] } } // MARK: - Stop - @MainActor func testStopUninitialized() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -307,12 +291,11 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) _ = await store.send(.stop) } - @MainActor func testStopInitializing() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -321,14 +304,13 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .initializing, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) _ = await store.send(.stop) } // MARK: - Start - @MainActor func testStartUninitialized() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -337,7 +319,7 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) _ = await store.send(.start) } @@ -351,7 +333,6 @@ class StateManagementEdgeCaseTests: XCTestCase { let expectation = XCTestExpectation(description: "Should set badge to 0") klaviyoSwiftEnvironment.setBadgeCount = { _ in expectation.fulfill() - return nil } let initialState = KlaviyoState(apiKey: apiKey, anonymousId: "foo", queue: [], @@ -362,16 +343,17 @@ class StateManagementEdgeCaseTests: XCTestCase { email: "foo@foo.com", phoneNumber: "1800-blobs4u", externalId: "external-id", queue: [], requestsInFlight: [], initalizationState: .initializing, - flushing: true), reducer: KlaviyoReducer()) + flushing: true)) { KlaviyoReducer() } // Attempting to get more coverage _ = await store.send(.completeInitialization(initialState)) { $0.initalizationState = .initialized $0.anonymousId = "foo" } await store.receive(.start) - await store.receive(.flushQueue) - await store.receive(.setPushEnablement(PushEnablement.authorized)) + + await store.receive(.setPushEnablement(PushEnablement.authorized, .available, .test)) await store.receive(.setBadgeCount(0)) + await store.receive(.flushQueue(.test)) await fulfillment(of: [expectation], timeout: 1, enforceOrder: true) } @@ -385,7 +367,6 @@ class StateManagementEdgeCaseTests: XCTestCase { expectation.isInverted = true klaviyoSwiftEnvironment.setBadgeCount = { _ in expectation.fulfill() - return nil } let initialState = KlaviyoState(apiKey: apiKey, anonymousId: "foo", queue: [], @@ -396,21 +377,20 @@ class StateManagementEdgeCaseTests: XCTestCase { email: "foo@foo.com", phoneNumber: "1800-blobs4u", externalId: "external-id", queue: [], requestsInFlight: [], initalizationState: .initializing, - flushing: true), reducer: KlaviyoReducer()) + flushing: true)) { KlaviyoReducer() } // Attempting to get more coverage _ = await store.send(.completeInitialization(initialState)) { $0.initalizationState = .initialized $0.anonymousId = "foo" } await store.receive(.start) - await store.receive(.flushQueue) - await store.receive(.setPushEnablement(PushEnablement.authorized)) + await store.receive(.setPushEnablement(PushEnablement.authorized, PushBackground.available, .test)) + await store.receive(.flushQueue(.test)) await fulfillment(of: [expectation], timeout: 1, enforceOrder: true) } // MARK: - Network Status Changed - @MainActor func testNetworkStatusChangedUninitialized() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -419,14 +399,13 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) _ = await store.send(.networkConnectivityChanged(.reachableViaWWAN)) } // MARK: - Missing api key for token request - @MainActor func testTokenRequestMissingApiKey() async { let initialState = KlaviyoState( anonymousId: environment.uuid().uuidString, @@ -434,39 +413,41 @@ class StateManagementEdgeCaseTests: XCTestCase { requestsInFlight: [], initalizationState: .initialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Impossible case really but we want coverage on it. - _ = await store.send(.setPushToken("blobtoken", .authorized)) { - $0.pendingRequests = [.pushToken("blobtoken", .authorized)] + _ = await store.send(.setPushToken("blobtoken", .authorized, .available, .test)) { + $0.pendingRequests = [.pushToken("blobtoken", .authorized, .available, .test)] } } // MARK: - set enqueue event uninitialized - @MainActor func testOpenedPushEventUninitializedAddsToPendingRequests() async throws { - let store = TestStore(initialState: .init(queue: []), reducer: KlaviyoReducer()) + let store = TestStore(initialState: .init(queue: [])) { + KlaviyoReducer() + } let event = Event(name: ._openedPush) - _ = await store.send(.enqueueEvent(event)) { - $0.pendingRequests = [.event(event)] + _ = await store.send(.enqueueEvent(event, .test)) { + $0.pendingRequests = [.event(event, .test)] } } - @MainActor func testEnqueueNonOpenedPushEventUninitializedDoesNotAddToPendingRequest() async throws { let expection = XCTestExpectation(description: "fatal error expected") - environment.emitDeveloperWarning = { _ in + environment.logger.error = { _ in // Would really runTimeWarn - not happening because we can't do that in tests so we fake it. expection.fulfill() } - let store = TestStore(initialState: .init(queue: []), reducer: KlaviyoReducer()) + let store = TestStore(initialState: .init(queue: [])) { + KlaviyoReducer() + } let nonOpenedPushEvents = Event.EventName.allCases.filter { $0 != ._openedPush } for event in nonOpenedPushEvents { let event = Event(name: event) - _ = await store.send(.enqueueEvent(event)) + _ = await store.send(.enqueueEvent(event, .test)) } await fulfillment(of: [expection]) @@ -474,20 +455,19 @@ class StateManagementEdgeCaseTests: XCTestCase { // MARK: - set profile uninitialized - @MainActor func testSetProfileUnitialized() async throws { let expection = XCTestExpectation(description: "fatal error expected") - environment.emitDeveloperWarning = { _ in - // Would really runTimeWarn - not happening because we can't do that in tests so we fake it. + environment.logger.error = { _ in expection.fulfill() } - let store = TestStore(initialState: .init(queue: []), reducer: KlaviyoReducer()) + let store = TestStore(initialState: .init(queue: [])) { + KlaviyoReducer() + } let profile = Profile(email: "foo") - _ = await store.send(.enqueueProfile(profile)) + _ = await store.send(.enqueueProfile(profile, .test)) await fulfillment(of: [expection]) } - @MainActor func testSetProfileWithEmptyStringIdentifiers() async throws { let initialState = KlaviyoState( apiKey: TEST_API_KEY, @@ -498,19 +478,19 @@ class StateManagementEdgeCaseTests: XCTestCase { pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.appContextInfo())), + deviceData: .init(context: .test)), queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: true) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.enqueueProfile(Profile(email: "", phoneNumber: "", externalId: ""))) { + _ = await store.send(.enqueueProfile(Profile(email: "", phoneNumber: "", externalId: ""), .test)) { $0.email = nil // since we reset state $0.phoneNumber = nil // since we reset state $0.externalId = nil // since we reset state - $0.enqueueProfileOrTokenRequest() + $0.enqueueProfileOrTokenRequest(appConextInfo: .test) $0.pushTokenData = nil } } diff --git a/Tests/KlaviyoSwiftTests/StateManagementTests.swift b/Tests/KlaviyoSwiftTests/StateManagementTests.swift index 74f66f48..f45bbac1 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementTests.swift @@ -5,30 +5,44 @@ // Created by Noah Durell on 12/6/22. // -@testable import KlaviyoCore @testable import KlaviyoSwift -import AnyCodable import Combine import Foundation +@testable import KlaviyoCore +@testable import KlaviyoSDKDependencies import XCTest +#if swift(>=6) +nonisolated(unsafe) var count = 0 +#else +var count = 0 +#endif + +@MainActor class StateManagementTests: XCTestCase { - @MainActor override func setUp() async throws { environment = KlaviyoEnvironment.test() klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() + count = 0 } // MARK: - Initialization - @MainActor func testInitialize() async throws { let initialState = KlaviyoState(queue: [], requestsInFlight: []) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) + + // This will give us coverage to ensure state is saved. + klaviyoSwiftEnvironment.stateChangePublisher = { + AsyncStream { continuation in + continuation.yield(KlaviyoState(queue: [], initalizationState: .initialized)) + continuation.finish() + } + } let apiKey = "fake-key" // Avoids a warning in xcode despite the result being discardable. - await store.send(.initialize(apiKey)) { + await store.send(.initialize(apiKey, .test)) { $0.apiKey = apiKey $0.initalizationState = .initializing } @@ -41,52 +55,45 @@ class StateManagementTests: XCTestCase { } await store.receive(.start) - await store.receive(.flushQueue) - await store.receive(.setPushEnablement(PushEnablement.authorized)) + await store.receive(.setPushEnablement(PushEnablement.authorized, .available, .test)) await store.receive(.setBadgeCount(0)) + await store.receive(.flushQueue(.test)) } - @MainActor func testInitializeSubscribesToAppropriatePublishers() async throws { let lifecycleExpectation = XCTestExpectation(description: "lifecycle is subscribed") let stateChangeIsSubscribed = XCTestExpectation(description: "state change is subscribed") - let lifecycleSubject = PassthroughSubject() - environment.appLifeCycle.lifeCycleEvents = { - lifecycleSubject.handleEvents(receiveSubscription: { _ in + klaviyoSwiftEnvironment.lifeCyclePublisher = { + AsyncStream { continuation in lifecycleExpectation.fulfill() - }) - .eraseToAnyPublisher() + continuation.finish() + } } - let stateChangeSubject = PassthroughSubject() klaviyoSwiftEnvironment.stateChangePublisher = { - stateChangeSubject.handleEvents(receiveSubscription: { _ in + AsyncStream { continuation in stateChangeIsSubscribed.fulfill() - }) - .eraseToAnyPublisher() + continuation.finish() + } } let initialState = KlaviyoState(queue: [], requestsInFlight: []) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) store.exhaustivity = .off let apiKey = "fake-key" - _ = await store.send(.initialize(apiKey)) - - stateChangeSubject.send(completion: .finished) - lifecycleSubject.send(completion: .finished) + _ = await store.send(.initialize(apiKey, .test)) await fulfillment(of: [stateChangeIsSubscribed, lifecycleExpectation]) } // MARK: - Set Email - @MainActor func testSetEmail() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setEmail("test@blob.com")) { + _ = await store.send(.setEmail("test@blob.com", .test)) { $0.email = "test@blob.com" - let request = $0.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement) + let request = $0.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement, background: $0.pushTokenData!.pushBackground, appContextInfo: .test) $0.queue = [request] $0.pushTokenData = nil } @@ -94,14 +101,13 @@ class StateManagementTests: XCTestCase { // MARK: Set Phone Number - @MainActor func testSetPhoneNumber() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setPhoneNumber("+1800555BLOB")) { + _ = await store.send(.setPhoneNumber("+1800555BLOB", .test)) { $0.phoneNumber = "+1800555BLOB" - let request = $0.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement) + let request = $0.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement, background: $0.pushTokenData!.pushBackground, appContextInfo: .test) $0.queue = [request] $0.pushTokenData = nil } @@ -109,14 +115,13 @@ class StateManagementTests: XCTestCase { // MARK: - Set External Id. - @MainActor func testSetExternalId() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.setExternalId("external-blob")) { + _ = await store.send(.setExternalId("external-blob", .test)) { $0.externalId = "external-blob" - let request = $0.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement) + let request = $0.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: $0.pushTokenData!.pushToken, enablement: $0.pushTokenData!.pushEnablement, background: $0.pushTokenData!.pushBackground, appContextInfo: .test) $0.queue = [request] $0.pushTokenData = nil } @@ -124,19 +129,18 @@ class StateManagementTests: XCTestCase { // MARK: - Set Push Token - @MainActor func testSetPushToken() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.pushTokenData = nil initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - let pushTokenRequest = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blobtoken", enablement: .authorized) - _ = await store.send(.setPushToken("blobtoken", .authorized)) { + let pushTokenRequest = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blobtoken", enablement: .authorized, background: .available, appContextInfo: .test) + _ = await store.send(.setPushToken("blobtoken", .authorized, .available, .test)) { $0.queue = [pushTokenRequest] } - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.flushing = true $0.requestsInFlight = $0.queue $0.queue = [] @@ -147,28 +151,27 @@ class StateManagementTests: XCTestCase { _ = await store.receive(.deQueueCompletedResults(pushTokenRequest)) { $0.flushing = false $0.requestsInFlight = [] - $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())) + $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: .test)) } } - @MainActor func testSetPushTokenEnablementChanged() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.pushTokenData?.pushEnablement = .denied initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) let pushTokenRequest = initialState.buildTokenRequest( apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: initialState.pushTokenData!.pushToken, - enablement: .authorized) + enablement: .authorized, background: .available, appContextInfo: .test) - _ = await store.send(.setPushToken(initialState.pushTokenData!.pushToken, .authorized)) { + _ = await store.send(.setPushToken(initialState.pushTokenData!.pushToken, .authorized, .available, .test)) { $0.queue = [pushTokenRequest] } - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.flushing = true $0.requestsInFlight = $0.queue $0.queue = [] @@ -187,20 +190,19 @@ class StateManagementTests: XCTestCase { } } - @MainActor func testSetPushTokenMultipleTimes() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.pushTokenData = nil initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - let pushTokenRequest = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blobtoken", enablement: .authorized) + let pushTokenRequest = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blobtoken", enablement: .authorized, background: .available, appContextInfo: .test) - _ = await store.send(.setPushToken("blobtoken", .authorized)) { + _ = await store.send(.setPushToken("blobtoken", .authorized, .available, .test)) { $0.queue = [pushTokenRequest] } - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.flushing = true $0.requestsInFlight = $0.queue $0.queue = [] @@ -211,44 +213,41 @@ class StateManagementTests: XCTestCase { _ = await store.receive(.deQueueCompletedResults(pushTokenRequest)) { $0.flushing = false $0.requestsInFlight = [] - $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())) + $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: .test)) } - _ = await store.send(.setPushToken("blobtoken", .authorized)) + _ = await store.send(.setPushToken("blobtoken", .authorized, .available, .test)) } // MARK: - Set Push Enablement - @MainActor func testSetPushEnablementPushTokenIsNil() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.pushTokenData = nil - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - await store.send(.setPushEnablement(.authorized)) + await store.send(.setPushEnablement(.authorized, .available, .test)) } - @MainActor func testSetPushEnablementChanged() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.pushTokenData?.pushEnablement = .denied - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) let pushTokenRequest = initialState.buildTokenRequest( apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: initialState.pushTokenData!.pushToken, - enablement: .authorized) + enablement: .authorized, background: .available, appContextInfo: .test) - _ = await store.send(.setPushEnablement(.authorized)) + _ = await store.send(.setPushEnablement(.authorized, .available, .test)) - await store.receive(.setPushToken(initialState.pushTokenData!.pushToken, .authorized)) { + await store.receive(.setPushToken(initialState.pushTokenData!.pushToken, .authorized, .available, .test)) { $0.queue = [pushTokenRequest] } } // MARK: - flush - @MainActor func testFlushUninitializedQueueDoesNotFlush() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -256,11 +255,10 @@ class StateManagementTests: XCTestCase { requestsInFlight: [], initalizationState: .uninitialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - _ = await store.send(.flushQueue) + let store = TestStore.testStore(initialState) + _ = await store.send(.flushQueue(.test)) } - @MainActor func testQueueThatIsFlushingDoesNotFlush() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -268,11 +266,10 @@ class StateManagementTests: XCTestCase { requestsInFlight: [], initalizationState: .initialized, flushing: true) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - _ = await store.send(.flushQueue) + let store = TestStore.testStore(initialState) + _ = await store.send(.flushQueue(.test)) } - @MainActor func testEmptyQueueDoesNotFlush() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, @@ -280,13 +277,11 @@ class StateManagementTests: XCTestCase { requestsInFlight: [], initalizationState: .initialized, flushing: false) - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - _ = await store.send(.flushQueue) + let store = TestStore.testStore(initialState) + _ = await store.send(.flushQueue(.test)) } - @MainActor func testFlushQueueWithMultipleRequests() async throws { - var count = 0 // request uuids need to be unique :) environment.uuid = { count += 1 @@ -302,11 +297,11 @@ class StateManagementTests: XCTestCase { var initialState = INITIALIZED_TEST_STATE() initialState.flushing = false let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized) + let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized, background: .available, appContextInfo: .test) initialState.queue = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.flushing = true $0.requestsInFlight = $0.queue $0.queue = [] @@ -320,39 +315,37 @@ class StateManagementTests: XCTestCase { } await store.receive(.sendRequest) await store.receive(.deQueueCompletedResults(request2)) { - $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())) + $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: .test)) $0.flushing = false $0.requestsInFlight = [] $0.queue = [] } } - @MainActor func testFlushQueueDuringExponentialBackoff() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retryWithBackoff(requestCount: 23, totalRetryCount: 23, currentBackoff: 200) initialState.flushing = false let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized) + let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized, background: .available, appContextInfo: .test) initialState.queue = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.retryInfo = .retryWithBackoff(requestCount: 23, totalRetryCount: 23, currentBackoff: 200 - Int(initialState.flushInterval)) } } - @MainActor func testFlushQueueExponentialBackoffGoesToSize() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.retryInfo = .retryWithBackoff(requestCount: 23, totalRetryCount: 23, currentBackoff: Int(initialState.flushInterval) - 2) initialState.flushing = false let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized) + let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized, background: .available, appContextInfo: .test) initialState.queue = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.retryInfo = .retry(23) $0.flushing = true $0.requestsInFlight = $0.queue @@ -369,21 +362,19 @@ class StateManagementTests: XCTestCase { } } - @MainActor func testSendRequestWhenNotFlushing() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Shouldn't really happen but getting more coverage... _ = await store.send(.sendRequest) } // MARK: - send request - @MainActor func testSendRequestWithNoRequestsInFlight() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Shouldn't really happen but getting more coverage... _ = await store.send(.sendRequest) { $0.flushing = false @@ -392,10 +383,9 @@ class StateManagementTests: XCTestCase { // MARK: - Network Connectivity Changed - @MainActor func testNetworkConnectivityChanges() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) // Shouldn't really happen but getting more coverage... _ = await store.send(.networkConnectivityChanged(.notReachable)) { $0.flushInterval = Double.infinity @@ -407,24 +397,23 @@ class StateManagementTests: XCTestCase { $0.flushing = false $0.flushInterval = StateManagementConstants.wifiFlushInterval } - await store.receive(.flushQueue) + await store.receive(.flushQueue(.test), timeout: TIMEOUT_NANOSECONDS) _ = await store.send(.networkConnectivityChanged(.reachableViaWWAN)) { $0.flushInterval = StateManagementConstants.cellularFlushInterval } - await store.receive(.flushQueue) + await store.receive(.flushQueue(.test)) } // MARK: - Stop - @MainActor func testStopWithRequestsInFlight() async throws { // This test is a little convoluted but essentially want to make when we stop // that we save our state. var initialState = INITIALIZED_TEST_STATE() let request = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) - let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized) + let request2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "blob_token", enablement: .authorized, background: .available, appContextInfo: .test) initialState.requestsInFlight = [request, request2] - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) _ = await store.send(.stop) @@ -437,11 +426,10 @@ class StateManagementTests: XCTestCase { // MARK: - Test pending profile - @MainActor func testFlushWithPendingProfile() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) let profileAttributes: [(Profile.ProfileKey, Any)] = [ (.city, Profile.test.location!.city!), @@ -471,8 +459,8 @@ class StateManagementTests: XCTestCase { var request: KlaviyoRequest? - _ = await store.send(.flushQueue) { - $0.enqueueProfileOrTokenRequest() + _ = await store.send(.flushQueue(.test)) { + $0.enqueueProfileOrTokenRequest(appConextInfo: .test) $0.requestsInFlight = $0.queue $0.queue = [] $0.flushing = true @@ -514,26 +502,24 @@ class StateManagementTests: XCTestCase { // MARK: - Test set profile - @MainActor func testSetProfileWithExistingProperties() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.phoneNumber = "555BLOB" - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.enqueueProfile(Profile(email: "foo"))) { + _ = await store.send(.enqueueProfile(Profile(email: "foo"), .test)) { $0.phoneNumber = nil $0.email = "foo" - $0.enqueueProfileOrTokenRequest() + $0.enqueueProfileOrTokenRequest(appConextInfo: .test) $0.pushTokenData = nil } } - @MainActor func testSetProfileWithAllProfileIdentifiersAndProperties() async throws { let initialState = INITIALIZED_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) - _ = await store.send(.enqueueProfile(Profile.test)) { + _ = await store.send(.enqueueProfile(Profile.test, .test)) { $0.email = Profile.test.email $0.phoneNumber = Profile.test.phoneNumber $0.externalId = Profile.test.externalId @@ -545,23 +531,22 @@ class StateManagementTests: XCTestCase { pushToken: initialState.pushTokenData!.pushToken, enablement: initialState.pushTokenData!.pushEnablement.rawValue, background: initialState.pushTokenData!.pushBackground.rawValue, - profile: Profile.test.toAPIModel(anonymousId: initialState.anonymousId!)) - )) + profile: Profile.test.toAPIModel(anonymousId: initialState.anonymousId!), appContextInfo: .test) + ), uuid: environment.uuid().uuidString) $0.queue = [request] } } // MARK: - Test enqueue event - @MainActor func testEnqueueEvents() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.phoneNumber = "555BLOB" - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) for eventName in Event.EventName.allCases { let event = Event(name: eventName, properties: ["push_token": initialState.pushTokenData!.pushToken]) - await store.send(.enqueueEvent(event)) { + await store.send(.enqueueEvent(event, .test)) { try $0.enqueueRequest( request: KlaviyoRequest( apiKey: XCTUnwrap($0.apiKey), @@ -572,25 +557,24 @@ class StateManagementTests: XCTestCase { phoneNumber: $0.phoneNumber, anonymousId: initialState.anonymousId!, time: event.time, - pushToken: initialState.pushTokenData!.pushToken) - )))) + pushToken: initialState.pushTokenData!.pushToken, appContextInfo: .test) + )), uuid: environment.uuid().uuidString)) } // if the event is opened push we want to flush immidietly, for all other events we flush during regular intervals set in code if eventName == ._openedPush { - await store.receive(.flushQueue, timeout: TIMEOUT_NANOSECONDS) + await store.receive(.flushQueue(.test)) } } } - @MainActor func testEnqueueEventWhenInitilizingSendsEvent() async throws { let initialState = INITILIZING_TEST_STATE() - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore.testStore(initialState) let event = Event(name: .openedAppMetric) - await store.send(.enqueueEvent(event)) { - $0.pendingRequests = [KlaviyoState.PendingRequest.event(event)] + await store.send(.enqueueEvent(event, .test)) { + $0.pendingRequests = [KlaviyoState.PendingRequest.event(event, .test)] } await store.send(.completeInitialization(initialState)) { @@ -598,7 +582,7 @@ class StateManagementTests: XCTestCase { $0.initalizationState = .initialized } - await store.receive(.enqueueEvent(event), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.enqueueEvent(event, .test), timeout: TIMEOUT_NANOSECONDS) { try $0.enqueueRequest( request: KlaviyoRequest( apiKey: XCTUnwrap($0.apiKey), @@ -608,32 +592,35 @@ class StateManagementTests: XCTestCase { properties: event.properties, phoneNumber: $0.phoneNumber, anonymousId: initialState.anonymousId!, - time: event.time) - ))) + time: event.time, + appContextInfo: .test) + )), uuid: environment.uuid().uuidString) ) } - await store.receive(.start, timeout: TIMEOUT_NANOSECONDS) - await store.receive(.flushQueue, timeout: TIMEOUT_NANOSECONDS) - await store.receive(.setPushEnablement(PushEnablement.authorized), timeout: TIMEOUT_NANOSECONDS) + await store.receive(.start) + await store.receive(.setPushEnablement(PushEnablement.authorized, .available, .test)) await store.receive(.setBadgeCount(0)) + await store.receive(.flushQueue(.test), timeout: TIMEOUT_NANOSECONDS) } @MainActor func testFetchForms() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .success(TEST_FULL_FORMS_SUCCESS.data(using: .utf8)!) } + environment.klaviyoAPI.send = { _, _, _ in .success(TEST_FULL_FORMS_SUCCESS.data(using: .utf8)!) } - let request = KlaviyoRequest(apiKey: initialState.apiKey!, endpoint: .fetchForms) + let request = KlaviyoRequest(apiKey: initialState.apiKey!, endpoint: .fetchForms, uuid: environment.uuid().uuidString) _ = await store.send(.fetchForms) { $0.queue = [request] } - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.flushing = true $0.requestsInFlight = $0.queue $0.queue = [] @@ -658,17 +645,19 @@ class StateManagementTests: XCTestCase { func testFetchFormsDecodingError() async throws { var initialState = INITIALIZED_TEST_STATE() initialState.flushing = false - let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + let store = TestStore(initialState: initialState) { + KlaviyoReducer() + } - environment.klaviyoAPI.send = { _, _ in .success(TEST_FULL_FORMS_INVALID_KEY.data(using: .utf8)!) } + environment.klaviyoAPI.send = { _, _, _ in .success(TEST_FULL_FORMS_INVALID_KEY.data(using: .utf8)!) } - let request = KlaviyoRequest(apiKey: initialState.apiKey!, endpoint: .fetchForms) + let request = KlaviyoRequest(apiKey: initialState.apiKey!, endpoint: .fetchForms, uuid: environment.uuid().uuidString) _ = await store.send(.fetchForms) { $0.queue = [request] } - _ = await store.send(.flushQueue) { + _ = await store.send(.flushQueue(.test)) { $0.flushing = true $0.requestsInFlight = $0.queue $0.queue = [] diff --git a/Tests/KlaviyoSwiftTests/TestData.swift b/Tests/KlaviyoSwiftTests/TestData.swift index 7073417b..0144a3f5 100644 --- a/Tests/KlaviyoSwiftTests/TestData.swift +++ b/Tests/KlaviyoSwiftTests/TestData.swift @@ -9,24 +9,25 @@ import Combine import Foundation import KlaviyoCore @_spi(KlaviyoPrivate) @testable import KlaviyoSwift +@_spi(Internals) import KlaviyoSDKDependencies let TEST_API_KEY = "fake-key" -let INITIALIZED_TEST_STATE = { +@MainActor let INITIALIZED_TEST_STATE = { KlaviyoState( apiKey: TEST_API_KEY, anonymousId: environment.uuid().uuidString, pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.appContextInfo())), + deviceData: .init(context: .test)), queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: true) } -let INITILIZING_TEST_STATE = { +@MainActor let INITILIZING_TEST_STATE = { KlaviyoState( apiKey: TEST_API_KEY, anonymousId: environment.uuid().uuidString, @@ -36,7 +37,7 @@ let INITILIZING_TEST_STATE = { flushing: true) } -let INITIALIZED_TEST_STATE_INVALID_PHONE = { +@MainActor let INITIALIZED_TEST_STATE_INVALID_PHONE = { KlaviyoState( apiKey: TEST_API_KEY, anonymousId: environment.uuid().uuidString, @@ -44,14 +45,14 @@ let INITIALIZED_TEST_STATE_INVALID_PHONE = { pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.appContextInfo())), + deviceData: .init(context: .test)), queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: true) } -let INITIALIZED_TEST_STATE_INVALID_EMAIL = { +@MainActor let INITIALIZED_TEST_STATE_INVALID_EMAIL = { KlaviyoState( apiKey: TEST_API_KEY, email: "invalid_email", @@ -59,13 +60,14 @@ let INITIALIZED_TEST_STATE_INVALID_EMAIL = { pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.appContextInfo())), + deviceData: .init(context: .test)), queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: true) } +@MainActor extension Profile { static let SAMPLE_PROPERTIES = [ "blob": "blob", @@ -99,6 +101,7 @@ extension Profile.Location { zip: "0BLOB") } +@MainActor extension Event { static let SAMPLE_PROPERTIES = [ "blob": "blob", @@ -115,7 +118,7 @@ extension Event { "Device Manufacturer": "Orange", "Device Model": "jPhone 1,1" ] as [String: Any] - static let test = Self(name: .customEvent("blob"), properties: nil, time: KlaviyoEnvironment.test().date()) + static let test = Self(name: .customEvent("blob"), properties: nil, time: KlaviyoEnvironment.test().date(), uniqueId: KlaviyoEnvironment.test().uuid().uuidString) } extension Event.Metric { @@ -132,14 +135,14 @@ extension KlaviyoState { pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: DeviceMetadata(context: environment.appContextInfo())), + deviceData: DeviceMetadata(context: AppContextInfo.test)), queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: true) } -let SAMPLE_DATA: NSMutableArray = [ +@MainActor let SAMPLE_DATA: NSMutableArray = [ [ "properties": [ "foo": "bar" @@ -231,19 +234,23 @@ let TEST_FULL_FORMS_INVALID_KEY = """ """ extension KlaviyoSwiftEnvironment { - static let testStore = Store(initialState: KlaviyoState(queue: []), reducer: KlaviyoReducer()) + static let testStore = Store.production static let test = { KlaviyoSwiftEnvironment(send: { action in testStore.send(action) }, state: { - KlaviyoSwiftEnvironment.testStore.state.value + testStore.currentState }, statePublisher: { Just(INITIALIZED_TEST_STATE()).eraseToAnyPublisher() }, stateChangePublisher: { - Empty().eraseToAnyPublisher() - }, setBadgeCount: { _ in - nil + AsyncStream.finished + }, lifeCyclePublisher: { + AsyncStream.finished + }, + getBackgroundSetting: { + .available + }, networkSession: { NetworkSession.test() }, setBadgeCount: { _ in }) } } diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift new file mode 100644 index 00000000..7a84a201 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift @@ -0,0 +1,38 @@ +import Dispatch +@testable import KlaviyoSwift +@testable import KlaviyoSDKDependencies + +func mainActorNow(execute block: @MainActor @Sendable () -> R) -> R { + if DispatchQueue.getSpecific(key: key) == value { + return MainActor._assumeIsolated { + block() + } + } else { + return DispatchQueue.main.sync { + MainActor._assumeIsolated { + block() + } + } + } +} + +func mainActorASAP(execute block: @escaping @MainActor @Sendable () -> Void) { + if DispatchQueue.getSpecific(key: key) == value { + MainActor._assumeIsolated { + block() + } + } else { + DispatchQueue.main.async { + MainActor._assumeIsolated { + block() + } + } + } +} + +private let key: DispatchSpecificKey = { + let key = DispatchSpecificKey() + DispatchQueue.main.setSpecific(key: key, value: value) + return key +}() +private let value: UInt8 = 0 diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift new file mode 100644 index 00000000..d2cd999c --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift @@ -0,0 +1,92 @@ +import KlaviyoSDKDependencies + +#if compiler(>=6) + public typealias _SendableAnyKeyPath = any AnyKeyPath & Sendable + public typealias _SendablePartialKeyPath = any PartialKeyPath & Sendable + public typealias _SendableKeyPath = any KeyPath & Sendable + public typealias _SendableWritableKeyPath = any WritableKeyPath + & Sendable + public typealias _SendableReferenceWritableKeyPath = any ReferenceWritableKeyPath< + Root, Value + > + & Sendable + public typealias _SendablePartialCaseKeyPath = any PartialCaseKeyPath & Sendable + public typealias _SendableCaseKeyPath = any CaseKeyPath & Sendable +#else + public typealias _SendableAnyKeyPath = AnyKeyPath + public typealias _SendablePartialKeyPath = PartialKeyPath + public typealias _SendableKeyPath = KeyPath + public typealias _SendableWritableKeyPath = WritableKeyPath + public typealias _SendableReferenceWritableKeyPath = ReferenceWritableKeyPath< + Root, Value + > + public typealias _SendablePartialCaseKeyPath = PartialCaseKeyPath + public typealias _SendableCaseKeyPath = CaseKeyPath +#endif + +// NB: Dynamic member lookup does not currently support sendable key paths and even breaks +// autocomplete. +// +// * https://github.com/swiftlang/swift/issues/77035 +// * https://github.com/swiftlang/swift/issues/77105 +extension _AppendKeyPath { + @_transparent + func unsafeSendable() -> _SendableAnyKeyPath + where Self == AnyKeyPath { + #if compiler(>=6) + unsafeBitCast(self, to: _SendableAnyKeyPath.self) + #else + self + #endif + } + + @_transparent + func unsafeSendable() -> _SendablePartialKeyPath + where Self == PartialKeyPath { + #if compiler(>=6) + unsafeBitCast(self, to: _SendablePartialKeyPath.self) + #else + self + #endif + } + + @_transparent + func unsafeSendable() -> _SendableKeyPath + where Self == KeyPath { + #if compiler(>=6) + unsafeBitCast(self, to: _SendableKeyPath.self) + #else + self + #endif + } + + @_transparent + func unsafeSendable() -> _SendableWritableKeyPath + where Self == WritableKeyPath { + #if compiler(>=6) + unsafeBitCast(self, to: _SendableWritableKeyPath.self) + #else + self + #endif + } + + @_transparent + func unsafeSendable() -> _SendableReferenceWritableKeyPath + where Self == ReferenceWritableKeyPath { + #if compiler(>=6) + unsafeBitCast(self, to: _SendableReferenceWritableKeyPath.self) + #else + self + #endif + } + + @_transparent + func unsafeSendable() -> _SendableCaseKeyPath + where Self == CaseKeyPath { + #if compiler(>=6) + unsafeBitCast(self, to: _SendableCaseKeyPath.self) + #else + self + #endif + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift new file mode 100644 index 00000000..7ff69fe4 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift @@ -0,0 +1,21 @@ +// MARK: Equatable + +func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool? { + (lhs as? any Equatable)?.isEqual(other: rhs) +} + +extension Equatable { + fileprivate func isEqual(other: Any) -> Bool { + self == other as? Self + } +} + +// MARK: Identifiable + +func _identifiableID(_ value: Any) -> AnyHashable? { + func open(_ value: some Identifiable) -> AnyHashable { + value.id + } + guard let value = value as? any Identifiable else { return nil } + return open(value) +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift new file mode 100644 index 00000000..c5db9108 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift @@ -0,0 +1,20 @@ +#if canImport(Combine) + import Combine +#endif + +protocol Reference: AnyObject, CustomStringConvertible, Sendable { + associatedtype Value: Sendable + var value: Value { get set } + + func access() + func withMutation(_ mutation: () throws -> T) rethrows -> T + #if canImport(Combine) + var publisher: AnyPublisher { get } + #endif +} + +extension Reference { + var valueType: Any.Type { + Value.self + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift new file mode 100644 index 00000000..dd9490a3 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift @@ -0,0 +1,115 @@ +@testable import KlaviyoSDKDependencies +@testable import KlaviyoSwift + +@_spi(Internals) +public func withSharedChangeTracking( + _ apply: (SharedChangeTracker) throws -> T +) rethrows -> T { + let changeTracker = SharedChangeTracker() + return try changeTracker.track { + try apply(changeTracker) + } +} + +@_spi(Internals) +public func withSharedChangeTracking( + _ apply: (SharedChangeTracker) async throws -> T +) async rethrows -> T { + let changeTracker = SharedChangeTracker() + return try await changeTracker.track { + try await apply(changeTracker) + } +} + +protocol Change { + associatedtype Value + var reference: any Reference { get } + var snapshot: Value { get set } +} + +extension Change { + func assertUnchanged() { + if let difference = diff(snapshot, self.reference.value, format: .proportional) { + reportIssue( + """ + Tracked changes to '\(self.reference.description)' but failed to assert: … + + \(difference.indent(by: 2)) + + (Before: −, After: +) + + Call 'Shared<\(Value.self)>.assert' to exhaustively test these changes, or call \ + 'skipChanges' to ignore them. + """ + ) + } + } +} + +struct AnyChange: Change, Sendable { + let reference: any Reference + var snapshot: Value + + init(_ reference: some Reference) { + self.reference = reference + self.snapshot = reference.value + } +} + +@_spi(Internals) +public final class SharedChangeTracker: Sendable { + let changes: LockIsolated<[ObjectIdentifier: any Sendable]> = LockIsolated([:]) + var hasChanges: Bool { !self.changes.isEmpty } + @_spi(Internals) public init() {} + func resetChanges() { self.changes.withValue { $0.removeAll() } } + func assertUnchanged() { + for change in self.changes.values { + if let change = change as? any Change { + change.assertUnchanged() + } + } + self.resetChanges() + } + func track(_ reference: some Reference) { + if !self.changes.keys.contains(ObjectIdentifier(reference)) { + self.changes.withValue { $0[ObjectIdentifier(reference)] = AnyChange(reference) } + } + } + subscript(_ reference: some Reference) -> AnyChange? { + _read { yield self.changes[ObjectIdentifier(reference)] as? AnyChange } + _modify { + var change = self.changes[ObjectIdentifier(reference)] as? AnyChange + yield &change + self.changes.withValue { [change] in $0[ObjectIdentifier(reference)] = change } + } + } + func track(_ operation: () throws -> R) rethrows -> R { + // ND: revisit + // $0.sharedChangeTrackers.insert(self) + try operation() + } + func track(_ operation: () async throws -> R) async rethrows -> R { + // ND: revist + // $0.sharedChangeTrackers.insert(self) + try await operation() + } + @_spi(Internals) + public func assert(_ operation: () throws -> R) rethrows -> R { + + // ND: revisit + // $0.sharedChangeTracker = self + try operation() + + } +} + +extension SharedChangeTracker: Hashable { + @_spi(Internals) + public static func == (lhs: SharedChangeTracker, rhs: SharedChangeTracker) -> Bool { + lhs === rhs + } + @_spi(Internals) + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift new file mode 100644 index 00000000..d30a4f36 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift @@ -0,0 +1,334 @@ +@testable import KlaviyoSDKDependencies + +/// A value that represents either a success or a failure. This type differs from Swift's `Result` +/// type in that it uses only one generic for the success case, leaving the failure case as an +/// untyped `Error`. +/// +/// This type is needed because Swift's concurrency tools can only express untyped errors, such as +/// `async` functions and `AsyncSequence`, and so their output can realistically only be bridged to +/// `Result<_, any Error>`. However, `Result<_, any Error>` is never `Equatable` since `Error` is not +/// `Equatable`, and equatability is very important for testing in the Composable Architecture. By +/// defining our own type we get the ability to recover equatability in most situations. +/// +/// If someday Swift gets typed `throws`, then we can eliminate this type and rely solely on +/// `Result`. +/// +/// You typically use this type as the payload of an action which receives a response from an +/// effect: +/// +/// ```swift +/// enum Action: Equatable { +/// case factButtonTapped +/// case factResponse(TaskResult) +/// } +/// ``` +/// +/// Then you can model your dependency as using simple `async` and `throws` functionality: +/// +/// ```swift +/// struct NumberFactClient { +/// var fetch: (Int) async throws -> String +/// } +/// ``` +/// +/// And finally you can use ``Effect/run(priority:operation:catch:fileID:filePath:line:column:)`` to construct an +/// effect in the reducer that invokes the `numberFact` endpoint and wraps its response in a +/// ``TaskResult`` by using its catching initializer, ``TaskResult/init(catching:)``: +/// +/// ```swift +/// case .factButtonTapped: +/// return .run { send in +/// await send( +/// .factResponse( +/// TaskResult { try await self.numberFact.fetch(state.number) } +/// ) +/// ) +/// } +/// +/// case let .factResponse(.success(fact)): +/// // do something with fact +/// +/// case .factResponse(.failure): +/// // handle error +/// +/// // ... +/// } +/// ``` +/// +/// ## Equality +/// +/// The biggest downside to using an untyped `Error` in a result type is that the result will not +/// be equatable even if the success type is. This negatively affects your ability to test features +/// that use ``TaskResult`` in their actions with the ``TestStore``. +/// +/// ``TaskResult`` does extra work to try to maintain equatability when possible. If the underlying +/// type masked by the `Error` is `Equatable`, then it will use that `Equatable` conformance +/// on two failures. Luckily, most errors thrown by Apple's frameworks are already equatable, and +/// because errors are typically simple value types, it is usually possible to have the compiler +/// synthesize a conformance for you. +/// +/// If you are testing the unhappy path of a feature that feeds a ``TaskResult`` back into the +/// system, be sure to conform the error to equatable, or the test will fail: +/// +/// ```swift +/// // Set up a failing dependency +/// struct RefreshFailure: Error {} +/// store.dependencies.apiClient.fetchFeed = { throw RefreshFailure() } +/// +/// // Simulate pull-to-refresh +/// store.send(.refresh) { $0.isLoading = true } +/// +/// // Assert against failure +/// await store.receive(.refreshResponse(.failure(RefreshFailure())) { // 🛑 +/// $0.errorLabelText = "An error occurred." +/// $0.isLoading = false +/// } +/// // 🛑 'RefreshFailure' is not equatable +/// ``` +/// +/// To get a passing test, explicitly conform your custom error to the `Equatable` protocol: +/// +/// ```swift +/// // Set up a failing dependency +/// struct RefreshFailure: Error, Equatable {} // 👈 +/// store.dependencies.apiClient.fetchFeed = { throw RefreshFailure() } +/// +/// // Simulate pull-to-refresh +/// store.send(.refresh) { $0.isLoading = true } +/// +/// // Assert against failure +/// await store.receive(.refreshResponse(.failure(RefreshFailure())) { // ✅ +/// $0.errorLabelText = "An error occurred." +/// $0.isLoading = false +/// } +/// ``` +@available( + iOS, + deprecated: 9999, + message: + "Use 'Result', instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Moving-off-of-TaskResult" +) +@available( + macOS, + deprecated: 9999, + message: + "Use 'Result', instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Moving-off-of-TaskResult" +) +@available( + tvOS, + deprecated: 9999, + message: + "Use 'Result', instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Moving-off-of-TaskResult" +) +@available( + watchOS, + deprecated: 9999, + message: + "Use 'Result', instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Moving-off-of-TaskResult" +) +public enum TaskResult: Sendable { + /// A success, storing a `Success` value. + case success(Success) + + /// A failure, storing an error. + case failure(any Error) + + /// Creates a new task result by evaluating an async throwing closure, capturing the returned + /// value as a success, or any thrown error as a failure. + /// + /// This initializer is most often used in an async effect being returned from a reducer. See the + /// documentation for ``TaskResult`` for a concrete example. + /// + /// - Parameter body: An async, throwing closure. + @_transparent + public init(catching body: @Sendable () async throws -> Success) async { + do { + self = .success(try await body()) + } catch { + self = .failure(error) + } + } + + /// Transforms a `Result` into a `TaskResult`, erasing its `Failure` to `Error`. + /// + /// - Parameter result: A result. + @inlinable + public init(_ result: Result) { + switch result { + case let .success(value): + self = .success(value) + case let .failure(error): + self = .failure(error) + } + } + + /// Returns the success value as a throwing property. + @inlinable + public var value: Success { + get throws { + switch self { + case let .success(value): + return value + case let .failure(error): + throw error + } + } + } + + /// Returns a new task result, mapping any success value using the given transformation. + /// + /// Like `map` on `Result`, `Optional`, and many other types. + /// + /// - Parameter transform: A closure that takes the success value of this instance. + /// - Returns: A `TaskResult` instance with the result of evaluating `transform` as the new + /// success value if this instance represents a success. + @inlinable + public func map(_ transform: (Success) -> NewSuccess) -> TaskResult { + switch self { + case let .success(value): + return .success(transform(value)) + case let .failure(error): + return .failure(error) + } + } + + /// Returns a new task result, mapping any success value using the given transformation and + /// unwrapping the produced result. + /// + /// Like `flatMap` on `Result`, `Optional`, and many other types. + /// + /// - Parameter transform: A closure that takes the success value of the instance. + /// - Returns: A `TaskResult` instance, either from the closure or the previous `.failure`. + @inlinable + public func flatMap( + _ transform: (Success) -> TaskResult + ) -> TaskResult { + switch self { + case let .success(value): + return transform(value) + case let .failure(error): + return .failure(error) + } + } +} + +extension TaskResult: CasePathable { + public static var allCasePaths: AllCasePaths { + AllCasePaths() + } + + public struct AllCasePaths { + public var success: AnyCasePath { + AnyCasePath( + embed: { .success($0) }, + extract: { + guard case let .success(value) = $0 else { return nil } + return value + } + ) + } + + public var failure: AnyCasePath { + AnyCasePath( + embed: { .failure($0) }, + extract: { + guard case let .failure(value) = $0 else { return nil } + return value + } + ) + } + } +} + +extension Result where Success: Sendable, Failure == any Error { + /// Transforms a `TaskResult` into a `Result`. + /// + /// - Parameter result: A task result. + @inlinable + public init(_ result: TaskResult) { + switch result { + case let .success(value): + self = .success(value) + case let .failure(error): + self = .failure(error) + } + } +} + +enum TaskResultDebugging { + @TaskLocal static var emitRuntimeWarnings = true +} + +extension TaskResult: Equatable where Success: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.success(lhs), .success(rhs)): + return lhs == rhs + case let (.failure(lhs), .failure(rhs)): + return _isEqual(lhs, rhs) + ?? { + #if DEBUG + let lhsType = type(of: lhs) + if TaskResultDebugging.emitRuntimeWarnings, lhsType == type(of: rhs) { + let lhsTypeName = typeName(lhsType) + reportIssue( + """ + "\(lhsTypeName)" is not equatable. … + + To test two values of this type, it must conform to the "Equatable" protocol. For \ + example: + + extension \(lhsTypeName): Equatable {} + + See the documentation of "TaskResult" for more information. + """ + ) + } + #endif + return false + }() + default: + return false + } + } +} + +extension TaskResult: Hashable where Success: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .success(value): + hasher.combine(value) + hasher.combine(0) + case let .failure(error): + if let error = (error as Any) as? AnyHashable { + hasher.combine(error) + hasher.combine(1) + } else { + #if DEBUG + if TaskResultDebugging.emitRuntimeWarnings { + let errorType = typeName(type(of: error)) + reportIssue( + """ + "\(errorType)" is not hashable. … + + To hash a value of this type, it must conform to the "Hashable" protocol. For example: + + extension \(errorType): Hashable {} + + See the documentation of "TaskResult" for more information. + """ + ) + } + #endif + } + } + } +} + +extension TaskResult { + // NB: For those that try to interface with `TaskResult` using `Result`'s old API. + @available(*, unavailable, renamed: "value") + public func get() throws -> Success { + try self.value + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift index 27b15010..5be58838 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift @@ -1,47 +1,14 @@ -/** - MIT License - - Copyright (c) 2020 Point-Free, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - - */ -// -// TestStore.swift -// Taken from https://github.com/pointfreeco/swift-composable-architecture (modified to fit our use cases). -// -// Created by Noah Durell on 12/6/22. -// -@testable import KlaviyoSwift import Combine -import CustomDump import Foundation -import CasePaths -import XCTestDynamicOverlay -import Dispatch +@_spi(Internals) @testable import KlaviyoSwift +@_spi(Internals) @testable import KlaviyoSDKDependencies /// A testable runtime for a reducer. /// /// This object aids in writing expressive and exhaustive tests for features built in the -/// Composable Architecture. It allows you to send a sequence of actions to the store, and each -/// step of the way you must assert exactly how state changed, and how effect emissions were fed -/// back into the system. +/// Composable Architecture. It allows you to send a sequence of actions to the store, and each step +/// of the way you must assert exactly how state changed, and how effect emissions were fed back +/// into the system. /// /// See the dedicated article for detailed information on testing. /// @@ -51,33 +18,33 @@ import Dispatch /// sending use actions and receiving actions from effects. There are multiple ways the test store /// forces you to do this: /// -/// * After each action is sent you must describe precisely how the state changed from before -/// the action was sent to after it was sent. +/// * After each action is sent you must describe precisely how the state changed from before the +/// action was sent to after it was sent. /// -/// If even the smallest piece of data differs the test will fail. This guarantees that you -/// are proving you know precisely how the state of the system changes. +/// If even the smallest piece of data differs the test will fail. This guarantees that you are +/// proving you know precisely how the state of the system changes. /// -/// * Sending an action can sometimes cause an effect to be executed, and if that effect sends -/// an action back into the system, you **must** explicitly assert that you expect to receive -/// that action from the effect, _and_ you must assert how state changed as a result. +/// * Sending an action can sometimes cause an effect to be executed, and if that effect sends an +/// action back into the system, you **must** explicitly assert that you expect to receive that +/// action from the effect, _and_ you must assert how state changed as a result. /// -/// If you try to send another action before you have handled all effect actions, the -/// test will fail. This guarantees that you do not accidentally forget about an effect -/// action, and that the sequence of steps you are describing will mimic how the application -/// behaves in reality. +/// If you try to send another action before you have handled all effect actions, the test will +/// fail. This guarantees that you do not accidentally forget about an effect action, and that +/// the sequence of steps you are describing will mimic how the application behaves in reality. /// /// * All effects must complete by the time the test case has finished running, and all effect /// actions must be asserted on. /// /// If at the end of the assertion there is still an in-flight effect running or an unreceived /// action, the assertion will fail. This helps exhaustively prove that you know what effects -/// are in flight and forces you to prove that effects will not cause any future changes to -/// your state. +/// are in flight and forces you to prove that effects will not cause any future changes to your +/// state. /// /// For example, given a simple counter reducer: /// /// ```swift -/// struct Counter: ReducerProtocol { +/// @Reducer +/// struct Counter { /// struct State: Equatable { /// var count = 0 /// } @@ -87,17 +54,17 @@ import Dispatch /// case incrementButtonTapped /// } /// -/// func reduce( -/// into state: inout State, action: Action -/// ) -> EffectTask { -/// switch action { -/// case .decrementButtonTapped: -/// state.count -= 1 -/// return .none -/// -/// case .incrementButtonTapped: -/// state.count += 1 -/// return .none +/// var body: some Reducer { +/// Reduce { state, action in +/// switch action { +/// case .decrementButtonTapped: +/// state.count -= 1 +/// return .none +/// +/// case .incrementButtonTapped: +/// state.count += 1 +/// return .none +/// } /// } /// } /// } @@ -107,13 +74,15 @@ import Dispatch /// /// ```swift /// @MainActor -/// class CounterTests: XCTestCase { -/// func testCounter() async { +/// struct CounterTests { +/// @Test +/// func basics() async { /// let store = TestStore( /// // Given: a counter state of 0 /// initialState: Counter.State(count: 0), -/// reducer: Counter() -/// ) +/// ) { +/// Counter() +/// } /// /// // When: the increment button is tapped /// await store.send(.incrementButtonTapped) { @@ -125,11 +94,11 @@ import Dispatch /// ``` /// /// Note that in the trailing closure of `.send(.incrementButtonTapped)` we are given a single -/// mutable value of the state before the action was sent, and it is our job to mutate the value -/// to match the state after the action was sent. In this case the `count` field changes to `1`. +/// mutable value of the state before the action was sent, and it is our job to mutate the value to +/// match the state after the action was sent. In this case the `count` field changes to `1`. /// -/// If the change made in the closure does not reflect reality, you will get a test failure with -/// a nicely formatted failure message letting you know exactly what went wrong: +/// If the change made in the closure does not reflect reality, you will get a test failure with a +/// nicely formatted failure message letting you know exactly what went wrong: /// /// ```swift /// await store.send(.incrementButtonTapped) { @@ -137,59 +106,57 @@ import Dispatch /// } /// ``` /// -/// ``` -/// 🛑 A state change does not match expectation: … -/// -/// TestStoreFailureTests.State( -/// − count: 42 -/// + count: 1 -/// ) -/// -/// (Expected: −, Actual: +) -/// ``` +/// > ❌ Failure: A state change does not match expectation: … +/// > +/// > ```diff +/// > TestStoreFailureTests.State( +/// > - count: 42 +/// > + count: 1 +/// > ) +/// > ``` +/// > +/// > (Expected: −, Actual: +) /// -/// For a more complex example, consider the following bare-bones search feature that uses a -/// clock and cancel token to debounce requests: +/// For a more complex example, consider the following bare-bones search feature that uses a clock +/// and cancel token to debounce requests: /// /// ```swift -/// struct Search: ReducerProtocol { +/// @Reducer +/// struct Search { /// struct State: Equatable { /// var query = "" /// var results: [String] = [] /// } /// -/// enum Action: Equatable { +/// enum Action { /// case queryChanged(String) -/// case searchResponse(TaskResult<[String]>) +/// case searchResponse(Result<[String], any Error>) /// } /// /// @Dependency(\.apiClient) var apiClient /// @Dependency(\.continuousClock) var clock -/// private enum SearchID {} -/// -/// func reduce( -/// into state: inout State, action: Action -/// ) -> EffectTask { -/// switch action { -/// case let .queryChanged(query): -/// state.query = query -/// return .run { send in -/// try await self.clock.sleep(for: 0.5) -/// -/// guard let results = try? await self.apiClient.search(query) -/// else { return } -/// -/// await send(.response(results)) +/// private enum CancelID { case search } +/// +/// var body: some Reducer { +/// Reduce { state, action in +/// switch action { +/// case let .queryChanged(query): +/// state.query = query +/// return .run { send in +/// try await self.clock.sleep(for: 0.5) +/// +/// await send(.searchResponse(Result { try await self.apiClient.search(query) })) +/// } +/// .cancellable(id: CancelID.search, cancelInFlight: true) +/// +/// case let .searchResponse(.success(results)): +/// state.results = results +/// return .none +/// +/// case .searchResponse(.failure): +/// // Do error handling here. +/// return .none /// } -/// .cancellable(id: SearchID.self) -/// -/// case let .searchResponse(.success(results)): -/// state.results = results -/// return .none -/// -/// case .searchResponse(.failure): -/// // Do error handling here. -/// return .none /// } /// } /// } @@ -199,19 +166,20 @@ import Dispatch /// values that are fully controlled and deterministic: /// /// ```swift -/// let store = TestStore( -/// initialState: Search.State(), -/// reducer: Search() -/// ) -/// -/// // Simulate a search response with one item -/// store.dependencies.apiClient.search = { _ in -/// ["Composable Architecture"] -/// } -/// /// // Create a test clock to control the timing of effects /// let clock = TestClock() -/// store.dependencies.continuousClock = clock +/// +/// let store = TestStore(initialState: Search.State()) { +/// Search() +/// } withDependencies: { +/// // Override the clock dependency with the test clock +/// $0.continuousClock = clock +/// +/// // Simulate a search response with one item +/// $0.apiClient.search = { _ in +/// ["Composable Architecture"] +/// } +/// ) /// /// // Change the query /// await store.send(.searchFieldChanged("c") { @@ -223,7 +191,7 @@ import Dispatch /// await clock.advance(by: 0.5) /// /// // Assert that the expected response is received -/// await store.receive(.searchResponse(.success(["Composable Architecture"]))) { +/// await store.receive(\.searchResponse.success) { /// $0.results = ["Composable Architecture"] /// } /// ``` @@ -234,13 +202,13 @@ import Dispatch /// If we did not assert that the `searchResponse` action was received, we would get the following /// test failure: /// -/// ``` -/// 🛑 The store received 1 unexpected action after this one: … -/// -/// Unhandled actions: [ -/// [0]: Search.Action.searchResponse -/// ] -/// ``` +/// > ❌ Failure: The store received 1 unexpected action after this one: … +/// > +/// > ``` +/// > Unhandled actions: [ +/// > [0]: Search.Action.searchResponse +/// > ] +/// > ``` /// /// This helpfully lets us know that we have no asserted on everything that happened in the feature, /// which could be hiding a bug from us. @@ -248,16 +216,16 @@ import Dispatch /// Or if we had sent another action before handling the effect's action we would have also gotten /// a test failure: /// -/// ``` -/// 🛑 Must handle 1 received action before sending an action: … -/// -/// Unhandled actions: [ -/// [0]: Search.Action.searchResponse -/// ] -/// ``` +/// > ❌ Failure: Must handle 1 received action before sending an action: … +/// > +/// > ``` +/// > Unhandled actions: [ +/// > [0]: Search.Action.searchResponse +/// > ] +/// > ``` /// -/// All of these types of failures help you prove that you know exactly how your feature evolves -/// as actions are sent into the system. If the library did not produce a test failure in these +/// All of these types of failures help you prove that you know exactly how your feature evolves as +/// actions are sent into the system. If the library did not produce a test failure in these /// situations it could be hiding subtle bugs in your code. For example, when the user clears the /// search query you probably expect that the results are cleared and no search request is executed /// since there is no query. This can be done like so: @@ -273,14 +241,14 @@ import Dispatch /// ``` /// /// But, if in the future a bug is introduced causing a search request to be executed even when the -/// query is empty, you will get a test failure because a new effect is being created that is -/// not being asserted on. This is the power of exhaustive testing. +/// query is empty, you will get a test failure because a new effect is being created that is not +/// being asserted on. This is the power of exhaustive testing. /// /// ## Non-exhaustive testing /// -/// While exhaustive testing can be powerful, it can also be a nuisance, especially when testing -/// how many features integrate together. This is why sometimes you may want to selectively test -/// in a non-exhaustive style. +/// While exhaustive testing can be powerful, it can also be a nuisance, especially when testing how +/// many features integrate together. This is why sometimes you may want to selectively test in a +/// non-exhaustive style. /// /// > Tip: The concept of "non-exhaustive test store" was first introduced by /// [Krzysztof Zabłocki][merowing.info] in a [blog post][exhaustive-testing-in-tca] and @@ -292,15 +260,16 @@ import Dispatch /// complete before the test is finished. To turn off exhaustivity you can set ``exhaustivity`` /// to ``Exhaustivity/off``. When that is done the ``TestStore``'s behavior changes: /// -/// * The trailing closures of ``send(_:assert:file:line:)-1ax61`` and -/// ``receive(_:timeout:assert:file:line:)-1rwdd`` no longer need to assert on all state changes. -/// They can assert on any subset of changes, and only if they make an incorrect mutation will a -/// test failure be reported. -/// * The ``send(_:assert:file:line:)-1ax61`` and ``receive(_:timeout:assert:file:line:)-1rwdd`` -/// methods are allowed to be called even when actions have been received from effects that have -/// not been asserted on yet. Any pending actions will be cleared. -/// * Tests are allowed to finish with unasserted, received actions and in-flight effects. No test -/// failures will be reported. +/// * The trailing closures of ``send(_:assert:fileID:file:line:column:)-8f2pl`` and +/// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk`` no longer need to assert on all +/// state changes. They can assert on any subset of changes, and only if they make an incorrect +/// mutation will a test failure be reported. +/// * The ``send(_:assert:fileID:file:line:column:)-8f2pl`` and +/// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk`` methods are allowed to be +/// called even when actions have been received from effects that have not been asserted on yet. +/// Any pending actions will be cleared. +/// * Tests are allowed to finish with unasserted, received actions and in-flight effects. No test +/// failures will be reported. /// /// Non-exhaustive stores can be configured to report skipped assertions by configuring /// ``Exhaustivity/off(showSkippedAssertions:)``. When set to `true` the test store will have the @@ -331,13 +300,13 @@ import Dispatch /// tab switched to activity: /// /// ```swift -/// let store = TestStore( -/// initialState: App.State(), -/// reducer: App() -/// ) +/// let store = TestStore(initialState: App.State()) { +/// App() +/// } /// /// // 1️⃣ Emulate user tapping on submit button. -/// await store.send(.login(.submitButtonTapped)) { +/// // (You can use case key path syntax to send actions to deeply nested features.) +/// await store.send(\.login.submitButtonTapped) { /// // 2️⃣ Assert how all state changes in the login feature /// $0.login?.isLoading = true /// … @@ -345,7 +314,7 @@ import Dispatch /// /// // 3️⃣ Login feature performs API request to login, and /// // sends response back into system. -/// await store.receive(.login(.loginResponse(.success))) { +/// await store.receive(\.login.loginResponse.success) { /// // 4️⃣ Assert how all state changes in the login feature /// $0.login?.isLoading = false /// … @@ -353,7 +322,7 @@ import Dispatch /// /// // 5️⃣ Login feature sends a delegate action to let parent /// // feature know it has successfully logged in. -/// await store.receive(.login(.delegate(.didLogin))) { +/// await store.receive(\.login.delegate.didLogin) { /// // 6️⃣ Assert how all of app state changes due to that action. /// $0.authenticatedTab = .loggedIn( /// Profile.State(...) @@ -366,13 +335,13 @@ import Dispatch /// /// Doing this with exhaustive testing is verbose, and there are a few problems with this: /// -/// * We need to be intimately knowledgeable in how the login feature works so that we can assert -/// on how its state changes and how its effects feed data back into the system. -/// * If the login feature were to change its logic we may get test failures here even though the -/// logic we are actually trying to test doesn't really care about those changes. -/// * This test is very long, and so if there are other similar but slightly different flows we -/// want to test we will be tempted to copy-and-paste the whole thing, leading to lots of -/// duplicated, fragile tests. +/// * We need to be intimately knowledgeable in how the login feature works so that we can assert +/// on how its state changes and how its effects feed data back into the system. +/// * If the login feature were to change its logic we may get test failures here even though the +/// logic we are actually trying to test doesn't really care about those changes. +/// * This test is very long, and so if there are other similar but slightly different flows we +/// want to test we will be tempted to copy-and-paste the whole thing, leading to lots of +/// duplicated, fragile tests. /// /// Non-exhaustive testing allows us to test the high-level flow that we are concerned with, that of /// login causing the selected tab to switch to activity, without having to worry about what is @@ -380,14 +349,13 @@ import Dispatch /// the test store, and then just assert on what we are interested in: /// /// ```swift -/// let store = TestStore( -/// initialState: App.State(), -/// reducer: App() -/// ) -/// store.exhaustivity = .off // ⬅️ +/// let store = TestStore(App.State()) { +/// App() +/// } +/// store.exhaustivity = .off // ⬅️ /// -/// await store.send(.login(.submitButtonTapped)) -/// await store.receive(.login(.delegate(.didLogin))) { +/// await store.send(\.login.submitButtonTapped) +/// await store.receive(\.login.delegate.didLogin) { /// $0.selectedTab = .activity /// } /// ``` @@ -398,19 +366,18 @@ import Dispatch /// activity. Now the login feature is free to make any change it wants to make without affecting /// this integration test. /// -/// Using ``Exhaustivity/off`` for ``TestStore/exhaustivity`` causes all un-asserted changes to -/// pass without any notification. If you would like to see what test failures are being suppressed +/// Using ``Exhaustivity/off`` for ``TestStore/exhaustivity`` causes all un-asserted changes to pass +/// without any notification. If you would like to see what test failures are being suppressed /// without actually causing a failure, you can use ``Exhaustivity/off(showSkippedAssertions:)``: /// /// ```swift -/// let store = TestStore( -/// initialState: App.State(), -/// reducer: App() -/// ) -/// store.exhaustivity = .off(showSkippedAssertions: true) // ⬅️ +/// let store = TestStore(initialState: App.State()) { +/// App() +/// } +/// store.exhaustivity = .off(showSkippedAssertions: true) // ⬅️ /// -/// await store.send(.login(.submitButtonTapped)) -/// await store.receive(.login(.delegate(.didLogin))) { +/// await store.send(\.login.submitButtonTapped) +/// await store.receive(\.login.delegate.didLogin) { /// $0.selectedTab = .profile /// } /// ``` @@ -418,35 +385,35 @@ import Dispatch /// When this is run you will get grey, informational boxes on each assertion where some change /// wasn't fully asserted on: /// -/// ``` -/// ◽️ A state change does not match expectation: … -/// -///   App.State( -///   authenticatedTab: .loggedOut( -/// Login.State( -/// − isLoading: false -/// + isLoading: true, -/// … -/// ) -/// ) -///   ) -/// -/// (Expected: −, Actual: +) -/// -/// ◽️ Skipped receiving .login(.loginResponse(.success)) -/// -/// ◽️ A state change does not match expectation: … -/// -///   App.State( -/// − authenticatedTab: .loggedOut(…) -/// + authenticatedTab: .loggedIn( -/// + Profile.State(…) -/// + ), -/// … -///   ) -/// -/// (Expected: −, Actual: +) -/// ``` +/// > ◽️ Expected failure: A state change does not match expectation: … +/// > +/// > ```diff +/// >   App.State( +/// >   authenticatedTab: .loggedOut( +/// > Login.State( +/// > - isLoading: false +/// > + isLoading: true, +/// > … +/// > ) +/// > ) +/// >   ) +/// > ``` +/// > +/// > Skipped receiving .login(.loginResponse(.success)) +/// > +/// > A state change does not match expectation: … +/// > +/// > ```diff +/// >   App.State( +/// > - authenticatedTab: .loggedOut(…) +/// > + authenticatedTab: .loggedIn( +/// > + Profile.State(…) +/// > + ), +/// > … +/// >   ) +/// > ``` +/// > +/// > (Expected: −, Actual: +) /// /// The test still passes, and none of these notifications are test failures. They just let you know /// what things you are not explicitly asserting against, and can be useful to see when tracking @@ -460,106 +427,80 @@ import Dispatch #else @preconcurrency@MainActor #endif -final class TestStore { - - /// The current exhaustivity level of the test store. - var exhaustivity: Exhaustivity = .on +public final class TestStore { - /// Serializes all async work to the main thread for the lifetime of the test store. - public var useMainSerialExecutor: Bool { - get { uncheckedUseMainSerialExecutor } - set { uncheckedUseMainSerialExecutor = newValue } - } - private let originalUseMainSerialExecutor = uncheckedUseMainSerialExecutor - - /// The current environment. + /// The current dependencies of the test store. /// - /// The environment can be modified throughout a test store's lifecycle in order to influence - /// how it produces effects. This can be handy for testing flows that require a dependency to - /// start in a failing state and then later change into a succeeding state: + /// The dependencies define the execution context that your feature runs in. They can be modified + /// throughout the test store's lifecycle in order to influence how your feature produces effects. + /// + /// Typically you will override certain dependencies immediately after constructing the test + /// store. For example, if your feature need access to the current date and an API client to do + /// its job, you can override those dependencies like so: /// /// ```swift - /// // Start dependency endpoint in a failing state - /// store.environment.client.fetch = { _ in throw FetchError() } - /// await store.send(.buttonTapped) - /// await store.receive(.response(.failure(FetchError())) { - /// … + /// let store = TestStore(/* ... */) { + /// $0.apiClient = .mock + /// $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1234567890)) /// } /// - /// // Change dependency endpoint into a succeeding state - /// await store.environment.client.fetch = { "Hello \($0)!" } - /// await store.send(.buttonTapped) - /// await store.receive(.response(.success("Hello Blob!"))) { - /// … - /// } + /// // Store assertions here + /// ``` + /// + /// You can also override dependencies in the middle of the test in order to simulate how the + /// dependency changes as the user performs action. For example, to test the flow of an API + /// request failing at first but then later succeeding, you can do the following: + /// + /// ```swift + /// store.dependencies.apiClient = .failing + /// + /// store.send(.buttonTapped) { /* ... */ } + /// store.receive(\.searchResponse.failure) { /* ... */ } + /// + /// store.dependencies.apiClient = .mock + /// + /// store.send(.buttonTapped) { /* ... */ } + /// store.receive(\.searchResponse.success) { /* ... */ } /// ``` - @available( - iOS, - deprecated: 9999, - message: - """ - 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. - - See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol - """ - ) - @available( - macOS, - deprecated: 9999, - message: - """ - 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. - - See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol - """ - ) - @available( - tvOS, - deprecated: 9999, - message: - """ - 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. - See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol - """ - ) - @available( - watchOS, - deprecated: 9999, - message: - """ - 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. + /// The current exhaustivity level of the test store. + public var exhaustivity: Exhaustivity = .on - See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol - """ - ) - var environment: Environment { - _read { yield self._environment.wrappedValue } - _modify { yield &self._environment.wrappedValue } + /// Serializes all async work to the main thread for the lifetime of the test store. + public var useMainSerialExecutor: Bool { + get { uncheckedUseMainSerialExecutor } + set { uncheckedUseMainSerialExecutor = newValue } } + private let originalUseMainSerialExecutor = uncheckedUseMainSerialExecutor /// The current state of the test store. /// - /// When read from a trailing closure assertion in ``send(_:assert:file:line:)-1ax61`` or - /// ``receive(_:timeout:assert:file:line:)-1rwdd``, it will equal the `inout` state passed to the + /// When read from a trailing closure assertion in + /// ``send(_:assert:fileID:file:line:column:)-8f2pl`` or + /// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, it will equal the `inout` state + /// passed to the /// closure. - var state: State { + public var state: State { self.reducer.state } /// The default timeout used in all methods that take an optional timeout. /// /// This is the default timeout used in all methods that take an optional timeout, such as - /// ``receive(_:timeout:assert:file:line:)-332q2`` and ``finish(timeout:file:line:)-7pmv3``. - var timeout: UInt64 - - private var _environment: Box - private let file: StaticString - private let fromScopedAction: (ScopedAction) -> Action - private var line: UInt + /// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk`` and + /// ``finish(timeout:fileID:file:line:column:)-klnc``. + public var timeout: UInt64 + + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt let reducer: TestReducer + private let sharedChangeTracker: SharedChangeTracker private let store: Store.TestAction> - private let toScopedState: (State) -> ScopedState + + /// Returns `true` if the store's feature has been dismissed. + public fileprivate(set) var isDismissed = false /// Creates a test store with an initial state and a reducer powering its runtime. /// @@ -568,85 +509,72 @@ final class TestStore { /// /// - Parameters: /// - initialState: The state the feature starts in. - /// - reducer: The reducer that powers the runtime of the feature. - init( + /// - reducer: The reducer that powers the runtime of the feature. Unlike + /// ``Store/init(initialState:reducer:withDependencies:)``, this is _not_ a builder closure + /// due to a [Swift bug](https://github.com/apple/swift/issues/72399) that is more likely to + /// affect test store initialization. If you must compose multiple reducers in this closure, + /// wrap them in ``CombineReducers``. + /// - prepareDependencies: A closure that can be used to override dependencies that will be + /// accessed during the test. These dependencies will be used when producing the initial + /// state. + public init( initialState: @autoclosure () -> State, - reducer: Reducer, - file: StaticString = #file, - line: UInt = #line + reducer: () -> R, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) - where - Reducer.State == State, - Reducer.Action == Action, - State == ScopedState, - Action == ScopedAction, - Environment == Void - { - let initialState = initialState() - - let reducer = TestReducer(Reduce(reducer), initialState: initialState) - self._environment = .init(wrappedValue: ()) - self.file = file - self.fromScopedAction = { $0 } + where State: Equatable, R.State == State, R.Action == Action { + let sharedChangeTracker = SharedChangeTracker() + let reducer = TestReducer(Reduce(reducer()), initialState: initialState()) + self.fileID = fileID + self.filePath = filePath self.line = line + self.column = column self.reducer = reducer - self.store = Store(initialState: initialState, reducer: reducer) - self.timeout = 100 * NSEC_PER_MSEC - self.toScopedState = { $0 } + self.store = Store(initialState: reducer.state) { reducer } + self.timeout = 1 * NSEC_PER_SEC + self.sharedChangeTracker = sharedChangeTracker self.useMainSerialExecutor = true + self.reducer.store = self } - init( - _environment: Box, - file: StaticString, - fromScopedAction: @escaping (ScopedAction) -> Action, - line: UInt, - reducer: TestReducer, - store: Store.Action>, - timeout: UInt64 = 100 * NSEC_PER_MSEC, - toScopedState: @escaping (State) -> ScopedState - ) { - self._environment = _environment - self.file = file - self.fromScopedAction = fromScopedAction - self.line = line - self.reducer = reducer - self.store = store - self.timeout = timeout - self.toScopedState = toScopedState + /// Suspends until all in-flight effects have finished, or until it times out. + /// + /// Can be used to assert that all effects have finished. + /// + /// - Parameter duration: The amount of time to wait before asserting. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func finish( + timeout duration: Duration, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await self.finish( + timeout: duration.nanoseconds, fileID: fileID, file: filePath, line: line, column: column + ) } - // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. - // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 - #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) - /// Suspends until all in-flight effects have finished, or until it times out. - /// - /// Can be used to assert that all effects have finished. - /// - /// - Parameter duration: The amount of time to wait before asserting. - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - @MainActor - func finish( - timeout duration: Duration, - file: StaticString = #file, - line: UInt = #line - ) async { - await self.finish(timeout: duration.nanoseconds, file: file, line: line) - } - #endif - /// Suspends until all in-flight effects have finished, or until it times out. /// /// Can be used to assert that all effects have finished. /// + /// > Important: `TestStore.finish()` should only be called once per test store, at the end of the + /// > test. Interacting with a finished test store is undefined. + /// /// - Parameter nanoseconds: The amount of time to wait before asserting. @_disfavoredOverload - @MainActor - func finish( + public func finish( timeout nanoseconds: UInt64? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { + self.assertNoReceivedActions(fileID: fileID, filePath: filePath, line: line, column: column) let nanoseconds = nanoseconds ?? self.timeout let start = DispatchTime.now().uptimeNanoseconds await Task.megaYield() @@ -654,7 +582,7 @@ final class TestStore { guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds else { let timeoutMessage = - nanoseconds != self.self.timeout + nanoseconds != self.timeout ? #"try increasing the duration of this assertion's "timeout""# : #"configure this assertion with an explicit "timeout""# let suggestion = """ @@ -667,45 +595,36 @@ final class TestStore { If you are not yet using a clock/scheduler, or can not use a clock/scheduler, \ \(timeoutMessage). """ - - XCTFailHelper( + reportIssueHelper( """ Expected effects to finish, but there are still effects in-flight\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } await Task.yield() } + self.assertNoSharedChanges(fileID: fileID, filePath: filePath, line: line, column: column) } deinit { - uncheckedUseMainSerialExecutor = self.originalUseMainSerialExecutor - mainActorNow { self.completed() } + uncheckedUseMainSerialExecutor = self.originalUseMainSerialExecutor + mainActorNow { self.completed() } } func completed() { - if !self.reducer.receivedActions.isEmpty { - var actions = "" - customDump(self.reducer.receivedActions.map(\.action), to: &actions) - XCTFailHelper( - """ - The store received \(self.reducer.receivedActions.count) unexpected \ - action\(self.reducer.receivedActions.count == 1 ? "" : "s") after this one: … - - Unhandled actions: \(actions) - """, - file: self.file, - line: self.line - ) - } + self.assertNoReceivedActions( + fileID: self.fileID, filePath: self.filePath, line: self.line, column: self.column + ) for effect in self.reducer.inFlightEffects { - XCTFailHelper( + reportIssueHelper( """ An effect returned for this action is still running. It must complete before the end of \ the test. … @@ -717,24 +636,156 @@ final class TestStore { • If using async/await in your effect, it may need a little bit of time to properly \ finish. To fix you can simply perform "await store.finish()" at the end of your test. - • If an effect uses a clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), \ - make sure that you wait enough time for it to perform the effect. If you are using \ - a test clock/scheduler, advance it so that the effects may complete, or consider \ - using an immediate clock/scheduler to immediately perform the effect instead. + • If an effect uses a clock (or scheduler, via "receive(on:)", "delay", "debounce", etc.), \ + make sure that you wait enough time for it to perform the effect. If you are using a test \ + clock/scheduler, advance it so that the effects may complete, or consider using an \ + immediate clock/scheduler to immediately perform the effect instead. • If you are returning a long-living effect (timers, notifications, subjects, etc.), \ then make sure those effects are torn down by marking the effect ".cancellable" and \ returning a corresponding cancellation effect ("Effect.cancel") from another action, or, \ if your effect is driven by a Combine subject, send it a completion. + + • If you do not wish to assert on these effects, perform "await \ + store.skipInFlightEffects()", or consider using a non-exhaustive test store: \ + "store.exhaustivity = .off". """, - file: effect.action.file, - line: effect.action.line + fileID: effect.action.fileID, + filePath: effect.action.filePath, + line: effect.action.line, + column: effect.action.column ) } + self.assertNoSharedChanges( + fileID: self.fileID, + filePath: self.filePath, + line: self.line, + column: self.column + ) + } + + private func assertNoReceivedActions( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + if !self.reducer.receivedActions.isEmpty { + let actions = self.reducer.receivedActions + .map(\.action) + .map { " • " + debugCaseOutput($0, abbreviated: true) } + .joined(separator: "\n") + reportIssueHelper( + """ + The store received \(self.reducer.receivedActions.count) unexpected \ + action\(self.reducer.receivedActions.count == 1 ? "" : "s"): … + + Unhandled actions: + \(actions) + + To fix, explicitly assert against these actions using "store.receive", skip these actions \ + by performing "await store.skipReceivedActions()", or consider using a non-exhaustive test \ + store: "store.exhaustivity = .off". + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + } + + private func assertNoSharedChanges( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + // NB: This existential opening can go away if we can constrain 'State: Equatable' at the + // 'TestStore' level, but for some reason this breaks DocC. + if self.sharedChangeTracker.hasChanges, let stateType = State.self as? any Equatable.Type { + func open(_: EquatableState.Type) { + let store = self as! TestStore + try? store.expectedStateShouldMatch( + preamble: "Test store finished before asserting against changes to shared state", + postamble: """ + Invoke "TestStore.assert" at the end of this test to assert against changes to shared \ + state. + """, + expected: store.state, + actual: store.state, + updateStateToExpectedResult: nil, + skipUnnecessaryModifyFailure: true, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + open(stateType) + self.sharedChangeTracker.resetChanges() + } + } + + /// Overrides the store's exhaustivity for a given operation. + /// + /// - Parameters: + /// - exhaustivity: The exhaustivity. + /// - operation: The operation. + public func withExhaustivity( + _ exhaustivity: Exhaustivity, + operation: () throws -> R + ) rethrows -> R { + let previous = self.exhaustivity + defer { self.exhaustivity = previous } + self.exhaustivity = exhaustivity + return try operation() } + + #if compiler(>=6) + /// Overrides the store's exhaustivity for a given operation. + /// + /// - Parameters: + /// - exhaustivity: The exhaustivity. + /// - operation: The operation. + public func withExhaustivity( + _ exhaustivity: Exhaustivity, + operation: () async throws -> sending R + ) async rethrows -> R { + let previous = self.exhaustivity + defer { self.exhaustivity = previous } + self.exhaustivity = exhaustivity + return try await operation() + } + #else + public func withExhaustivity( + _ exhaustivity: Exhaustivity, + operation: () async throws -> R + ) async rethrows -> R { + let previous = self.exhaustivity + defer { self.exhaustivity = previous } + self.exhaustivity = exhaustivity + return try await operation() + } + #endif } -extension TestStore where ScopedState: Equatable { +/// A convenience type alias for referring to a test store of a given reducer's domain. +/// +/// Instead of specifying two generics: +/// +/// ```swift +/// let testStore: TestStore +/// ``` +/// +/// You can specify a single generic: +/// +/// ```swift +/// let testStore: TestStoreOf +/// ``` +public typealias TestStoreOf = TestStore + +extension TestStore where State: Equatable { /// Sends an action to the store and asserts when state changes. /// /// To assert on how state changes you can provide a trailing closure, and that closure is handed @@ -750,34 +801,34 @@ extension TestStore where ScopedState: Equatable { /// } /// ``` /// - /// This method suspends in order to allow any effects to start. For example, if you - /// track an analytics event in a ``EffectPublisher/fireAndForget(priority:_:)`` when an action is - /// sent, you can assert on that behavior immediately after awaiting `store.send`: + /// This method suspends in order to allow any effects to start. For example, if you track an + /// analytics event in an effect when an action is sent, you can assert on that behavior + /// immediately after awaiting `store.send`: /// /// ```swift - /// @MainActor - /// func testAnalytics() async { - /// let events = ActorIsolated<[String]>([]) + /// @Test + /// func analytics() async { + /// let events = LockIsolated<[String]>([]) /// let analytics = AnalyticsClient( /// track: { event in - /// await events.withValue { $0.append(event) } + /// events.withValue { $0.append(event) } /// } /// ) /// - /// let store = TestStore( - /// initialState: State(), - /// reducer: reducer, - /// environment: Environment(analytics: analytics) - /// ) + /// let store = TestStore(initialState: Feature.State()) { + /// Feature() + /// } withDependencies { + /// $0.analytics = analytics + /// } /// /// await store.send(.buttonTapped) /// - /// await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } + /// events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } /// } /// ``` /// - /// This method suspends only for the duration until the effect _starts_ from sending the - /// action. It does _not_ suspend for the duration of the effect. + /// This method suspends only for the duration until the effect _starts_ from sending the action. + /// It does _not_ suspend for the duration of the effect. /// /// In order to suspend for the duration of the effect you can use its return value, a /// ``TestStoreTask``, which represents the lifecycle of the effect started from sending an @@ -810,221 +861,335 @@ extension TestStore where ScopedState: Equatable { /// expected. /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when /// sending the action. - @MainActor @discardableResult - func send( - _ action: ScopedAction, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + public func send( + _ action: Action, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async -> TestStoreTask { - if !self.reducer.receivedActions.isEmpty { - var actions = "" - customDump(self.reducer.receivedActions.map(\.action), to: &actions) - XCTFailHelper( - """ - Must handle \(self.reducer.receivedActions.count) received \ - action\(self.reducer.receivedActions.count == 1 ? "" : "s") before sending an action: … + await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { + guard !self.isDismissed else { + reportIssue( + "Can't send action to dismissed test store.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return TestStoreTask(rawValue: nil, timeout: self.timeout) + } + if !self.reducer.receivedActions.isEmpty { + var actions = "" + customDump(self.reducer.receivedActions.map(\.action), to: &actions) + reportIssueHelper( + """ + Must handle \(self.reducer.receivedActions.count) received \ + action\(self.reducer.receivedActions.count == 1 ? "" : "s") before sending an action: … - Unhandled actions: \(actions) - """, - file: file, - line: line - ) - } + Unhandled actions: \(actions) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } - switch self.exhaustivity { - case .on: - break - case .off(showSkippedAssertions: true): - await self.skipReceivedActions(strict: false) - case .off(showSkippedAssertions: false): - self.reducer.receivedActions = [] - } + switch self.exhaustivity { + case .on: + break + case .off(showSkippedAssertions: true): + await self.skipReceivedActions(strict: false) + case .off(showSkippedAssertions: false): + self.reducer.receivedActions = [] + } - let expectedState = self.toScopedState(self.state) - let previousState = self.reducer.state - let task = self.store - .send(.init(origin: .send(self.fromScopedAction(action)), file: file, line: line)) + let expectedState = self.state + let previousState = self.reducer.state - if uncheckedUseMainSerialExecutor { - await Task.yield() - } else { - await self.reducer.effectDidSubscribe.stream.first(where: { _ in true }) + let task = self.sharedChangeTracker.track { + self.store.send( + .init( + origin: .send(action), fileID: fileID, filePath: filePath, line: line, column: column + ), + originatingFrom: nil + ) + } + if uncheckedUseMainSerialExecutor { + await Task.yield() + } else { + for await _ in self.reducer.effectDidSubscribe.stream { + break + } + } + do { + let currentState = self.state + self.reducer.state = previousState + defer { + self.reducer.state = currentState + } + + try self.expectedStateShouldMatch( + expected: expectedState, + actual: currentState, + updateStateToExpectedResult: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } catch { + reportIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + // NB: Give concurrency runtime more time to kick off effects so users don't need to manually + // instrument their effects. + await Task.megaYield(count: 20) + return .init(rawValue: task, timeout: self.timeout) } + } + /// Assert against the current state of the store. + /// + /// The trailing closure provided is given a mutable argument that represents the current state, + /// and you can provide any mutations you want to the state. If your mutations cause the argument + /// to differ from the current state of the test store, a test failure will be triggered. + /// + /// This tool is most useful in non-exhaustive test stores (see + /// ), which allow you to assert on a subset of the things + /// happening inside your features. For example, you can send an action in a child feature + /// without asserting on how many changes in the system, and then tell the test store to + /// ``finish(timeout:fileID:file:line:column:)-klnc`` by executing all of its effects, and finally + /// to ``skipReceivedActions(strict:fileID:file:line:column:)`` to receive all actions. After that + /// is done you can assert on the final state of the store: + /// + /// ```swift + /// store.exhaustivity = .off + /// await store.send(\.child.closeButtonTapped) + /// await store.finish() + /// await store.skipReceivedActions() + /// store.assert { + /// $0.child = nil + /// } + /// ``` + /// + /// > Note: This helper is only intended to be used with non-exhaustive test stores. It is not + /// needed in exhaustive test stores since any assertion you may make inside the trailing closure + /// has already been handled by a previous `send` or `receive`. + /// + /// - Parameters: + /// - updateStateToExpectedResult: A closure that asserts against the current state of the test + /// store. + public func assert( + _ updateStateToExpectedResult: @escaping (_ state: inout State) throws -> Void, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + let expectedState = self.state + let currentState = self.reducer.state do { - let currentState = self.state - self.reducer.state = previousState - defer { self.reducer.state = currentState } - try self.expectedStateShouldMatch( expected: expectedState, - actual: self.toScopedState(currentState), + actual: currentState, updateStateToExpectedResult: updateStateToExpectedResult, - file: file, - line: line + skipUnnecessaryModifyFailure: true, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } catch { - XCTFail("Threw error: \(error)", file: file, line: line) - } - if "\(self.file)" == "\(file)" { - self.line = line + reportIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } - // NB: Give concurrency runtime more time to kick off effects so users don't need to manually - // instrument their effects. - await Task.megaYield(count: 20) - return .init(rawValue: task, timeout: self.timeout) } private func expectedStateShouldMatch( - expected: ScopedState, - actual: ScopedState, - updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString, - line: UInt + preamble: String = "", + postamble: String = "", + expected: State, + actual: State, + updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, + skipUnnecessaryModifyFailure: Bool = false, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) throws { - let current = expected - var expected = expected - - switch self.exhaustivity { - case .on: - var expectedWhenGivenPreviousState = expected - if let updateStateToExpectedResult = updateStateToExpectedResult { - try updateStateToExpectedResult(&expectedWhenGivenPreviousState) + try self.sharedChangeTracker.assert { + let skipUnnecessaryModifyFailure = + skipUnnecessaryModifyFailure + || self.sharedChangeTracker.hasChanges == true + if self.exhaustivity != .on { + self.sharedChangeTracker.resetChanges() } - expected = expectedWhenGivenPreviousState - if expectedWhenGivenPreviousState != actual { - expectationFailure(expected: expectedWhenGivenPreviousState) - } else { - tryUnnecessaryModifyFailure() - } + let current = expected + var expected = expected - case .off: - var expectedWhenGivenActualState = actual - if let updateStateToExpectedResult = updateStateToExpectedResult { - try updateStateToExpectedResult(&expectedWhenGivenActualState) + let updateStateToExpectedResult = updateStateToExpectedResult.map { original in + { (state: inout State) in + try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { + try original(&state) + } + } } - expected = expectedWhenGivenActualState - if expectedWhenGivenActualState != actual { - self.withExhaustivity(.on) { - expectationFailure(expected: expectedWhenGivenActualState) - } - } else if self.exhaustivity == .off(showSkippedAssertions: true) - && expectedWhenGivenActualState == actual - { - var expectedWhenGivenPreviousState = current - if let updateStateToExpectedResult = updateStateToExpectedResult { - _XCTExpectFailure(strict: false) { - do { - try updateStateToExpectedResult(&expectedWhenGivenPreviousState) - } catch { - XCTFail( - """ - Skipped assertions: … - - Threw error: \(error) - """, - file: file, - line: line - ) - } - } + switch self.exhaustivity { + case .on: + var expectedWhenGivenPreviousState = expected + if let updateStateToExpectedResult { + try updateStateToExpectedResult(&expectedWhenGivenPreviousState) + } expected = expectedWhenGivenPreviousState + if expectedWhenGivenPreviousState != actual { expectationFailure(expected: expectedWhenGivenPreviousState) } else { tryUnnecessaryModifyFailure() } - } else { - tryUnnecessaryModifyFailure() + + case .off: + var expectedWhenGivenActualState = actual + if let updateStateToExpectedResult { + try updateStateToExpectedResult(&expectedWhenGivenActualState) + } + expected = expectedWhenGivenActualState + + if expectedWhenGivenActualState != actual { + self.withExhaustivity(.on) { + expectationFailure(expected: expectedWhenGivenActualState) + } + } else if self.exhaustivity == .off(showSkippedAssertions: true) + && expectedWhenGivenActualState == actual + { + var expectedWhenGivenPreviousState = current + if let updateStateToExpectedResult { + withExpectedIssue(isIntermittent: true) { + do { + try updateStateToExpectedResult(&expectedWhenGivenPreviousState) + } catch { + reportIssue( + """ + Skipped assertions: … + + Threw error: \(error) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + } + } + expected = expectedWhenGivenPreviousState + if self.withExhaustivity(.on, operation: { expectedWhenGivenPreviousState != actual }) { + expectationFailure(expected: expectedWhenGivenPreviousState) + } else { + tryUnnecessaryModifyFailure() + } + } else { + tryUnnecessaryModifyFailure() + } } - } - func expectationFailure(expected: ScopedState) { - let difference = - diff(expected, actual, format: .proportional) - .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } - ?? """ - Expected: - \(String(describing: expected).indent(by: 2)) + @MainActor + func expectationFailure(expected: State) { + let difference = self.withExhaustivity(.on) { + diff(expected, actual, format: .proportional) + .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } + ?? """ + Expected: + \(String(describing: expected).indent(by: 2)) - Actual: - \(String(describing: actual).indent(by: 2)) - """ - let messageHeading = - updateStateToExpectedResult != nil - ? "A state change does not match expectation" - : "State was not expected to change, but a change occurred" - XCTFailHelper( - """ - \(messageHeading): … + Actual: + \(String(describing: actual).indent(by: 2)) + """ + } + let messageHeading = + !preamble.isEmpty + ? preamble + : updateStateToExpectedResult != nil + ? "A state change does not match expectation" + : "State was not expected to change, but a change occurred" + reportIssueHelper( + """ + \(messageHeading): … - \(difference) - """, - file: file, - line: line - ) - } + \(difference)\(postamble.isEmpty ? "" : "\n\n\(postamble)") + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } - func tryUnnecessaryModifyFailure() { - guard expected == current && updateStateToExpectedResult != nil - else { return } + @MainActor + func tryUnnecessaryModifyFailure() { + guard + !skipUnnecessaryModifyFailure, + expected == current, + updateStateToExpectedResult != nil + else { return } - XCTFailHelper( - """ - Expected state to change, but no change occurred. + reportIssueHelper( + """ + Expected state to change, but no change occurred. - The trailing closure made no observable modifications to state. If no change to state is \ - expected, omit the trailing closure. - """, - file: file, - line: line - ) + The trailing closure made no observable modifications to state. If no change to state is \ + expected, omit the trailing closure. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + self.sharedChangeTracker.resetChanges() } } - - private func withExhaustivity(_ exhaustivity: Exhaustivity, operation: () -> Void) { - let previous = self.exhaustivity - self.exhaustivity = exhaustivity - operation() - self.exhaustivity = previous - } } -extension TestStore where ScopedState: Equatable, Action: Equatable { - /// Asserts an action was received from an effect and asserts when state changes. - /// - /// See ``receive(_:timeout:assert:file:line:)-332q2`` for more information of how to use this - /// method. - /// - /// - Parameters: - /// - expectedAction: An action expected from an effect. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to - /// the store. The mutable state sent to this closure must be modified to match the state of - /// the store after processing the given action. Do not provide a closure if no change is - /// expected. - @available(iOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(macOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - func receive( +extension TestStore where State: Equatable, Action: Equatable { + private func _receive( _ expectedAction: Action, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { + var expectedActionDump = "" + customDump(expectedAction, to: &expectedActionDump, indent: 2) self.receiveAction( matching: { expectedAction == $0 }, - failureMessage: "Expected to receive an action \(expectedAction), but didn't get one.", - onReceive: { receivedAction in - if expectedAction != receivedAction { - let difference = TaskResultDebugging.$emitRuntimeWarnings.withValue(false) { - diff(expectedAction, receivedAction, format: .proportional) - .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } + failureMessage: """ + Expected to receive the following action, but didn't: … + + \(expectedActionDump) + """, + unexpectedActionDescription: { receivedAction in + TaskResultDebugging.$emitRuntimeWarnings.withValue(false) { + diff(expectedAction, receivedAction, format: .proportional) + .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } ?? """ Expected: \(String(describing: expectedAction).indent(by: 2)) @@ -1032,410 +1197,781 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { Received: \(String(describing: receivedAction).indent(by: 2)) """ - } - - XCTFailHelper( - """ - Received unexpected action: … - - \(difference) - """, - file: file, - line: line - ) } }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } - /// Asserts a matching action was received from an effect and asserts how the state changes. + /// Asserts an action was received from an effect and asserts how the state changes. /// - /// See ``receive(_:timeout:assert:file:line:)-6b3xi`` for more information of how to use this - /// method. + /// When an effect is executed in your feature and sends an action back into the system, you can + /// use this method to assert that fact, and further assert how state changes after the effect + /// action is received: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive(.response(.success(42)) { + /// $0.count = 42 + /// } + /// ``` + /// + /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass + /// before effects execute and send actions, and that is why this method suspends. The default + /// time waited is very small, and typically it is enough so you should be controlling your + /// dependencies so that they do not wait for real world time to pass (see + /// for more information on how to do that). + /// + /// To change the amount of time this method waits for an action, pass an explicit `timeout` + /// argument, or set the ``timeout`` on the ``TestStore``. /// /// - Parameters: - /// - matchingAction: A closure that attempts to extract a value from an action. If it returns - /// `nil`, a test failure is reported. + /// - expectedAction: An action expected from an effect. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action + /// to the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change + /// is expected. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func receive( + _ expectedAction: Action, + timeout duration: Duration, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await self.receive( + expectedAction, + timeout: duration.nanoseconds, + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) + } + + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// When an effect is executed in your feature and sends an action back into the system, you can + /// use this method to assert that fact, and further assert how state changes after the effect + /// action is received: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive(.response(.success(42)) { + /// $0.count = 42 + /// } + /// ``` + /// + /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass + /// before effects execute and send actions, and that is why this method suspends. The default + /// time waited is very small, and typically it is enough so you should be controlling your + /// dependencies so that they do not wait for real world time to pass (see + /// for more information on how to do that). + /// + /// To change the amount of time this method waits for an action, pass an explicit `timeout` + /// argument, or set the ``timeout`` on the ``TestStore``. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. - @available(iOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(macOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - func receive( - _ matching: (Action) -> Bool, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + @_disfavoredOverload + public func receive( + _ expectedAction: Action, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self._receive( + expectedAction, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }() + return + } + await self.receiveAction( + matching: { expectedAction == $0 }, + timeout: nanoseconds, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + _ = { + self._receive( + expectedAction, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }() + await Task.megaYield() + } + } +} + +extension TestStore where State: Equatable { + private func _receive( + _ isMatching: (Action) -> Bool, + assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.receiveAction( - matching: matching, - failureMessage: "Expected to receive a matching action, but didn't get one.", - onReceive: { receivedAction in + matching: isMatching, + failureMessage: "Expected to receive an action matching predicate, but didn't get one.", + unexpectedActionDescription: { receivedAction in var action = "" customDump(receivedAction, to: &action, indent: 2) - XCTFailHelper( - """ - Received action without asserting on payload: - - \(action) - """, - overrideExhaustivity: self.exhaustivity == .on - ? .off(showSkippedAssertions: true) - : self.exhaustivity, - file: file, - line: line - ) + return action }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } - /// Asserts an action was received matching a case path and asserts how the state changes. - /// - /// See ``receive(_:timeout:assert:file:line:)-5n755`` for more information of how to use this - /// method. - /// - /// - Parameters: - /// - casePath: A case path identifying the case of an action to enum to receive - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to - /// the store. The mutable state sent to this closure must be modified to match the state of - /// the store after processing the given action. Do not provide a closure if no change is - /// expected. - @available(iOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(macOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") - func receive( - _ casePath: CasePath, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + private func _receive( + _ actionCase: AnyCasePath, + assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.receiveAction( - matching: { casePath.extract(from: $0) != nil }, - failureMessage: "Expected to receive a matching action, but didn't get one.", - onReceive: { receivedAction in + matching: { actionCase.extract(from: $0) != nil }, + failureMessage: "Expected to receive an action matching case path, but didn't get one.", + unexpectedActionDescription: { receivedAction in var action = "" customDump(receivedAction, to: &action, indent: 2) - XCTFailHelper( - """ - Received action without asserting on payload: - - \(action) - """, - overrideExhaustivity: self.exhaustivity == .on - ? .off(showSkippedAssertions: true) - : self.exhaustivity, - file: file, - line: line - ) + return action }, updateStateToExpectedResult, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } - // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. - // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 - #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) - /// Asserts an action was received from an effect and asserts how the state changes. - /// - /// When an effect is executed in your feature and sends an action back into the system, you - /// can use this method to assert that fact, and further assert how state changes after the - /// effect action is received: - /// - /// ```swift - /// await store.send(.buttontTapped) - /// await store.receive(.response(.success(42)) { - /// $0.count = 42 - /// } - /// ``` - /// - /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs - /// to pass before effects execute and send actions, and that is why this method suspends. - /// The default time waited is very small, and typically it is enough so you should be - /// controlling your dependencies so that they do not wait for real world time to pass (see - /// for more information on how to do that). - /// - /// To change the amount of time this method waits for an action, pass an explicit `timeout` - /// argument, or set the ``timeout`` on the ``TestStore``. - /// - /// - Parameters: - /// - expectedAction: An action expected from an effect. - /// - duration: The amount of time to wait for the expected action. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action - /// to the store. The mutable state sent to this closure must be modified to match the state - /// of the store after processing the given action. Do not provide a closure if no change - /// is expected. - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - @MainActor - func receive( - _ expectedAction: Action, - timeout duration: Duration, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { - await self.receive( - expectedAction, - timeout: duration.nanoseconds, - assert: updateStateToExpectedResult, - file: file, - line: line - ) - } + private func _receive( + _ actionCase: AnyCasePath, + _ value: Value, + assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.receiveAction( + matching: { actionCase.extract(from: $0) == value }, + failureMessage: "Expected to receive an action matching case path, but didn't get one.", + unexpectedActionDescription: { receivedAction in + var action = "" + if actionCase.extract(from: receivedAction) != nil, + let difference = diff(actionCase.embed(value), receivedAction, format: .proportional) + { + action.append( + """ + \(difference.indent(by: 2)) - /// Asserts an action was received from an effect that matches a predicate, and asserts how - /// the state changes. - /// - /// This method is similar to ``receive(_:timeout:assert:file:line:)-5n755``, except it allows - /// you to assert that an action was received that matches a predicate without asserting - /// on all the data in the action: - /// - /// ```swift - /// await store.send(.buttonTapped) - /// await store.receive { - /// guard case .response(.suceess) = $0 else { return false } - /// return true - /// } assert: { - /// store.count = 42 - /// } - /// ``` - /// - /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a - /// grey information box will show next to the `store.receive` line in Xcode letting you know - /// what data was in the effect that you chose not to assert on. - /// - /// If you only want to check that a particular action case was received, then you might - /// find the ``receive(_:timeout:assert:file:line:)-5n755`` overload of this method more - /// useful. - /// - /// - Parameters: - /// - matchingAction: A closure that attempts to extract a value from an action. If it returns - /// `nil`, a test failure is reported. - /// - duration: The amount of time to wait for the expected action. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action - /// to the store. The mutable state sent to this closure must be modified to match the state - /// of the store after processing the given action. Do not provide a closure if no change is - /// expected. - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - @MainActor - @_disfavoredOverload - func receive( - _ matching: (Action) -> Bool, - timeout duration: Duration, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { - await self.receive( - matching, - timeout: duration.nanoseconds, - assert: updateStateToExpectedResult, - file: file, - line: line - ) - } - #endif + (Expected: −, Actual: +) + """ + ) + } else { + customDump(receivedAction, to: &action, indent: 2) + } + return action + }, + updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } - /// Asserts an action was received from an effect and asserts how the state changes. + /// Asserts an action was received from an effect that matches a predicate, and asserts how the + /// state changes. /// - /// See ``receive(_:timeout:assert:file:line:)-332q2`` for more information on how to use this - /// method. + /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, except + /// it allows you to assert that an action was received that matches a predicate instead of a case + /// key path: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive { + /// guard case .response(.success) = $0 else { return false } + /// return true + /// } assert: { + /// store.count = 42 + /// } + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey + /// information box will show next to the `store.receive` line in Xcode letting you know what data + /// was in the effect that you chose not to assert on. + /// + /// If you only want to check that a particular action case was received, then you might find the + /// ``receive(_:timeout:assert:fileID:file:line:column:)-53wic`` overload of this method more + /// useful. /// /// - Parameters: - /// - expectedAction: An action expected from an effect. + /// - isMatching: A closure that attempts to match an action. If it returns `false`, a test + /// failure is reported. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action + /// to the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change is + /// expected. + @_disfavoredOverload + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func receive( + _ isMatching: (_ action: Action) -> Bool, + timeout duration: Duration, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await self.receive( + isMatching, + timeout: duration.nanoseconds, + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) + } + + /// Asserts an action was received from an effect that matches a predicate, and asserts how the + /// state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, except + /// it allows you to assert that an action was received that matches a predicate instead of a case + /// key path: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive { + /// guard case .response(.success) = $0 else { return false } + /// return true + /// } assert: { + /// store.count = 42 + /// } + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey + /// information box will show next to the `store.receive` line in Xcode letting you know what data + /// was in the effect that you chose not to assert on. + /// + /// If you only want to check that a particular action case was received, then you might find the + /// ``receive(_:timeout:assert:fileID:file:line:column:)-53wic`` overload of this method more + /// useful. + /// + /// - Parameters: + /// - isMatching: A closure that attempts to match an action. If it returns `false`, a test + /// failure is reported. /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. - @MainActor @_disfavoredOverload - func receive( - _ expectedAction: Action, + public func receive( + _ isMatching: (_ action: Action) -> Bool, timeout nanoseconds: UInt64? = nil, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { - guard !self.reducer.inFlightEffects.isEmpty - else { + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self._receive( + isMatching, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }() + return + } + await self.receiveAction( + matching: isMatching, + timeout: nanoseconds, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) _ = { - self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + isMatching, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() - return + await Task.megaYield() } - await self.receiveAction(timeout: nanoseconds, file: file, line: line) - _ = { - self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) - }() - await Task.megaYield() } - /// Asserts a matching action was received from an effect and asserts how the state changes. + /// Asserts an action was received matching a case path and asserts how the state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-35638``, except + /// it allows you to assert that an action was received that matches a case key path instead of a + /// predicate. /// - /// See ``receive(_:timeout:assert:file:line:)-6b3xi`` for more information on how to use this - /// method. + /// It can be useful to assert that a particular action was received without asserting on the data + /// inside the action. For example: + /// + /// ```swift + /// await store.receive(/Search.Action.searchResponse) { + /// $0.results = [ + /// "CasePaths", + /// "ComposableArchitecture", + /// "IdentifiedCollections", + /// "XCTestDynamicOverlay", + /// ] + /// } + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey + /// information box will show next to the `store.receive` line in Xcode letting you know what data + /// was in the effect that you chose not to assert on. /// /// - Parameters: - /// - matchingAction: A closure that attempts to extract a value from an action. If it returns - /// `nil`, a test failure is reported. + /// - actionCase: A case path identifying the case of an action to enum to receive /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. - @MainActor @_disfavoredOverload - func receive( - _ matching: (Action) -> Bool, + public func receive( + _ actionCase: CaseKeyPath, timeout nanoseconds: UInt64? = nil, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { - guard !self.reducer.inFlightEffects.isEmpty - else { + await self.receive( + AnyCasePath(actionCase), + timeout: nanoseconds, + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) + } + + /// Asserts an action was received matching a case path with a specific payload, and asserts how + /// the state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-53wic``, except + /// it allows you to assert on the value inside the action too. + /// + /// It can be useful when asserting on delegate actions sent by a child feature: + /// + /// ```swift + /// await store.receive(\.delegate.success, "Hello!") + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey + /// information box will show next to the `store.receive` line in Xcode letting you know what data + /// was in the effect that you chose not to assert on. + /// + /// - Parameters: + /// - actionCase: A case path identifying the case of an action to enum to receive + /// - value: The value to match in the action. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action + /// to the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change is + /// expected. + @_disfavoredOverload + public func receive( + _ actionCase: CaseKeyPath, + _ value: Value, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async + where Action: CasePathable { + let actionCase = AnyCasePath(actionCase) + await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self._receive( + actionCase, + value, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }() + return + } + await self.receiveAction( + matching: { actionCase.extract(from: $0) != nil }, + timeout: nanoseconds, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) _ = { - self.receive(matching, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + actionCase, + value, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() - return + await Task.megaYield() } - await self.receiveAction(timeout: nanoseconds, file: file, line: line) - _ = { - self.receive(matching, assert: updateStateToExpectedResult, file: file, line: line) - }() - await Task.megaYield() } - /// Asserts an action was received matching a case path and asserts how the state changes. + @available( + iOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + macOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + tvOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + watchOS, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @_disfavoredOverload + public func receive( + _ actionCase: AnyCasePath, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }() + return + } + await self.receiveAction( + matching: { actionCase.extract(from: $0) != nil }, + timeout: nanoseconds, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + _ = { + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }() + await Task.megaYield() + } + } + + /// Asserts an action was received matching a case path and asserts how the state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, except + /// it allows you to assert that an action was received that matches a case key path instead of a + /// predicate. + /// + /// It can be useful to assert that a particular action was received without asserting on the data + /// inside the action. For example: + /// + /// ```swift + /// await store.receive(\.searchResponse) { + /// $0.results = [ + /// "CasePaths", + /// "ComposableArchitecture", + /// "IdentifiedCollections", + /// "XCTestDynamicOverlay", + /// ] + /// } + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey + /// information box will show next to the `store.receive` line in Xcode letting you know what data + /// was in the effect that you chose not to assert on. + /// + /// - Parameters: + /// - actionCase: A case path identifying the case of an action to enum to receive + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action + /// to the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change is + /// expected. + @_disfavoredOverload + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func receive( + _ actionCase: CaseKeyPath, + timeout duration: Duration, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await self.receive( + AnyCasePath(actionCase), + timeout: duration, + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) + } + + /// Asserts an action was received matching a case path with a specific payload, and asserts how + /// the state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-53wic``, except + /// it allows you to assert on the value inside the action too. + /// + /// It can be useful when asserting on delegate actions sent by a child feature: + /// + /// ```swift + /// await store.receive(\.delegate.success, "Hello!") + /// ``` /// - /// See ``receive(_:timeout:assert:file:line:)-5n755`` for more information of how to use this - /// method. + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey + /// information box will show next to the `store.receive` line in Xcode letting you know what data + /// was in the effect that you chose not to assert on. /// /// - Parameters: - /// - casePath: A case path identifying the case of an action to enum to receive - /// - nanoseconds: The amount of time to wait for the expected action. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to - /// the store. The mutable state sent to this closure must be modified to match the state of - /// the store after processing the given action. Do not provide a closure if no change is + /// - actionCase: A case path identifying the case of an action to enum to receive + /// - value: The value to match in the action. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action + /// to the store. The mutable state sent to this closure must be modified to match the state + /// of the store after processing the given action. Do not provide a closure if no change is /// expected. - @MainActor @_disfavoredOverload - func receive( - _ casePath: CasePath, - timeout nanoseconds: UInt64? = nil, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { - guard !self.reducer.inFlightEffects.isEmpty - else { - _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) - }() - return - } - await self.receiveAction(timeout: nanoseconds, file: file, line: line) - _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) - }() - await Task.megaYield() + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func receive( + _ actionCase: _SendableCaseKeyPath, + _ value: Value, + timeout duration: Duration, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async + where Action: CasePathable { + await self.receive( + AnyCasePath( + embed: { actionCase($0) }, + extract: { action in + action[case: actionCase].flatMap { $0 == value ? $0 : nil } + } + ), + timeout: duration, + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) } - #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) - /// Asserts an action was received matching a case path and asserts how the state changes. - /// - /// This method is similar to ``receive(_:timeout:assert:file:line:)-5n755``, except it allows - /// you to assert that an action was received that matches a particular case of the action - /// enum without asserting on all the data in the action. - /// - /// It can be useful to assert that a particular action was received without asserting - /// on the data inside the action. For example: - /// - /// ```swift - /// await store.receive(/Search.Action.searchResponse) { - /// $0.results = [ - /// "CasePaths", - /// "ComposableArchitecture", - /// "IdentifiedCollections", - /// "XCTestDynamicOverlay", - /// ] - /// } - /// ``` - /// - /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a - /// grey information box will show next to the `store.receive` line in Xcode letting you know - /// what data was in the effect that you chose not to assert on. - /// - /// - Parameters: - /// - casePath: A case path identifying the case of an action to enum to receive - /// - duration: The amount of time to wait for the expected action. - /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action - /// to the store. The mutable state sent to this closure must be modified to match the state - /// of the store after processing the given action. Do not provide a closure if no change is - /// expected. - @MainActor - @_disfavoredOverload - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - func receive( - _ casePath: CasePath, - timeout duration: Duration, - assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) async { + @_disfavoredOverload + @available( + iOS, + introduced: 16, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + macOS, + introduced: 13, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + tvOS, + introduced: 16, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + @available( + watchOS, + introduced: 9, + deprecated: 9999, + message: + "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" + ) + public func receive( + _ actionCase: AnyCasePath, + timeout duration: Duration, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() return } - await self.receiveAction(timeout: duration.nanoseconds, file: file, line: line) + await self.receiveAction( + matching: { actionCase.extract(from: $0) != nil }, + timeout: duration.nanoseconds, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) + self._receive( + actionCase, + assert: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) }() await Task.megaYield() } - #endif + } private func receiveAction( matching predicate: (Action) -> Bool, failureMessage: @autoclosure () -> String, - onReceive: (Action) -> Void, - _ updateStateToExpectedResult: ((inout ScopedState) throws -> Void)?, - file: StaticString, - line: UInt + unexpectedActionDescription: (Action) -> String, + _ updateStateToExpectedResult: ((inout State) throws -> Void)?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) { + let updateStateToExpectedResult = updateStateToExpectedResult.map { original in + { (state: inout State) in + try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { + try original(&state) + } + } + } + guard !self.reducer.receivedActions.isEmpty else { - XCTFail( - """ - Expected to receive an action, but received none. - """, - file: file, - line: line + reportIssue( + failureMessage(), + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } if self.exhaustivity != .on { guard self.reducer.receivedActions.contains(where: { predicate($0.action) }) else { - XCTFail( + reportIssue( failureMessage(), - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } @@ -1444,52 +1980,76 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { while let receivedAction = self.reducer.receivedActions.first, !predicate(receivedAction.action) { + self.reducer.receivedActions.removeFirst() actions.append(receivedAction.action) - self.withExhaustivity(.off) { - self.receive(receivedAction.action, file: file, line: line) - } + self.reducer.state = receivedAction.state } if !actions.isEmpty { - var action = "" - customDump(actions, to: &action) - XCTFailHelper( + var actionsDump = "" + customDump(actions, to: &actionsDump) + reportIssueHelper( """ \(actions.count) received action\ \(actions.count == 1 ? " was" : "s were") skipped: - \(action) + \(actionsDump) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } let (receivedAction, state) = self.reducer.receivedActions.removeFirst() - onReceive(receivedAction) - let expectedState = self.toScopedState(self.state) - do { - try self.expectedStateShouldMatch( - expected: expectedState, - actual: self.toScopedState(state), - updateStateToExpectedResult: updateStateToExpectedResult, - file: file, - line: line + if !predicate(receivedAction) { + let receivedActionLater = self.reducer.receivedActions + .contains(where: { action, _ in predicate(receivedAction) }) + reportIssueHelper( + """ + Received unexpected action\(receivedActionLater ? " before this one" : ""): … + + \(unexpectedActionDescription(receivedAction)) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) - } catch { - XCTFail("Threw error: \(error)", file: file, line: line) + } else { + let expectedState = self.state + do { + try self.expectedStateShouldMatch( + expected: expectedState, + actual: state, + updateStateToExpectedResult: updateStateToExpectedResult, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } catch { + reportIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } } self.reducer.state = state - if "\(self.file)" == "\(file)" { - self.line = line - } } private func receiveAction( + matching predicate: (Action) -> Bool, timeout nanoseconds: UInt64?, - file: StaticString, - line: UInt + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) async { let nanoseconds = nanoseconds ?? self.timeout @@ -1498,8 +2058,14 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { while !Task.isCancelled { await Task.detached(priority: .background) { await Task.yield() }.value - guard self.reducer.receivedActions.isEmpty - else { break } + switch self.exhaustivity { + case .on: + guard self.reducer.receivedActions.isEmpty + else { return } + case .off: + guard !self.reducer.receivedActions.contains(where: { predicate($0.action) }) + else { return } + } guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds else { @@ -1521,73 +2087,127 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { clock/scheduler, advance it so that the effects may complete, or consider using \ an immediate clock/scheduler to immediately perform the effect instead. - If you are not yet using a scheduler, or can not use a scheduler, \(timeoutMessage). + If you are not yet using a clock/scheduler, or can not use a clock/scheduler, \ + \(timeoutMessage). """ } - XCTFail( + reportIssue( """ - Expected to receive an action, but received none\ + Expected to receive \(self.exhaustivity == .on ? "an action" : "a matching action"), but \ + received none\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) return } } - - guard !Task.isCancelled - else { return } } } -extension TestStore { - /// Scopes a store to assert against scoped state and actions. +extension TestStore where State: Equatable { + /// Sends an action to the store and asserts when state changes. + /// + /// This method is similar to ``send(_:assert:fileID:file:line:column:)-8f2pl``, except it allows + /// you to specify a case key path to an action, which can be useful when testing the integration + /// of features and sending deeply nested actions. For example: + /// + /// ```swift + /// await store.send(.destination(.presented(.child(.tap)))) + /// ``` /// - /// Useful for testing view store-specific state and actions. + /// …can be simplified to: + /// + /// ```swift + /// await store.send(\.destination.child.tap) + /// ``` /// /// - Parameters: - /// - toScopedState: A function that transforms the reducer's state into scoped state. This - /// state will be asserted against as it is mutated by the reducer. Useful for testing view - /// store state transformations. - /// - fromScopedAction: A function that wraps a more scoped action in the reducer's action. - /// Scoped actions can be "sent" to the store, while any reducer action may be received. - /// Useful for testing view store action transformations. - func scope( - state toScopedState: @escaping (ScopedState) -> S, - action fromScopedAction: @escaping (A) -> ScopedAction - ) -> TestStore { - .init( - _environment: self._environment, - file: self.file, - fromScopedAction: { self.fromScopedAction(fromScopedAction($0)) }, - line: self.line, - reducer: self.reducer, - store: self.store, - timeout: self.timeout, - toScopedState: { toScopedState(self.toScopedState($0)) } + /// - action: A case key path to an action. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @_disfavoredOverload + @discardableResult + public func send( + _ action: CaseKeyPath, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async -> TestStoreTask { + await self.send( + action(), + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column ) } - /// Scopes a store to assert against scoped state. + /// Sends an action to the store and asserts when state changes. /// - /// Useful for testing view store-specific state. + /// This method is similar to ``send(_:assert:fileID:file:line:column:)-8f2pl``, except it allows + /// you to specify a value for the associated value of the action. /// - /// - Parameter toScopedState: A function that transforms the reducer's state into scoped state. - /// This state will be asserted against as it is mutated by the reducer. Useful for testing - /// view store state transformations. - func scope( - state toScopedState: @escaping (ScopedState) -> S - ) -> TestStore { - self.scope(state: toScopedState, action: { $0 }) + /// It can be useful when sending nested action. For example: + /// + /// ```swift + /// await store.send(.destination(.presented(.child(.emailChanged("blob@pointfree.co"))))) + /// ``` + /// + /// …can be simplified to: + /// + /// ```swift + /// await store.send(\.destination.child.emailChanged, "blob@pointfree.co") + /// ``` + /// + /// - Parameters: + /// - action: A case key path to an action. + /// - value: A value to embed in `action`. + /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to + /// the store. The mutable state sent to this closure must be modified to match the state of + /// the store after processing the given action. Do not provide a closure if no change is + /// expected. + /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @_disfavoredOverload + @discardableResult + public func send( + _ action: CaseKeyPath, + _ value: Value, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async -> TestStoreTask { + await self.send( + action(value), + assert: updateStateToExpectedResult, + fileID: fileID, + file: filePath, + line: line, + column: column + ) } +} +extension TestStore { /// Clears the queue of received actions from effects. /// - /// Can be handy if you are writing an exhaustive test for a particular part of your feature, - /// but you don't want to explicitly deal with all of the received actions: + /// Can be handy if you are writing an exhaustive test for a particular part of your feature, but + /// you don't want to explicitly deal with all of the received actions: /// /// ```swift /// let store = TestStore(/* ... */) @@ -1595,7 +2215,7 @@ extension TestStore { /// await store.send(.buttonTapped) { /// // Assert on how state changed /// } - /// await store.receive(.response(/* ... */)) { + /// await store.receive(\.response) { /// // Assert on how state changed /// } /// @@ -1605,41 +2225,36 @@ extension TestStore { /// /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure /// will be reported. - @MainActor - func skipReceivedActions( + public func skipReceivedActions( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await Task.megaYield() - _ = { self.skipReceivedActions(strict: strict, file: file, line: line) }() + _ = { + self._skipReceivedActions( + strict: strict, fileID: fileID, file: filePath, line: line, column: column + ) + }() } - /// Clears the queue of received actions from effects. - /// - /// The synchronous version of ``skipReceivedActions(strict:file:line:)-a4ri``. - /// - /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure - /// will be reported. - @available( - iOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." - ) - @available( - macOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." - ) - @available( - tvOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." - ) - @available( - watchOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." - ) - func skipReceivedActions( + private func _skipReceivedActions( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { if strict && self.reducer.receivedActions.isEmpty { - XCTFail("There were no received actions to skip.") + reportIssue( + "There were no received actions to skip.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } guard !self.reducer.receivedActions.isEmpty @@ -1650,7 +2265,7 @@ extension TestStore { } else { customDump(self.reducer.receivedActions.map { $0.action }, to: &actions) } - XCTFailHelper( + reportIssueHelper( """ \(self.reducer.receivedActions.count) received action\ \(self.reducer.receivedActions.count == 1 ? " was" : "s were") skipped: @@ -1660,8 +2275,10 @@ extension TestStore { overrideExhaustivity: self.exhaustivity == .on ? .off(showSkippedAssertions: true) : self.exhaustivity, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) self.reducer.state = self.reducer.receivedActions.last!.state self.reducer.receivedActions = [] @@ -1669,8 +2286,8 @@ extension TestStore { /// Cancels any currently in-flight effects. /// - /// Can be handy if you are writing an exhaustive test for a particular part of your feature, - /// but you don't want to explicitly deal with all effects: + /// Can be handy if you are writing an exhaustive test for a particular part of your feature, but + /// you don't want to explicitly deal with all effects: /// /// ```swift /// let store = TestStore(/* ... */) @@ -1678,7 +2295,7 @@ extension TestStore { /// await store.send(.buttonTapped) { /// // Assert on how state changed /// } - /// await store.receive(.response(/* ... */)) { + /// await store.receive(\.response) { /// // Assert on how state changed /// } /// @@ -1688,40 +2305,36 @@ extension TestStore { /// /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure /// will be reported. - func skipInFlightEffects( + public func skipInFlightEffects( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { await Task.megaYield() - _ = { self.skipInFlightEffects(strict: strict, file: file, line: line) }() + _ = { + self._skipInFlightEffects( + strict: strict, fileID: fileID, filePath: filePath, line: line, column: column + ) + }() } - /// Cancels any currently in-flight effects. - /// - /// The synchronous version of ``skipInFlightEffects(strict:file:line:)-5hbsk``. - /// - /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure - /// will be reported. - @available( - iOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." - ) - @available( - macOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." - ) - @available( - tvOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." - ) - @available( - watchOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." - ) - func skipInFlightEffects( + fileprivate func _skipInFlightEffects( strict: Bool = true, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { if strict && self.reducer.inFlightEffects.isEmpty { - XCTFail("There were no in-flight effects to skip.") + reportIssue( + "There were no in-flight effects to skip.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } guard !self.reducer.inFlightEffects.isEmpty @@ -1734,7 +2347,7 @@ extension TestStore { customDump(self.reducer.inFlightEffects.map { $0.action.origin.action }, to: &actions) } - XCTFailHelper( + reportIssueHelper( """ \(self.reducer.inFlightEffects.count) in-flight effect\ \(self.reducer.inFlightEffects.count == 1 ? " was" : "s were") cancelled, originating from: @@ -1744,45 +2357,47 @@ extension TestStore { overrideExhaustivity: self.exhaustivity == .on ? .off(showSkippedAssertions: true) : self.exhaustivity, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) - - for effect in self.reducer.inFlightEffects { - _ = EffectPublisher.cancel(id: effect.id).sink { _ in } - } self.reducer.inFlightEffects = [] } - private func XCTFailHelper( + private func reportIssueHelper( _ message: String = "", overrideExhaustivity exhaustivity: Exhaustivity? = nil, - file: StaticString, - line: UInt + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt ) { let exhaustivity = exhaustivity ?? self.exhaustivity switch exhaustivity { case .on: - XCTFail(message, file: file, line: line) - case .off(showSkippedAssertions: true): - _XCTExpectFailure { - XCTFail( - """ - Skipped assertions: … + reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) + case let .off(showSkippedAssertions): + if showSkippedAssertions { + withExpectedIssue { + reportIssue( + """ + Skipped assertions: … - \(message) - """, - file: file, - line: line - ) + \(message) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } } - case .off(showSkippedAssertions: false): - break } } } -/// The type returned from ``TestStore/send(_:assert:file:line:)-1ax61`` that represents the +/// The type returned from ``TestStore/send(_:assert:fileID:file:line:column:)-8f2pl`` that represents the /// lifecycle of the effect started from sending an action. /// /// You can use this value in tests to cancel the effect started from sending an action: @@ -1801,21 +2416,21 @@ extension TestStore { /// store.send(.startTimerButtonTapped) /// /// await mainQueue.advance(by: .seconds(1)) -/// await store.receive(.timerTick) { $0.elapsed = 1 } +/// await store.receive(\.timerTick) { $0.elapsed = 1 } /// /// // Wait for cleanup effects to finish before completing the test /// await store.send(.stopTimerButtonTapped).finish() /// ``` /// -/// See ``TestStore/finish(timeout:file:line:)-7pmv3`` for the ability to await all in-flight -/// effects in the test store. +/// See ``TestStore/finish(timeout:fileID:file:line:column:)-klnc`` for the ability to await all +/// in-flight effects in the test store. /// -/// See ``ViewStoreTask`` for the analog provided to ``ViewStore``. -struct TestStoreTask: Hashable, Sendable { +/// See ``StoreTask`` for the analog provided to ``Store``. +public struct TestStoreTask: Hashable, Sendable { fileprivate let rawValue: Task? fileprivate let timeout: UInt64 - init(rawValue: Task?, timeout: UInt64) { + @_spi(Canary) public init(rawValue: Task?, timeout: UInt64) { self.rawValue = rawValue self.timeout = timeout } @@ -1823,8 +2438,8 @@ struct TestStoreTask: Hashable, Sendable { /// Cancels the underlying task and waits for it to finish. /// /// This can be handy when a feature needs to start a long-living effect when the feature appears, - /// but cancellation of that effect is handled by the parent when the feature disappears. Such - /// a feature is difficult to exhaustively test in isolation because there is no action in its + /// but cancellation of that effect is handled by the parent when the feature disappears. Such a + /// feature is difficult to exhaustively test in isolation because there is no action in its /// domain that cancels the effect: /// /// ```swift @@ -1835,35 +2450,41 @@ struct TestStoreTask: Hashable, Sendable { /// /// await onAppearTask.cancel() // ✅ Cancel the task to simulate the feature disappearing. /// ``` - func cancel() async { + public func cancel() async { self.rawValue?.cancel() await self.rawValue?.cancellableValue } - // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. - // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 - #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) - /// Asserts the underlying task finished. - /// - /// - Parameter duration: The amount of time to wait before asserting. - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - func finish( - timeout duration: Duration, - file: StaticString = #file, - line: UInt = #line - ) async { - await self.finish(timeout: duration.nanoseconds, file: file, line: line) - } - #endif + /// Asserts the underlying task finished. + /// + /// - Parameter duration: The amount of time to wait before asserting. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func finish( + timeout duration: Duration, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + await self.finish( + timeout: duration.nanoseconds, + fileID: fileID, + file: filePath, + line: line, + column: column + ) + } /// Asserts the underlying task finished. /// /// - Parameter nanoseconds: The amount of time to wait before asserting. @_disfavoredOverload - func finish( + public func finish( timeout nanoseconds: UInt64? = nil, - file: StaticString = #file, - line: UInt = #line + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) async { let nanoseconds = nanoseconds ?? self.timeout await Task.megaYield() @@ -1893,15 +2514,17 @@ struct TestStoreTask: Hashable, Sendable { \(timeoutMessage). """ - XCTFail( + reportIssue( """ Expected task to finish, but it is still in-flight\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, - file: file, - line: line + fileID: fileID, + filePath: filePath, + line: line, + column: column ) } } @@ -1910,17 +2533,18 @@ struct TestStoreTask: Hashable, Sendable { /// /// After the value of this property becomes `true`, it remains `true` indefinitely. There is /// no way to uncancel a task. - var isCancelled: Bool { + public var isCancelled: Bool { self.rawValue?.isCancelled ?? true } } -class TestReducer: ReducerProtocol { +class TestReducer: Reducer { let base: Reduce - let effectDidSubscribe = AsyncStream.streamWithContinuation() + let effectDidSubscribe = AsyncStream.makeStream(of: Void.self) var inFlightEffects: Set = [] var receivedActions: [(action: Action, state: State)] = [] var state: State + weak var store: TestStore? init( _ base: Reduce, @@ -1930,10 +2554,10 @@ class TestReducer: ReducerProtocol { self.state = initialState } - func reduce(into state: inout State, action: TestAction) -> EffectTask { + func reduce(into state: inout State, action: TestAction) -> Effect { let reducer = self.base - let effects: EffectTask + let effects: Effect switch action.origin { case let .send(action): effects = reducer.reduce(into: &state, action: action) @@ -1951,21 +2575,33 @@ class TestReducer: ReducerProtocol { case .publisher, .run: let effect = LongLivingEffect(action: action) - return - effects - .handleEvents( - receiveSubscription: { [effectDidSubscribe, weak self] _ in - self?.inFlightEffects.insert(effect) - Task { - await Task.megaYield() - effectDidSubscribe.continuation.yield() + return .publisher { [effectDidSubscribe, weak self] in + _EffectPublisher(effects) + .handleEvents( + receiveSubscription: { _ in + self?.inFlightEffects.insert(effect) + Task { + await Task.megaYield() + effectDidSubscribe.continuation.yield() + } + }, + receiveCompletion: { [weak self] _ in + self?.inFlightEffects.remove(effect) + }, + receiveCancel: { [weak self] in + self?.inFlightEffects.remove(effect) } - }, - receiveCompletion: { [weak self] _ in self?.inFlightEffects.remove(effect) }, - receiveCancel: { [weak self] in self?.inFlightEffects.remove(effect) } - ) - .map { .init(origin: .receive($0), file: action.file, line: action.line) } - .eraseToEffect() + ) + .map { + .init( + origin: .receive($0), + fileID: action.fileID, + filePath: action.filePath, + line: action.line, + column: action.column + ) + } + } } } @@ -1984,8 +2620,14 @@ class TestReducer: ReducerProtocol { struct TestAction { let origin: Origin - let file: StaticString + let fileID: StaticString + let filePath: StaticString let line: UInt + let column: UInt + + fileprivate var action: Action { + self.origin.action + } enum Origin { case receive(Action) @@ -2000,31 +2642,16 @@ class TestReducer: ReducerProtocol { } } -extension Task where Success == Never, Failure == Never { - // NB: We would love if this was not necessary, but due to a lack of async testing tools in Swift - // we're not sure if there is an alternative. See this forum post for more information: - // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 - static func megaYield(count: Int = 10) async { - for _ in 1...count { - await Task.detached(priority: .background) { await Task.yield() }.value - } +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +extension Duration { + fileprivate var nanoseconds: UInt64 { + UInt64(self.components.seconds) * NSEC_PER_SEC + + UInt64(self.components.attoseconds) / 1_000_000_000 } } -// NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. -// See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 -#if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) - @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) - extension Duration { - fileprivate var nanoseconds: UInt64 { - UInt64(self.components.seconds) * NSEC_PER_SEC - + UInt64(self.components.attoseconds) / 1_000_000_000 - } - } -#endif - /// The exhaustivity of assertions made by the test store. -enum Exhaustivity: Equatable { +public enum Exhaustivity: Equatable, Sendable { /// Exhaustive assertions. /// /// This setting requires you to exhaustively assert on all state changes and all actions received @@ -2032,11 +2659,13 @@ enum Exhaustivity: Equatable { /// deallocated. /// /// To manually skip actions or effects, use - /// ``TestStore/skipReceivedActions(strict:file:line:)-a4ri`` or - /// ``TestStore/skipInFlightEffects(strict:file:line:)-5hbsk``. + /// ``TestStore/skipReceivedActions(strict:fileID:file:line:column:)`` or + /// ``TestStore/skipInFlightEffects(strict:fileID:file:line:column:)``. /// /// To partially match an action received from an effect, use - /// ``TestStore/receive(_:timeout:assert:file:line:)-4e4m0``. + /// ``TestStore/receive(_:timeout:assert:fileID:file:line:column:)-53wic`` or + /// ``TestStore/receive(_:timeout:assert:fileID:file:line:column:)-35638``. + case on /// Non-exhaustive assertions. @@ -2054,171 +2683,43 @@ enum Exhaustivity: Equatable { case off(showSkippedAssertions: Bool) /// Non-exhaustive assertions. - static let off = Self.off(showSkippedAssertions: false) -} - -@_transparent -private func _XCTExpectFailure( - _ failureReason: String? = nil, - strict: Bool = true, - failingBlock: () -> Void -) { - #if DEBUG - guard - let XCTExpectedFailureOptions = NSClassFromString("XCTExpectedFailureOptions") - as Any as? NSObjectProtocol, - let options = strict - ? XCTExpectedFailureOptions - .perform(NSSelectorFromString("alloc"))?.takeUnretainedValue() - .perform(NSSelectorFromString("init"))?.takeUnretainedValue() - : XCTExpectedFailureOptions - .perform(NSSelectorFromString("nonStrictOptions"))?.takeUnretainedValue() - else { return } - - let XCTExpectFailureWithOptionsInBlock = unsafeBitCast( - dlsym(dlopen(nil, RTLD_LAZY), "XCTExpectFailureWithOptionsInBlock"), - to: (@convention(c) (String?, AnyObject, () -> Void) -> Void).self - ) - - XCTExpectFailureWithOptionsInBlock(failureReason, options, failingBlock) - #endif -} - - - -extension AsyncStream { - static func streamWithContinuation( - _ elementType: Element.Type = Element.self, - bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded - ) -> (stream: Self, continuation: Continuation) { - var continuation: Continuation! - return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) - } -} - -enum TaskResultDebugging { - @TaskLocal static var emitRuntimeWarnings = true -} - -extension String { - @usableFromInline - func indent(by indent: Int) -> String { - let indentation = String(repeating: " ", count: indent) - return indentation + self.replacingOccurrences(of: "\n", with: "\n\(indentation)") - } -} - -/// A type-erased reducer that invokes the given `reduce` function. -/// -/// ``Reduce`` is useful for injecting logic into a reducer tree without the overhead of introducing -/// a new type that conforms to ``ReducerProtocol``. -struct Reduce: ReducerProtocol { - @usableFromInline - let reduce: (inout State, Action) -> EffectTask - - @usableFromInline - init( - internal reduce: @escaping (inout State, Action) -> EffectTask - ) { - self.reduce = reduce - } - - /// Initializes a reducer with a `reduce` function. - /// - /// - Parameter reduce: A function that is called when ``reduce(into:action:)`` is invoked. - @inlinable - init(_ reduce: @escaping (inout State, Action) -> EffectTask) { - self.init(internal: reduce) - } - - /// Type-erases a reducer. - /// - /// - Parameter reducer: A reducer that is called when ``reduce(into:action:)`` is invoked. - @inlinable - init(_ reducer: R) - where R.State == State, R.Action == Action { - self.init(internal: reducer.reduce) - } - - @inlinable - func reduce(into state: inout State, action: Action) -> EffectTask { - self.reduce(&state, action) - } -} - -// ND: Pulled from recent version of TCA to address test threading issues. - -#if !os(WASI) && !os(Windows) && !os(Android) -private typealias Original = @convention(thin) (UnownedJob) -> Void -private typealias Hook = @convention(thin) (UnownedJob, Original) -> Void - -public var uncheckedUseMainSerialExecutor: Bool { - get { swift_task_enqueueGlobal_hook != nil } - set { - swift_task_enqueueGlobal_hook = - newValue - ? { job, _ in MainActor.shared.enqueue(job) } - : nil - } + public static let off = Self.off(showSkippedAssertions: false) } -private var swift_task_enqueueGlobal_hook: Hook? { - get { _swift_task_enqueueGlobal_hook.wrappedValue.pointee } - set { _swift_task_enqueueGlobal_hook.wrappedValue.pointee = newValue } - } - private let _swift_task_enqueueGlobal_hook = UncheckedSendable( - dlsym(dlopen(nil, 0), "swift_task_enqueueGlobal_hook").assumingMemoryBound(to: Hook?.self) +extension TestStore { + @available( + *, + unavailable, + message: + "Provide a key path to the case you expect to receive (like 'store.receive(\\.tap)'), or conform 'Action' to 'Equatable' to assert against it directly." ) - -#endif - -func mainActorNow(execute block: @MainActor @Sendable () -> R) -> R { - if DispatchQueue.getSpecific(key: key) == value { - return MainActor._assumeIsolated { - block() - } - } else { - return DispatchQueue.main.sync { - MainActor._assumeIsolated { - block() - } - } + public func receive( + _ expectedAction: Action, + assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async { + fatalError() } } -private let key: DispatchSpecificKey = { - let key = DispatchSpecificKey() - DispatchQueue.main.setSpecific(key: key, value: value) - return key -}() -private let value: UInt8 = 0 - - -extension MainActor { - // NB: This functionality was not back-deployed in Swift 5.9 - static func _assumeIsolated( - _ operation: @MainActor () throws -> T, - file: StaticString = #fileID, - line: UInt = #line - ) rethrows -> T { - #if swift(<5.10) - typealias YesActor = @MainActor () throws -> T - typealias NoActor = () throws -> T - - guard Thread.isMainThread else { - fatalError( - "Incorrect actor executor assumption; Expected same executor as \(self).", - file: file, - line: line - ) - } - - return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in - let rawFn = unsafeBitCast(fn, to: NoActor.self) - return try rawFn() - } - #else - return try assumeIsolated(operation, file: file, line: line) - #endif - } +// TODO: Move to `swift-issue-reporting`? +private func _withIssueContext( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt, + @_inheritActorContext operation: () async throws -> R +) async rethrows -> R { + let result = try await withIssueContext( + fileID: fileID, + filePath: filePath, + line: line, + column: column, + operation: operation + ) + await Task.yield() + return result } diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift new file mode 100644 index 00000000..5674c5b8 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift @@ -0,0 +1,106 @@ +import XCTestDynamicOverlay +@testable import KlaviyoSDKDependencies + +@available(*, deprecated, renamed: "expectDifference") +public func XCTAssertDifference( + _ expression: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + operation: () throws -> Void = {}, + changes updateExpectingResult: (inout T) throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) where T: Equatable { + do { + var expression1 = try expression() + try updateExpectingResult(&expression1) + try operation() + let expression2 = try expression() + let message = message() + guard expression1 != expression2 else { return } + let format = DiffFormat.proportional + guard let difference = diff(expression1, expression2, format: format) + else { + XCTFail( + """ + XCTAssertDifference failed: ("\(expression1)" is not equal to ("\(expression2)"), but no \ + difference was detected. + """, + file: file, + line: line + ) + return + } + let failure = """ + XCTAssertDifference failed: … + + \(difference.indenting(by: 2)) + + (Expected: \(format.first), Actual: \(format.second)) + """ + XCTFail( + "\(failure)\(message.isEmpty ? "" : " - \(message)")", + file: file, + line: line + ) + } catch { + XCTFail( + """ + XCTAssertDifference failed: threw error "\(error)" + """, + file: file, + line: line + ) + } +} + +@available(*, deprecated, renamed: "expectDifference") +public func XCTAssertDifference( + _ expression: @autoclosure @Sendable () throws -> T, + _ message: @autoclosure @Sendable () -> String = "", + operation: @Sendable () async throws -> Void = {}, + changes updateExpectingResult: @Sendable (inout T) throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async where T: Equatable { + do { + var expression1 = try expression() + try updateExpectingResult(&expression1) + try await operation() + let expression2 = try expression() + let message = message() + guard expression1 != expression2 else { return } + let format = DiffFormat.proportional + guard let difference = diff(expression1, expression2, format: format) + else { + XCTFail( + """ + XCTAssertDifference failed: ("\(expression1)" is not equal to ("\(expression2)"), but no \ + difference was detected. + """, + file: file, + line: line + ) + return + } + let failure = """ + XCTAssertDifference failed: … + + \(difference.indenting(by: 2)) + + (Expected: \(format.first), Actual: \(format.second)) + """ + XCTFail( + "\(failure)\(message.isEmpty ? "" : " - \(message)")", + file: file, + line: line + ) + } catch { + XCTFail( + """ + XCTAssertDifference failed: threw error "\(error)" + """, + file: file, + line: line + ) + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift new file mode 100644 index 00000000..4aae00e0 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift @@ -0,0 +1,54 @@ +import XCTestDynamicOverlay +@testable import KlaviyoSDKDependencies + +@available(*, deprecated, renamed: "expectNoDifference") +public func XCTAssertNoDifference( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) where T: Equatable { + do { + let expression1 = try expression1() + let expression2 = try expression2() + let message = message() + guard expression1 != expression2 else { return } + let format = DiffFormat.proportional + guard let difference = diff(expression1, expression2, format: format) + else { + XCTFail( + """ + XCTAssertNoDifference failed: An unexpected failure occurred. Please report the issue to https://github.com/pointfreeco/swift-custom-dump … + + ("\(expression1)" is not equal to ("\(expression2)") + + But no difference was detected. + """, + file: file, + line: line + ) + return + } + let failure = """ + XCTAssertNoDifference failed: … + + \(difference.indenting(by: 2)) + + (First: \(format.first), Second: \(format.second)) + """ + XCTFail( + "\(failure)\(message.isEmpty ? "" : " - \(message)")", + file: file, + line: line + ) + } catch { + XCTFail( + """ + XCTAssertNoDifference failed: threw error "\(error)" + """, + file: file, + line: line + ) + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift new file mode 100644 index 00000000..5e022407 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift @@ -0,0 +1,72 @@ +import Foundation +@_spi(CurrentTestCase) import XCTestDynamicOverlay +@testable import KlaviyoSDKDependencies + +/// Asserts that an enum value matches a particular case and modifies the associated value in place. +@available(*, deprecated, message: "Use 'CasePathable.modify' to mutate an expected case, instead.") +public func XCTModify( + _ enum: inout Enum, + case keyPath: CaseKeyPath, + _ message: @autoclosure () -> String = "", + _ body: (inout Case) throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) { + _XCTModify(&`enum`, case: AnyCasePath(keyPath), message(), body, file: file, line: line) +} + +func _XCTModify( + _ enum: inout Enum, + case casePath: AnyCasePath, + _ message: @autoclosure () -> String = "", + _ body: (inout Case) throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) { + guard var value = casePath.extract(from: `enum`) + else { + #if canImport(ObjectiveC) + _ = XCTCurrentTestCase?.perform(Selector(("setContinueAfterFailure:")), with: false) + #endif + let message = message() + XCTFail( + """ + XCTModify: Expected to extract value of type "\(typeName(Case.self))" from \ + "\(typeName(Enum.self))"\ + \(message.isEmpty ? "" : " - " + message) … + + Actual: + \(`enum`) + """, + file: file, + line: line + ) + return + } + let before = value + do { + try body(&value) + } catch { + XCTFail("Threw error: \(error)", file: file, line: line) + return + } + + if XCTModifyLocals.isExhaustive, + let isEqual = _isEqual(before, value), + isEqual + { + XCTFail( + """ + XCTModify: Expected "\(typeName(Case.self))" value to be modified but it was unchanged. + """ + ) + } + + `enum` = casePath.embed(value) +} + +@_spi(Internals) public enum XCTModifyLocals { + @TaskLocal public static var isExhaustive = true +} + +struct UnwrappingCase: Error {} diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift new file mode 100644 index 00000000..2a827492 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift @@ -0,0 +1,158 @@ +#if canImport(CoreLocation) + import CoreLocation + import KlaviyoSDKDependencies + + #if compiler(>=5.4) + extension CLAccuracyAuthorization: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .fullAccuracy: + return "CLAccuracyAuthorization.fullAccuracy" + case .reducedAccuracy: + return "CLAccuracyAuthorization.reducedAccuracy" + @unknown default: + return "CLAccuracyAuthorization.(@unknown default, rawValue: \(self.rawValue))" + } + } + } + #endif + + extension CLActivityType: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .airborne: + return "CLActivityType.airborne" + case .automotiveNavigation: + return "CLActivityType.automotiveNavigation" + case .other: + return "CLActivityType.other" + case .fitness: + return "CLActivityType.fitness" + case .otherNavigation: + return "CLActivityType.otherNavigation" + @unknown default: + return "CLActivityType.(@unknown default, rawValue: \(self.rawValue))" + } + } + } + + extension CLAuthorizationStatus: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .authorizedAlways: + return "CLAuthorizationStatus.authorizedAlways" + case .authorizedWhenInUse: + return "CLAuthorizationStatus.authorizedWhenInUse" + case .denied: + return "CLAuthorizationStatus.denied" + case .notDetermined: + return "CLAuthorizationStatus.notDetermined" + case .restricted: + return "CLAuthorizationStatus.restricted" + @unknown default: + return "CLAuthorizationStatus.(@unknown default, rawValue: \(self.rawValue))" + } + } + } + + extension CLDeviceOrientation: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .faceUp: + return "CLDeviceOrientation.faceUp" + case .faceDown: + return "CLDeviceOrientation.faceDown" + case .landscapeLeft: + return "CLDeviceOrientation.landscapeLeft" + case .landscapeRight: + return "CLDeviceOrientation.landscapeRight" + case .portrait: + return "CLDeviceOrientation.portrait" + case .portraitUpsideDown: + return "CLDeviceOrientation.portraitUpsideDown" + case .unknown: + return "CLDeviceOrientation.unknown" + @unknown default: + return "CLDeviceOrientation.(@unknown default, rawValue: \(self.rawValue))" + } + } + } + + #if compiler(>=5.9) + @available(iOS 7, macOS 10.15, *) + @available(tvOS, unavailable) + @available(visionOS, unavailable) + @available(watchOS, unavailable) + extension CLProximity: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .far: + return "CLProximity.far" + case .immediate: + return "CLProximity.immediate" + case .near: + return "CLProximity.near" + case .unknown: + return "CLProximity.unknown" + @unknown default: + return "CLProximity.(@unknown default, rawValue: \(self.rawValue))" + } + } + } + #elseif compiler(>=5.3) + @available(iOS 7, macOS 10.15, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension CLProximity: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .far: + return "CLProximity.far" + case .immediate: + return "CLProximity.immediate" + case .near: + return "CLProximity.near" + case .unknown: + return "CLProximity.unknown" + @unknown default: + return "CLProximity.(@unknown default, rawValue: \(self.rawValue))" + } + } + } + #endif + + #if compiler(>=5.9) + @available(iOS 7, macOS 10, *) + @available(tvOS, unavailable) + @available(visionOS, unavailable) + @available(watchOS, unavailable) + extension CLRegionState: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .inside: + return "CLRegionState.inside" + case .outside: + return "CLRegionState.outside" + case .unknown: + return "CLRegionState.unknown" + } + } + } + #else + @available(iOS 7, macOS 10, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension CLRegionState: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { + case .inside: + return "CLRegionState.inside" + case .outside: + return "CLRegionState.outside" + case .unknown: + return "CLRegionState.unknown" + } + } + } + #endif +#endif diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift new file mode 100644 index 00000000..5d75a885 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift @@ -0,0 +1,324 @@ +import Foundation +import KlaviyoSDKDependencies + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +// NB: Xcode 13 does not include macOS 12 SDK +// NB: Swift 5.5 does not include AttributedString on other platforms (yet) +#if compiler(>=5.5) && !targetEnvironment(macCatalyst) && (os(iOS) || os(tvOS) || os(watchOS)) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension AttributedString: CustomDumpRepresentable { + public var customDumpValue: Any { + NSAttributedString(self).string + } + } +#endif + +extension Calendar: CustomDumpReflectable { + public var customDumpMirror: Mirror { + .init( + self, + children: [ + "identifier": self.identifier, + "locale": self.locale as Any, + "timeZone": self.timeZone, + "firstWeekday": self.firstWeekday, + "minimumDaysInFirstWeek": self.minimumDaysInFirstWeek, + ], + displayStyle: .struct + ) + } +} + +#if !os(WASI) + extension Data: CustomDumpStringConvertible { + public var customDumpDescription: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = .useBytes + return "Data(\(formatter.string(fromByteCount: .init(self.count))))" + } + } +#endif + +#if !os(WASI) + extension Date: CustomDumpStringConvertible { + public var customDumpDescription: String { + "Date(\(Self.formatter.string(from: self)))" + } + + private static var formatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + formatter.timeZone = TimeZone(secondsFromGMT: 0)! + return formatter + } + } +#endif + +extension Decimal: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.description + } +} + +extension Locale: CustomDumpStringConvertible { + public var customDumpDescription: String { + "Locale(\(self.identifier))" + } +} + +extension NSAttributedString: CustomDumpRepresentable { + public var customDumpValue: Any { + self.string + } +} + +extension NSCalendar: CustomDumpRepresentable { + public var customDumpValue: Any { + self as Calendar + } +} + +extension NSData: CustomDumpRepresentable { + public var customDumpValue: Any { + self as Data + } +} + +extension NSDate: CustomDumpRepresentable { + public var customDumpValue: Any { + self as Date + } +} + +extension NSError: CustomDumpReflectable { + public var customDumpMirror: Mirror { + let swiftError = self as Error + guard type(of: swiftError) is NSError.Type else { + return Mirror(reflecting: swiftError) + } + return Mirror( + self, + children: [ + "domain": self.domain, + "code": self.code, + "userInfo": self.userInfo, + ], + displayStyle: .class + ) + } +} + +// NB: `NSException` in unavailable on Linux +#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + extension NSException: CustomDumpReflectable { + public var customDumpMirror: Mirror { + .init( + self, + children: [ + "name": self.name, + "reason": self.reason as Any, + "userInfo": self.userInfo as Any, + ], + displayStyle: .class + ) + } + } +#endif + +extension NSExceptionName: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.rawValue + } +} + +// NB: `NSExpression` in unavailable on Linux +#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + extension NSExpression: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.debugDescription + } + } +#endif + +extension NSIndexPath: CustomDumpRepresentable { + public var customDumpValue: Any { + self as IndexPath + } +} + +extension NSIndexSet: CustomDumpRepresentable { + public var customDumpValue: Any { + self as IndexSet + } +} + +extension NSLocale: CustomDumpRepresentable { + public var customDumpValue: Any { + self as Locale + } +} + +@available(iOS 10, macOS 10.12, tvOS 10, watchOS 3, *) +extension NSMeasurement: CustomDumpRepresentable { + public var customDumpValue: Any { + self as Measurement + } +} + +#if !os(WASI) + extension NSNotification: CustomDumpRepresentable { + public var customDumpValue: Any { + self as Notification + } + } +#endif + +extension NSOrderedSet: CustomDumpReflectable { + public var customDumpMirror: Mirror { + .init( + self, + unlabeledChildren: self.array, + displayStyle: .collection + ) + } +} + +extension NSPredicate: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.debugDescription + } +} + +extension NSRange: CustomDumpRepresentable { + public var customDumpValue: Any { + Range(self) as Any + } +} + +extension NSString: CustomDumpRepresentable { + public var customDumpValue: Any { + self as String + } +} + +extension NSTimeZone: CustomDumpRepresentable { + public var customDumpValue: Any { + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + return self as TimeZone + #else + // NB: Cannot cast directly to `TimeZone` on Linux + return TimeZone(identifier: self.name) as Any + #endif + } +} + +extension NSURL: CustomDumpRepresentable { + public var customDumpValue: Any { + self as URL + } +} + +extension NSURLComponents: CustomDumpRepresentable { + public var customDumpValue: Any { + self as URLComponents + } +} + +extension NSURLQueryItem: CustomDumpRepresentable { + public var customDumpValue: Any { + self as URLQueryItem + } +} + +#if !os(WASI) + extension NSURLRequest: CustomDumpRepresentable { + public var customDumpValue: Any { + self as URLRequest + } + } +#endif + +extension NSUUID: CustomDumpRepresentable { + public var customDumpValue: Any { + self as UUID + } +} + +extension NSValue: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.debugDescription + } +} + +extension TimeZone: CustomDumpReflectable { + public var customDumpMirror: Mirror { + .init( + self, + children: [ + "identifier": self.identifier, + "abbreviation": self.abbreviation() as Any, + "secondsFromGMT": self.secondsFromGMT(), + "isDaylightSavingTime": self.isDaylightSavingTime(), + ], + displayStyle: .struct + ) + } +} + +extension URL: CustomDumpStringConvertible { + public var customDumpDescription: String { + "URL(\(self.absoluteString))" + } +} + +#if !os(WASI) + extension URLRequest.NetworkServiceType: CustomDumpStringConvertible { + public var customDumpDescription: String { + switch self { #if canImport(FoundationNetworking) + case .background: + return "URLRequest.NetworkServiceType.background" + case .default: + return "URLRequest.NetworkServiceType.default" + case .networkServiceTypeCallSignaling: + return "URLRequest.NetworkServiceType.networkServiceTypeCallSignaling" + case .video: + return "URLRequest.NetworkServiceType.video" + case .voice: + return "URLRequest.NetworkServiceType.voice" + case .voip: + return "URLRequest.NetworkServiceType.voip" + #else + case .avStreaming: + return "URLRequest.NetworkServiceType.avStreaming" + case .background: + return "URLRequest.NetworkServiceType.background" + case .callSignaling: + return "URLRequest.NetworkServiceType.callSignaling" + case .default: + return "URLRequest.NetworkServiceType.default" + case .responsiveAV: + return "URLRequest.NetworkServiceType.responsiveAV" + case .responsiveData: + return "URLRequest.NetworkServiceType.responsiveData" + case .video: + return "URLRequest.NetworkServiceType.video" + case .voice: + return "URLRequest.NetworkServiceType.voice" + case .voip: + return "URLRequest.NetworkServiceType.voip" + @unknown default: + return "URLRequest.NetworkServiceType.(@unknown default, rawValue: \(self.rawValue))" + #endif + } + } + } +#endif + +extension UUID: CustomDumpStringConvertible { + public var customDumpDescription: String { + "UUID(\(self.uuidString))" + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift new file mode 100644 index 00000000..2700b5d1 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import KlaviyoSDKDependencies + +extension AnyKeyPath: CustomDumpStringConvertible { + public var customDumpDescription: String { + if #available(macOS 13.3, iOS 16.4, watchOS 9.4, tvOS 16.4, *) { + return self.debugDescription + } + return """ + \(typeName(Self.self))<\ + \(typeName(Self.rootType, genericsAbbreviated: false)), \ + \(typeName(Self.valueType, genericsAbbreviated: false))> + """ + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift new file mode 100644 index 00000000..cdab7cbd --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift @@ -0,0 +1,47 @@ +import Foundation +import KlaviyoSDKDependencies + +extension Character: CustomDumpRepresentable { + public var customDumpValue: Any { + String(self) + } +} + +#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) + extension Duration: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.formatted( + .units( + allowed: [.days, .hours, .minutes, .seconds, .milliseconds, .microseconds, .nanoseconds], + width: .wide + ) + ) + } + } +#endif + +extension ObjectIdentifier: CustomDumpStringConvertible { + public var customDumpDescription: String { + self.debugDescription + .replacingOccurrences(of: "0x0*", with: "0x", options: .regularExpression) + } +} + +extension StaticString: CustomDumpRepresentable { + public var customDumpValue: Any { + "\(self)" + } +} + +extension UnicodeScalar: CustomDumpRepresentable { + public var customDumpValue: Any { + String(self) + } +} + +extension AnyHashable: CustomDumpRepresentable { + public var customDumpValue: Any { + base + } +} diff --git a/Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift b/Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift new file mode 100644 index 00000000..768e6b7b --- /dev/null +++ b/Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift @@ -0,0 +1,6 @@ +// +// Collections.swift +// klaviyo-swift-sdk +// +// Created by Noah Durell on 10/24/24. +// From b91c5757c8d430443df8321b1dfe82172538c40a Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Tue, 10 Dec 2024 13:57:22 -0500 Subject: [PATCH 2/9] precommit run --- .../Development Assets/JSTestWebViewModel.swift | 4 ++-- Tests/KlaviyoSwiftTests/StateManagementTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift index bc794379..5f9faf03 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift @@ -16,8 +16,8 @@ class JSTestWebViewModel: @preconcurrency KlaviyoWebViewModeling { let loadScripts: [String: WKUserScript]? /// Publishes scripts for the `WKWebView` to execute. - private var continuation: AsyncStream<(script: String, callback: (@Sendable (Result) -> Void)?)>.Continuation? - lazy var scriptStream: AsyncStream<(script: String, callback: (@Sendable (Result) -> Void)?)> = AsyncStream { [weak self] continuation in + private var continuation: AsyncStream < (script: String, callback: (@Sendable (Result) -> Void)?)>.Continuation? + lazy var scriptStream: AsyncStream < (script: String, callback: (@Sendable (Result) -> Void)?)> = AsyncStream { [weak self] continuation in self?.continuation = continuation } diff --git a/Tests/KlaviyoSwiftTests/StateManagementTests.swift b/Tests/KlaviyoSwiftTests/StateManagementTests.swift index f45bbac1..ec378d9f 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementTests.swift @@ -5,11 +5,11 @@ // Created by Noah Durell on 12/6/22. // +@testable import KlaviyoCore +@testable import KlaviyoSDKDependencies @testable import KlaviyoSwift import Combine import Foundation -@testable import KlaviyoCore -@testable import KlaviyoSDKDependencies import XCTest #if swift(>=6) From 54b98989ef116ed1fcafc75332d9fa2cc0a29e01 Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 11 Dec 2024 16:46:25 -0500 Subject: [PATCH 3/9] restrict jstestwebviewmodel to swift 6 --- .../KlaviyoWebView/Development Assets/JSTestWebViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift index 5f9faf03..950eb6de 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/Development Assets/JSTestWebViewModel.swift @@ -6,6 +6,7 @@ // #if DEBUG +#if swift(>=6) import Combine import Foundation import WebKit @@ -73,3 +74,4 @@ class JSTestWebViewModel: @preconcurrency KlaviyoWebViewModeling { } } #endif +#endif From a3aff9ecb227b713e3a1819c6d775ed2ba7ab61b Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 11 Dec 2024 16:52:49 -0500 Subject: [PATCH 4/9] hide the preview too --- .../KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift index 5eadedc4..45c6725b 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewController.swift @@ -172,6 +172,7 @@ func createKlaviyoWebPreview(viewModel: KlaviyoWebViewModeling) -> UIViewControl return createKlaviyoWebPreview(viewModel: viewModel) } +#if swift(>=6.0) @available(iOS 17.0, *) #Preview("JS Test Page") { let indexHtmlFileUrl = Bundle.module.url(forResource: "jstest", withExtension: "html")! @@ -179,3 +180,5 @@ func createKlaviyoWebPreview(viewModel: KlaviyoWebViewModeling) -> UIViewControl return KlaviyoWebViewController(viewModel: viewModel) } #endif + +#endif From 798b3c9e35da3ea0efdc7e6ce9d8191571df503e Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 18 Dec 2024 13:22:49 -0500 Subject: [PATCH 5/9] fix dispatch call --- Sources/KlaviyoSwift/Klaviyo.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KlaviyoSwift/Klaviyo.swift b/Sources/KlaviyoSwift/Klaviyo.swift index 0df6c659..858ed096 100644 --- a/Sources/KlaviyoSwift/Klaviyo.swift +++ b/Sources/KlaviyoSwift/Klaviyo.swift @@ -222,6 +222,6 @@ extension KlaviyoSDK { /// - warning: For internal use only. The host app should not manually call this method, as /// the logic for fetching and displaying forms will be handled internally within the SDK. public func fetchForms() { - dispatchOnMainThread(action: .fetchForms) + dispatchStoreAction(action: .fetchForms) } } From 54e8e62381b576e961b6592a7b36a9e5f602f183 Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 18 Dec 2024 14:12:58 -0500 Subject: [PATCH 6/9] fix tests and some more main actor stuff --- Package.swift | 2 +- Package@swift-6.0.swift | 2 +- Sources/KlaviyoCore/KlaviyoEnvironment.swift | 4 ++-- Sources/KlaviyoSwift/Klaviyo.swift | 4 +++- Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift | 7 +++++-- .../KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift | 2 +- Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift | 2 +- Tests/KlaviyoCoreTests/FileUtilsTests.swift | 2 +- Tests/KlaviyoCoreTests/KlaviyoAPITests.swift | 2 +- Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift | 2 +- Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift | 2 +- Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift | 2 +- Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift | 2 +- Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift | 2 +- Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift | 2 +- 15 files changed, 22 insertions(+), 17 deletions(-) diff --git a/Package.swift b/Package.swift index aaea7fd1..a827d5e7 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "klaviyo-swift-sdk", - platforms: [.iOS(.v15), .macOS(.v10_15)], + platforms: [.iOS(.v15)], products: [ .library( name: "KlaviyoSwift", diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index f934fcda..ee5ea040 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "klaviyo-swift-sdk", - platforms: [.iOS(.v15), .macOS(.v10_15)], + platforms: [.iOS(.v15)], products: [ .library( name: "KlaviyoSwift", diff --git a/Sources/KlaviyoCore/KlaviyoEnvironment.swift b/Sources/KlaviyoCore/KlaviyoEnvironment.swift index e11567af..24a35d4b 100644 --- a/Sources/KlaviyoCore/KlaviyoEnvironment.swift +++ b/Sources/KlaviyoCore/KlaviyoEnvironment.swift @@ -12,9 +12,9 @@ import UIKit // Though this is a var it should never be modified outside of tests. #if swift(>=5.10) -public nonisolated(unsafe) var environment = KlaviyoEnvironment.production +public internal(set) nonisolated(unsafe) var environment = KlaviyoEnvironment.production #else -public var environment = KlaviyoEnvironment.production +public internal(set) var environment = KlaviyoEnvironment.production #endif public struct KlaviyoEnvironment: Sendable { diff --git a/Sources/KlaviyoSwift/Klaviyo.swift b/Sources/KlaviyoSwift/Klaviyo.swift index 858ed096..11bfbff5 100644 --- a/Sources/KlaviyoSwift/Klaviyo.swift +++ b/Sources/KlaviyoSwift/Klaviyo.swift @@ -222,6 +222,8 @@ extension KlaviyoSDK { /// - warning: For internal use only. The host app should not manually call this method, as /// the logic for fetching and displaying forms will be handled internally within the SDK. public func fetchForms() { - dispatchStoreAction(action: .fetchForms) + Task { + await dispatchStoreAction(action: .fetchForms) + } } } diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift index 7971a63d..990bf19a 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift @@ -28,8 +28,10 @@ class KlaviyoWebViewModel: KlaviyoWebViewModeling { var scripts: [String: WKUserScript] = [:] if let closeHandlerScript = try? FileIO.getFileContents(path: "closeHandler", type: "js") { - let script = WKUserScript(source: closeHandlerScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) - scripts["closeHandler"] = script + Task { + let script = await WKUserScript(source: closeHandlerScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + scripts["closeHandler"] = script + } } return scripts @@ -41,6 +43,7 @@ class KlaviyoWebViewModel: KlaviyoWebViewModeling { // TODO: handle navigation events } + @MainActor func handleScriptMessage(_ message: WKScriptMessage) { if message.name == "closeHandler" { // TODO: handle close button tap diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift index fa67b0a8..e3766211 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModeling.swift @@ -19,5 +19,5 @@ protocol KlaviyoWebViewModeling { var scriptStream: AsyncStream < (script: String, callback: (@Sendable (Result) -> Void)?)> { get } func handleNavigationEvent(_ event: WKNavigationEvent) - func handleScriptMessage(_ message: WKScriptMessage) + @MainActor func handleScriptMessage(_ message: WKScriptMessage) } diff --git a/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift b/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift index aa873af1..12e18c2a 100644 --- a/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift +++ b/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift @@ -5,8 +5,8 @@ // Created by Noah Durell on 9/26/22. // +@testable import KlaviyoCore import Combine -import KlaviyoCore import XCTest @MainActor diff --git a/Tests/KlaviyoCoreTests/FileUtilsTests.swift b/Tests/KlaviyoCoreTests/FileUtilsTests.swift index 3c05dc4f..fbbe6ac4 100644 --- a/Tests/KlaviyoCoreTests/FileUtilsTests.swift +++ b/Tests/KlaviyoCoreTests/FileUtilsTests.swift @@ -5,7 +5,7 @@ // Created by Noah Durell on 9/29/22. // -import KlaviyoCore +@testable import KlaviyoCore import XCTest @MainActor diff --git a/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift b/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift index 8c4f18bf..ad95645d 100644 --- a/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift +++ b/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift @@ -5,7 +5,7 @@ // Created by Noah Durell on 11/16/22. // -import KlaviyoCore +@testable import KlaviyoCore import SnapshotTesting import XCTest diff --git a/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift b/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift index ce443190..003acb11 100644 --- a/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift +++ b/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift @@ -5,9 +5,9 @@ // Created by Noah Durell on 12/15/22. // +@testable import KlaviyoCore @testable import KlaviyoSwift import Foundation -import KlaviyoCore import XCTest let TIMEOUT_NANOSECONDS: UInt64 = 10_000_000_000 // 10 seconds diff --git a/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift b/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift index d7995510..58cac67a 100644 --- a/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift +++ b/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift @@ -5,10 +5,10 @@ // Created by Noah Durell on 12/15/22. // +@testable import KlaviyoCore @testable import KlaviyoSwift @preconcurrency import Combine // Will figure out a better way for this... import Foundation -import KlaviyoCore import XCTest @MainActor diff --git a/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift index e1e0de3e..13faf9c4 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift @@ -5,10 +5,10 @@ // Created by Noah Durell on 2/21/23. // +@testable import KlaviyoCore @testable import KlaviyoSDKDependencies @testable import KlaviyoSwift import Foundation -import KlaviyoCore import XCTest // MARK: - KlaviyoSDKTests diff --git a/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift index b610f05d..ac9ef13b 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift @@ -5,9 +5,9 @@ // Created by Noah Durell on 12/1/22. // +@testable import KlaviyoCore @testable import KlaviyoSwift import Foundation -import KlaviyoCore import KlaviyoSDKDependencies import SnapshotTesting import XCTest diff --git a/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift b/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift index c99b8033..599b72a7 100644 --- a/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift +++ b/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift @@ -1,7 +1,7 @@ +@testable import KlaviyoCore import XCTest @_spi(KlaviyoPrivate) @testable import KlaviyoSwift import Combine -import KlaviyoCore @MainActor class StateChangePublisherTests: XCTestCase { diff --git a/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift b/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift index 8ea2597c..a3874ce0 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift @@ -5,9 +5,9 @@ // Created by Noah Durell on 12/15/22. // +@testable import KlaviyoCore @testable import KlaviyoSwift import Foundation -import KlaviyoCore import XCTest @MainActor From 59e48fcfbce125d13b3f412a8f8eb7d4b52847e9 Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 18 Dec 2024 14:25:12 -0500 Subject: [PATCH 7/9] handle differently pre swift 6 --- Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift index 990bf19a..2bba5f78 100644 --- a/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift +++ b/Sources/KlaviyoUI/KlaviyoWebView/KlaviyoWebViewModel.swift @@ -28,10 +28,16 @@ class KlaviyoWebViewModel: KlaviyoWebViewModeling { var scripts: [String: WKUserScript] = [:] if let closeHandlerScript = try? FileIO.getFileContents(path: "closeHandler", type: "js") { + #if swift(<6.0) + let script = try WKUserScript(source: closeHandlerScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + scripts["closeHandler"] = script + + #else Task { let script = await WKUserScript(source: closeHandlerScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true) scripts["closeHandler"] = script } + #endif } return scripts From ca3e06e74466fe9f15d72fcd1253e77ccd1e5b52 Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 18 Dec 2024 15:55:14 -0500 Subject: [PATCH 8/9] update a bunch of headers --- .../IssueReporting/IssueReporter.swift | 3 +++ .../IssueReporters/RuntimeWarningReporter.swift | 2 -- .../Vendor/ComposableArchitecture/DispatchQueue.swift | 3 +++ .../Vendor/ComposableArchitecture/KeyPath+Sendable.swift | 3 +++ .../Vendor/ComposableArchitecture/OpenExistential.swift | 3 +++ .../Vendor/ComposableArchitecture/Reference.swift | 3 +++ .../Vendor/ComposableArchitecture/SharedChangeTracker.swift | 3 +++ .../Vendor/ComposableArchitecture/TaskResult.swift | 4 ++++ .../Vendor/ComposableArchitecture/TestStore.swift | 4 ++++ .../Vendor/ComposableArchitecture/XCTAssertDifference.swift | 3 +++ .../ComposableArchitecture/XCTAssertNoDifference.swift | 3 +++ .../Vendor/ComposableArchitecture/XCTestSupport.swift | 3 +++ .../Vendor/CustomDump/Conformances/CoreLocation.swift | 3 +++ .../Vendor/CustomDump/Conformances/Foundation.swift | 3 +++ .../Vendor/CustomDump/Conformances/KeyPath.swift | 3 +++ .../Vendor/CustomDump/Conformances/Swift.swift | 3 +++ .../Vendor/IdentifiedCollections/Collections.swift | 6 ------ 17 files changed, 47 insertions(+), 8 deletions(-) delete mode 100644 Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift index 466c70af..9b529f5e 100644 --- a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporter.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from Swift Issue Reporting v1.3.0 on 11/15/2024 +/// https://github.com/pointfreeco/swift-issue-reporting/tree/1.3.0 + /// A type that can report issues. public protocol IssueReporter: Sendable { /// Called when an issue is reported. diff --git a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift index 4026c52c..2238ab4f 100644 --- a/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift +++ b/Sources/KlaviyoSDKDependencies/IssueReporting/IssueReporters/RuntimeWarningReporter.swift @@ -25,8 +25,6 @@ extension IssueReporter where Self == _RuntimeWarningReporter { /// fault-level messages to the console. /// /// Use ``IssueReporter/runtimeWarning`` to create one of these values. -/// Copied verbatim from xctest dynamic overlay v1.3.0 on 11/15/2024 -/// https://github.com/pointfreeco/xctest-dynamic-overlay/tree/1.3.0 public struct _RuntimeWarningReporter: IssueReporter { #if canImport(os) diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift index 7a84a201..330c83ff 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/DispatchQueue.swift @@ -1,3 +1,6 @@ +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: added Klaviyo imports + import Dispatch @testable import KlaviyoSwift @testable import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift index d2cd999c..2ae3828b 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/KeyPath+Sendable.swift @@ -1,3 +1,6 @@ +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: added KlaviyoSDKDependencies import + import KlaviyoSDKDependencies #if compiler(>=6) diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift index 7ff69fe4..82315356 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/OpenExistential.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + // MARK: Equatable func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool? { diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift index c5db9108..c590d9d4 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/Reference.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + #if canImport(Combine) import Combine #endif diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift index dd9490a3..e4a7e731 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + @testable import KlaviyoSDKDependencies @testable import KlaviyoSwift diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift index d30a4f36..99504d61 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TaskResult.swift @@ -1,3 +1,7 @@ +/// Copied with changes from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: added KlaviyoSDKDependencies import + @testable import KlaviyoSDKDependencies /// A value that represents either a success or a failure. This type differs from Swift's `Result` diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift index 5be58838..2474f531 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift @@ -1,3 +1,7 @@ +/// Copied with changes from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 +/// Changes from TCA: added KlaviyoSDKDependencies import, also likely made similar changes as we done to main store and root store. + import Combine import Foundation @_spi(Internals) @testable import KlaviyoSwift diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift index 5674c5b8..319f2ad4 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertDifference.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + import XCTestDynamicOverlay @testable import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift index 4aae00e0..19c8405a 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTAssertNoDifference.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + import XCTestDynamicOverlay @testable import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift index 5e022407..1002daa1 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from TCA v1.16.1 on 11/14/2024 +/// https://github.com/pointfreeco/swift-composable-architecture/tree/1.16.1 + import Foundation @_spi(CurrentTestCase) import XCTestDynamicOverlay @testable import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift index 2a827492..962a53f8 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/CoreLocation.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from swift-custom-dump v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + #if canImport(CoreLocation) import CoreLocation import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift index 5d75a885..acc55d72 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Foundation.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from swift-custom-dump v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + import Foundation import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift index 2700b5d1..a82c9f5c 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/KeyPath.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from swift-custom-dump v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + import Foundation @testable import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift index cdab7cbd..ec598a3a 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/CustomDump/Conformances/Swift.swift @@ -1,3 +1,6 @@ +/// Copied verbatim from swift-custom-dump v1.3.2 on 11/15/2024 +/// https://github.com/pointfreeco/swift-custom-dump/tree/1.3.2 + import Foundation import KlaviyoSDKDependencies diff --git a/Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift b/Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift deleted file mode 100644 index 768e6b7b..00000000 --- a/Tests/KlaviyoSwiftTests/Vendor/IdentifiedCollections/Collections.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Collections.swift -// klaviyo-swift-sdk -// -// Created by Noah Durell on 10/24/24. -// From a556b42755e62fd05364bd0ed7b62469a7749471 Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Wed, 18 Dec 2024 15:57:58 -0500 Subject: [PATCH 9/9] added back function so we can say it's verbatim --- .../ComposableArchitecture/XCTestSupport.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift index 1002daa1..043353ba 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/XCTestSupport.swift @@ -5,6 +5,18 @@ import Foundation @_spi(CurrentTestCase) import XCTestDynamicOverlay @testable import KlaviyoSDKDependencies +/// Asserts that an enum value matches a particular case and modifies the associated value in place. +@available(*, deprecated, message: "Use 'CasePathable.modify' to mutate an expected case, instead.") +public func XCTModify( + _ optional: inout Wrapped?, + _ message: @autoclosure () -> String = "", + _ body: (inout Wrapped) throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTModify(&optional, case: \.some, message(), body, file: file, line: line) +} + /// Asserts that an enum value matches a particular case and modifies the associated value in place. @available(*, deprecated, message: "Use 'CasePathable.modify' to mutate an expected case, instead.") public func XCTModify(