diff --git a/.changeset/ignored-exception-types-watchos.md b/.changeset/ignored-exception-types-watchos.md new file mode 100644 index 000000000..168f7fdff --- /dev/null +++ b/.changeset/ignored-exception-types-watchos.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': patch +--- + +Fix `errorTrackingConfig.ignoredExceptionTypes` not applying to manually captured exceptions on watchOS and visionOS. diff --git a/.changeset/spm-package-traits.md b/.changeset/spm-package-traits.md new file mode 100644 index 000000000..c86fc6f02 --- /dev/null +++ b/.changeset/spm-package-traits.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': minor +--- + +Add `SessionReplay` and `CrashReporting` Swift package traits (on by default) so SwiftPM apps can leave out session replay with the vendored libwebp, and the vendored PLCrashReporter, with `traits: []`. diff --git a/.changeset/spm-surveys-trait.md b/.changeset/spm-surveys-trait.md new file mode 100644 index 000000000..f57d1f00a --- /dev/null +++ b/.changeset/spm-surveys-trait.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': minor +--- + +Add a `Surveys` Swift package trait (on by default) so SwiftPM apps that don't use surveys can leave out the survey engine and UI. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 710e6d0a7..303abf7ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,8 @@ jobs: command: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=ios | xcpretty - platform: macos-spm command: set -o pipefail && xcrun swift build --arch arm64 + - platform: spm-no-traits + command: make buildSdkSpmNoTraits buildTestsSpmTraitsIOS - platform: macos command: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=macos | xcpretty - platform: tvos @@ -87,6 +89,9 @@ jobs: - name: Build SDK via Swift Package Manager if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: make buildSdkSpm + - name: Build SDK via Swift Package Manager with package traits off + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: make buildSdkSpmNoTraits - name: Install CocoaPods if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: gem install cocoapods diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 438d09c70..6129659e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,35 @@ jobs: if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: make test + # Package traits off: libwebp and PLCrashReporter are not linked, so the crash-reporting stub + # and the trait-gated tests run. + test-no-traits: + needs: detect-markdown-only + runs-on: macos-15 + steps: + - name: Complete markdown-only PR check + if: needs.detect-markdown-only.outputs.markdown_only == 'true' + run: echo "Only Markdown files changed; no additional work is required for this check." + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + with: + xcode-version: latest-stable + - name: Cache SPM dependencies + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: ${{ runner.os }}-spm-no-traits-${{ hashFiles('Package.swift', 'Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm-no-traits- + - name: Test SDK (package traits off) + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: make testNoTraits + # `make test` runs `swift test` on macOS, which compiles out every `#if os(iOS)` block, so the # iOS-only suites (session replay, autocapture, tracing headers, crash reporting, etc.) never run # there. Run the iOS test target on a simulator so those suites are actually exercised in CI. diff --git a/AGENTS.md b/AGENTS.md index 7726b68ef..6dd32c83a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - Preserve deployment minima: iOS 15, tvOS 15, macOS 11, watchOS 10, visionOS 1. Keep platform guards and graceful degradation, especially for session replay. Availability annotations/checks do not require adding platform support or raising targets. Intentional support changes require checking `Package.swift` plus CocoaPods/Xcode packaging/build configuration. - Public APIs must remain thread-safe and callable from any thread. Preserve offline operation and queue-based event batching; never assume connectivity. `PostHogSDK.shared` is the default singleton; independent instances use `PostHogSDK.with(_:)` with `PostHogConfig`. - Preserve replay masking/privacy behavior and privacy-safe error logging; do not expose sensitive user data. -- Prefer no new dependencies. libwebp is embedded; PHPLCrashReporter is vendored, prefixed PLCrashReporter for native crash reporting on iOS/macOS/tvOS, excluded on watchOS/visionOS. +- Prefer no new dependencies. libwebp is embedded; PHPLCrashReporter is vendored, prefixed PLCrashReporter for native crash reporting on iOS/macOS/tvOS, excluded on watchOS/visionOS. Both are behind default-on package traits (`SessionReplay`, which also gates the replay engine, and `CrashReporting`); gate code that uses them with `#if !SWIFT_PACKAGE || `. The survey engine and UI are behind the default-on `Surveys` trait the same way. Once released, never add a new default-on trait on its own: apps that list traits would silently lose the feature. Split an existing trait instead, keeping it as an umbrella that enables the new ones. ## Validation selector - Use `make` wrappers, not direct `swift`/`xcodebuild`. Iterate with focused tests; before submitting SDK changes, run contributor checks: `make lint`, `make test`, `make buildSdk`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f4925489..f25b9b896 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ Markdown-only changes need documentation/link checks and `git diff --check`, not | Replay presentation/privacy masking in an app host | `make testPresentationMasks` | | Masking golden-image verification | `make maskSnapshots` | | SDK across supported platforms, including Mac Catalyst | `make buildSdk` | +| Code gated by the `SessionReplay`, `CrashReporting` or `Surveys` package traits | `make buildSdkSpmNoTraits`, `make testNoTraits`, `make buildTestsSpmTraitsIOS` | | Example/platform integrations | `make buildExamplesPlatforms` | | CocoaPods integration modes | `make buildExamplePodsStaticLib`, `make buildExamplePodsStaticFramework`, `make buildExamplePodsDynamicFramework` | | XCFramework integration | `make buildExampleXCFramework` | diff --git a/Makefile b/Makefile index 0413819bf..af09fb6ca 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: testSurveyUI build buildSdk buildExamples format swiftLint swiftFormat swiftLintCheck swiftFormatCheck installSwiftLint installSwiftFormat test testUploadSymbols recordEventShapeSnapshots testDowngradeCompatibility testOniOSSimulator testOnMacSimulator maskSnapshots recordMaskSnapshots checkMaskSnapshotRuntime lint bootstrap releaseCocoaPods api apiCheck apiUpdate buildIOS buildSdkSpm +.PHONY: testSurveyUI build buildSdk buildExamples format swiftLint swiftFormat swiftLintCheck swiftFormatCheck installSwiftLint installSwiftFormat test testUploadSymbols recordEventShapeSnapshots testDowngradeCompatibility testOniOSSimulator testOnMacSimulator maskSnapshots recordMaskSnapshots checkMaskSnapshotRuntime lint bootstrap releaseCocoaPods api apiCheck apiUpdate buildIOS buildSdkSpm buildSdkSpmNoTraits buildTestsSpmTraitsIOS testNoTraits build: buildSdk buildExamples @@ -8,6 +8,7 @@ buildIOS: buildSdk: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=ios | xcpretty #ios $(MAKE) buildSdkSpm #macOS + $(MAKE) buildSdkSpmNoTraits #macOS + iOS, package traits off set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=macos | xcpretty #macOS set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination 'platform=macOS,variant=Mac Catalyst' | xcpretty #Mac Catalyst set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=tvos | xcpretty #tvOS @@ -17,6 +18,20 @@ buildSdk: buildSdkSpm: set -o pipefail && xcrun swift build --arch arm64 +# Builds with every package trait off, so libwebp and PLCrashReporter are not compiled or linked. +# The iOS build covers the WebP gate, which macOS compiles out. +buildSdkSpmNoTraits: + set -o pipefail && xcrun swift build --target PostHog --arch arm64 --disable-default-traits #macOS + set -o pipefail && xcrun swift build --target PostHog --disable-default-traits --triple arm64-apple-ios15.0 --sdk "$$(xcrun --sdk iphoneos --show-sdk-path)" #iOS + +# Compiles the iOS test target with traits off and with each trait on its own, so trait-gated SDK code and +# the tests that use it stay in sync. `make test` runs on macOS, which compiles out the iOS-only code. +buildTestsSpmTraitsIOS: + set -o pipefail && xcrun swift build --build-tests --disable-default-traits --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + set -o pipefail && xcrun swift build --build-tests --traits CrashReporting --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + set -o pipefail && xcrun swift build --build-tests --traits SessionReplay --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + set -o pipefail && xcrun swift build --build-tests --traits Surveys --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + buildExamples: \ buildExamplesPlatforms \ buildExampleXCFramework \ @@ -203,6 +218,9 @@ testIOSResultParser: test: testUploadSymbols testIOSResultParser set -o pipefail && swift test --no-parallel -Xswiftc -DTESTING $(if $(filter),--filter $(filter)) +testNoTraits: + set -o pipefail && swift test --no-parallel --disable-default-traits -Xswiftc -DTESTING $(if $(filter),--filter $(filter)) + recordEventShapeSnapshots: UPDATE_EVENT_SHAPE_SNAPSHOTS=1 $(MAKE) test filter=PostHogEventSnapshotTests diff --git a/Package.swift b/Package.swift index db82f2080..4d9ee752c 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,21 @@ let package = Package( targets: ["PostHog"] ), ], + traits: [ + .trait( + name: "SessionReplay", + description: "Session replay on iOS, including the vendored libwebp. When disabled, session replay is not available." + ), + .trait( + name: "CrashReporting", + description: "Captures crashes and uncaught exceptions using the vendored PLCrashReporter. When disabled, only manual captures are sent." + ), + .trait( + name: "Surveys", + description: "Shows mobile surveys on iOS. When disabled, surveys are not loaded or shown." + ), + .default(enabledTraits: ["SessionReplay", "CrashReporting", "Surveys"]), + ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", from: "9.0.0"), @@ -24,8 +39,8 @@ let package = Package( name: "PostHog", dependencies: [ "PostHogObjCExceptionSupport", - "phlibwebp", - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS])), + .target(name: "phlibwebp", condition: .when(traits: ["SessionReplay"])), + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS], traits: ["CrashReporting"])), ], path: "PostHog", exclude: [ @@ -95,7 +110,7 @@ let package = Package( "OHHTTPStubs", .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), // The crash-report processor tests import this directly to build a PHPLCrashReport. - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS])), + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS], traits: ["CrashReporting"])), ], path: "PostHogTests", resources: [ diff --git a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift index a771c8823..1f2bd0de4 100644 --- a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -7,7 +7,7 @@ import Foundation -#if os(iOS) || os(macOS) || os(tvOS) +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) internal import PHPLCrashReporter enum PostHogCrashReportProcessor { diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 3b9a299cc..7cc021073 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -7,7 +7,7 @@ import Foundation -#if os(iOS) || os(macOS) || os(tvOS) +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) internal import PHPLCrashReporter class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { @@ -294,41 +294,45 @@ import Foundation hedgeLog("Failed to process crash report: \(error)") } } - - /// Returns `true` if any entry in `properties["$exception_list"]` has a - /// `type` matching one of `ignoredTypes`. Walks the exception list rather - /// than only the outermost entry so a wrapped exception whose underlying - /// cause has an ignored type is still suppressed. Match is - /// case-sensitive and exact. For an `NSException` the type is its `name`, - /// which can embed free text. - static func exceptionListMatchesIgnoredTypes(_ properties: [String: Any], ignoredTypes: [String]) -> Bool { - guard let exceptionList = properties["$exception_list"] as? [[String: Any]] else { - return false - } - let ignored = Set(ignoredTypes) - return exceptionList.contains { entry in - guard let exType = entry["type"] as? String else { return false } - return ignored.contains(exType) - } - } } #else - // watchOS/visionOS stub - crash reporting is not available on these platforms + // Stub for watchOS/visionOS, where crash reporting is not available, and for SPM builds + // with the `CrashReporting` trait disabled, where PHPLCrashReporter is not linked. class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { + static func clearInstalls() { /* no-op */ } + var requiresSwizzling: Bool { false } func install(_: PostHogSDK) -> PostHogIntegrationInstallResult { - .skipped(.notAvailableOnPlatform) + #if os(iOS) || os(macOS) || os(tvOS) + .skipped(.disabledByPackageTrait) + #else + .skipped(.notAvailableOnPlatform) + #endif } func uninstall(_: PostHogSDK) { /* no-op */ } func start() { /* no-op */ } func stop() { /* no-op */ } + } +#endif - /// Crash reporting is unavailable on this platform; always returns `false`. - static func exceptionListMatchesIgnoredTypes(_: [String: Any], ignoredTypes _: [String]) -> Bool { - false +extension PostHogErrorTrackingAutoCaptureIntegration { + /// Returns `true` if any entry in `properties["$exception_list"]` has a + /// `type` matching one of `ignoredTypes`. Walks the exception list rather + /// than only the outermost entry so a wrapped exception whose underlying + /// cause has an ignored type is still suppressed. Match is + /// case-sensitive and exact. For an `NSException` the type is its `name`, + /// which can embed free text. + static func exceptionListMatchesIgnoredTypes(_ properties: [String: Any], ignoredTypes: [String]) -> Bool { + guard let exceptionList = properties["$exception_list"] as? [[String: Any]] else { + return false + } + let ignored = Set(ignoredTypes) + return exceptionList.contains { entry in + guard let exType = entry["type"] as? String else { return false } + return ignored.contains(exType) } } -#endif +} diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift index a0c4d1847..0d58b8f7c 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift @@ -32,6 +32,7 @@ import Foundation /// Whether crash autocapture is enabled. /// /// When enabled, fatal crashes are persisted and sent as `$exception` events on the next launch. + /// With Swift Package Manager, this requires the `CrashReporting` package trait, which is enabled by default. /// Default: `false`. @available(watchOS, unavailable, message: "Crash autocapture is not available on watchOS") @available(visionOS, unavailable, message: "Crash autocapture is not available on visionOS") diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 85f54a007..928185ce9 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -402,12 +402,16 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Enable Recording of Session Replays for iOS /// + /// With Swift Package Manager, this requires the `SessionReplay` package trait, which is enabled by default. + /// /// Note: Ingestion controls (sampling, feature flags, and event triggers) are currently applied using AND logic. /// All configured conditions must be satisfied for recording to start. /// /// Default: false @objc public var sessionReplay: Bool = false /// Session Replay configuration + /// + /// With Swift Package Manager, this requires the `SessionReplay` package trait, which is enabled by default. @objc public let sessionReplayConfig: PostHogSessionReplayConfig = .init() #endif @@ -422,6 +426,8 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Enable mobile surveys /// + /// With Swift Package Manager, this requires the `Surveys` package trait, which is enabled by default. + /// /// Default: true /// /// Note: Event triggers will only work with the instance that first enables surveys. @@ -438,6 +444,7 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Configuration for mobile survey presentation and localization. /// /// Mutate fields on `config.surveysConfig` or replace this object before calling setup. + /// With Swift Package Manager, this requires the `Surveys` package trait, which is enabled by default. @available(watchOS, unavailable, message: "Surveys are only available on iOS") @available(macOS, unavailable, message: "Surveys are only available on iOS") @available(tvOS, unavailable, message: "Surveys are only available on iOS") diff --git a/PostHog/PostHogIntegration.swift b/PostHog/PostHogIntegration.swift index 82902b502..1f23bfa77 100644 --- a/PostHog/PostHogIntegration.swift +++ b/PostHog/PostHogIntegration.swift @@ -12,6 +12,7 @@ enum PostHogIntegrationInstallSkipReason: String, CustomStringConvertible { case alreadyInstalled = "Already installed to another PostHogSDK instance" case disabledByRemoteConfig = "Disabled in remote config" case notAvailableOnPlatform = "Not available on this platform" + case disabledByPackageTrait = "Disabled by a package trait" } enum PostHogIntegrationInstallResult: Equatable { diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index f6cc42a9a..7e64e718d 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -729,7 +729,7 @@ let maxRetryDelay = 30.0 // SDK-computed debug keys overwrite a same-named registered super property (js: `extend` // after super properties), so a stale `register()` can't shadow the live status. - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) var replayDebugProperties = replayIntegration?.debugProperties() ?? [ "$recording_status": "disabled", "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode(config: config), diff --git a/PostHog/Replay/CGColor+Util.swift b/PostHog/Replay/CGColor+Util.swift index 9fee8b564..d867f57c7 100644 --- a/PostHog/Replay/CGColor+Util.swift +++ b/PostHog/Replay/CGColor+Util.swift @@ -5,7 +5,7 @@ // Created by Manoel Aranda Neto on 21.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import UIKit diff --git a/PostHog/Replay/NetworkSample.swift b/PostHog/Replay/NetworkSample.swift index 5c6bf4d35..2f561afc1 100644 --- a/PostHog/Replay/NetworkSample.swift +++ b/PostHog/Replay/NetworkSample.swift @@ -5,7 +5,7 @@ // Created by Manoel Aranda Neto on 26.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation struct NetworkSample { diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift index 26af415de..383041f10 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 05/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation final class PostHogConsoleLogInterceptor { diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift b/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift index 6f211abc3..b89ab4f64 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 09/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation final class PostHogSessionReplayConsoleLogsPlugin: PostHogSessionReplayPlugin { diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 38ed75563..3934c29f9 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 28/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /// Session replay plugin that captures network requests using URLSession swizzling. diff --git a/PostHog/Replay/Plugins/Network/URLSessionExtension.swift b/PostHog/Replay/Plugins/Network/URLSessionExtension.swift index 0c043ef4a..e7e0f0691 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionExtension.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionExtension.swift @@ -241,51 +241,53 @@ end: UInt64? = nil, postHog: PostHogSDK? ) { - let instance = postHog ?? PostHogSDK.shared + #if !SWIFT_PACKAGE || SessionReplay + let instance = postHog ?? PostHogSDK.shared - // we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension - // has to be called manually anyway - guard let sessionId, instance.isSessionReplayActive() else { - return - } - let currentEnd = end ?? getMonotonicTimeInMilliseconds() + // we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension + // has to be called manually anyway + guard let sessionId, instance.isSessionReplayActive() else { + return + } + let currentEnd = end ?? getMonotonicTimeInMilliseconds() - PostHogReplayIntegration.dispatchQueue.async { - var snapshotsData: [Any] = [] + PostHogReplayIntegration.dispatchQueue.async { + var snapshotsData: [Any] = [] - var requestsData: [String: Any] = ["duration": currentEnd - start, - "method": request?.httpMethod ?? "GET", - "name": request?.url?.absoluteString ?? (response?.url?.absoluteString ?? ""), - "initiatorType": "fetch", - "entryType": "resource", - "timestamp": timestamp.toMillis()] + var requestsData: [String: Any] = ["duration": currentEnd - start, + "method": request?.httpMethod ?? "GET", + "name": request?.url?.absoluteString ?? (response?.url?.absoluteString ?? ""), + "initiatorType": "fetch", + "entryType": "resource", + "timestamp": timestamp.toMillis()] - // the UI special case if the transferSize is 0 as coming from cache - let transferSize = Int64(request?.httpBody?.count ?? 0) + (response?.expectedContentLength ?? 0) - if transferSize > 0 { - requestsData["transferSize"] = transferSize - } + // the UI special case if the transferSize is 0 as coming from cache + let transferSize = Int64(request?.httpBody?.count ?? 0) + (response?.expectedContentLength ?? 0) + if transferSize > 0 { + requestsData["transferSize"] = transferSize + } - if let urlResponse = response as? HTTPURLResponse { - requestsData["responseStatus"] = urlResponse.statusCode - } + if let urlResponse = response as? HTTPURLResponse { + requestsData["responseStatus"] = urlResponse.statusCode + } - let payloadData: [String: Any] = ["requests": [requestsData]] - let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData] + let payloadData: [String: Any] = ["requests": [requestsData]] + let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData] - let recordingData: [String: Any] = ["type": 6, "data": pluginData, "timestamp": timestamp.toMillis()] - snapshotsData.append(recordingData) + let recordingData: [String: Any] = ["type": 6, "data": pluginData, "timestamp": timestamp.toMillis()] + snapshotsData.append(recordingData) - instance.capture( - "$snapshot", - properties: [ - "$snapshot_source": "mobile", - "$snapshot_data": snapshotsData, - "$session_id": sessionId, - ], - timestamp: timestamp - ) - } + instance.capture( + "$snapshot", + properties: [ + "$snapshot_source": "mobile", + "$snapshot_data": snapshotsData, + "$session_id": sessionId, + ], + timestamp: timestamp + ) + } + #endif } } #endif diff --git a/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift b/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift index ab63ab1a0..c17bafed2 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift @@ -4,7 +4,7 @@ * Copyright 2019-Present Datadog, Inc. */ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation diff --git a/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift b/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift index 2350d513a..f5ac2578a 100644 --- a/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift +++ b/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 12/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /// Session replay plugins are used to capture specific types of meta data during a session, diff --git a/PostHog/Replay/PostHogPresentationCover.swift b/PostHog/Replay/PostHogPresentationCover.swift index 9f94f4226..03e9b566c 100644 --- a/PostHog/Replay/PostHogPresentationCover.swift +++ b/PostHog/Replay/PostHogPresentationCover.swift @@ -3,7 +3,7 @@ // PostHog // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import UIKit diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 3cac45718..4e29c93e1 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -6,7 +6,7 @@ // // Created by Manoel Aranda Neto on 19.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import PhotosUI import SwiftUI @@ -2034,6 +2034,35 @@ } #endif +#elseif os(iOS) + import UIKit + + // Stub for SPM builds with the `SessionReplay` trait disabled, where the replay engine and libwebp + // are not compiled. It is never installed, so the SDK's replay calls see no integration. + final class PostHogReplayIntegration: PostHogIntegration { + var requiresSwizzling: Bool { false } + + func install(_: PostHogSDK) -> PostHogIntegrationInstallResult { + .skipped(.disabledByPackageTrait) + } + + func uninstall(_: PostHogSDK) { /* no-op */ } + func start() { /* no-op */ } + func stop() { /* no-op */ } + func isActive() -> Bool { + false + } + func captureBridgeSnapshot(episodeFirstFrame _: Bool, window _: UIWindow? = nil) -> Bool { + false + } + func debugProperties() -> [String: Any] { + [:] + } + + #if TESTING + static func clearInstalls() { /* no-op */ } + #endif + } #endif // swiftlint:enable cyclomatic_complexity file_length diff --git a/PostHog/Replay/PostHogSessionReplayConfig.swift b/PostHog/Replay/PostHogSessionReplayConfig.swift index 455151fd2..392c04949 100644 --- a/PostHog/Replay/PostHogSessionReplayConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConfig.swift @@ -97,19 +97,21 @@ } } - /// Returns an array of plugin types based on current configuration - func getPluginTypes() -> [PostHogSessionReplayPlugin.Type] { - var types: [PostHogSessionReplayPlugin.Type] = [] + #if !SWIFT_PACKAGE || SessionReplay + /// Returns an array of plugin types based on current configuration + func getPluginTypes() -> [PostHogSessionReplayPlugin.Type] { + var types: [PostHogSessionReplayPlugin.Type] = [] - if captureLogs { - types.append(PostHogSessionReplayConsoleLogsPlugin.self) - } + if captureLogs { + types.append(PostHogSessionReplayConsoleLogsPlugin.self) + } - if captureNetworkTelemetry { - types.append(PostHogSessionReplayNetworkPlugin.self) - } + if captureNetworkTelemetry { + types.append(PostHogSessionReplayNetworkPlugin.self) + } - return types - } + return types + } + #endif } #endif diff --git a/PostHog/Replay/UIColor+Util.swift b/PostHog/Replay/UIColor+Util.swift index b3d04639b..b2bb81583 100644 --- a/PostHog/Replay/UIColor+Util.swift +++ b/PostHog/Replay/UIColor+Util.swift @@ -4,7 +4,7 @@ // // Created by Manoel Aranda Neto on 21.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import UIKit diff --git a/PostHog/Replay/UIImage+Util.swift b/PostHog/Replay/UIImage+Util.swift index 1645348e9..5de42f401 100644 --- a/PostHog/Replay/UIImage+Util.swift +++ b/PostHog/Replay/UIImage+Util.swift @@ -17,7 +17,11 @@ } private func toWebPBase64(_ compressionQuality: CGFloat) -> String? { - toImageBase64(mimeType: "webp", data: webpData(compressionQuality: compressionQuality)) + #if !SWIFT_PACKAGE || SessionReplay + toImageBase64(mimeType: "webp", data: webpData(compressionQuality: compressionQuality)) + #else + nil + #endif } private func toJpegBase64(_ compressionQuality: CGFloat) -> String? { diff --git a/PostHog/Surveys/BottomSection.swift b/PostHog/Surveys/BottomSection.swift index d8745ac48..e617caab1 100644 --- a/PostHog/Surveys/BottomSection.swift +++ b/PostHog/Surveys/BottomSection.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 18/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct BottomSection: View { diff --git a/PostHog/Surveys/ConfirmationMessage.swift b/PostHog/Surveys/ConfirmationMessage.swift index 6a668e5f8..a7e1ffb3c 100644 --- a/PostHog/Surveys/ConfirmationMessage.swift +++ b/PostHog/Surveys/ConfirmationMessage.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct ConfirmationMessage: View { diff --git a/PostHog/Surveys/IntroMessage.swift b/PostHog/Surveys/IntroMessage.swift index 48f695927..370f83b48 100644 --- a/PostHog/Surveys/IntroMessage.swift +++ b/PostHog/Surveys/IntroMessage.swift @@ -5,7 +5,7 @@ // Created by PostHog Code on 2026-08-06. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI /// Optional intro screen shown before the first question — the leading mirror of the diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index 60739f15c..66f65b332 100644 --- a/PostHog/Surveys/PostHogSurveyIntegration.swift +++ b/PostHog/Surveys/PostHogSurveyIntegration.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 20/02/2025. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation #if os(iOS) @@ -1183,4 +1183,17 @@ } } #endif +#elseif os(iOS) + // Stub for SPM builds with the `Surveys` trait disabled, where the survey engine and UI are not compiled. + final class PostHogSurveyIntegration: PostHogIntegration { + var requiresSwizzling: Bool { false } + + func install(_: PostHogSDK) -> PostHogIntegrationInstallResult { + .skipped(.disabledByPackageTrait) + } + + func uninstall(_: PostHogSDK) { /* no-op */ } + func start() { /* no-op */ } + func stop() { /* no-op */ } + } #endif diff --git a/PostHog/Surveys/PostHogSurveysConfig.swift b/PostHog/Surveys/PostHogSurveysConfig.swift index 08a18350c..edb702d4f 100644 --- a/PostHog/Surveys/PostHogSurveysConfig.swift +++ b/PostHog/Surveys/PostHogSurveysConfig.swift @@ -15,6 +15,7 @@ import Foundation /// Delegate responsible for managing survey presentation in your app. /// Handles survey rendering, response collection, and lifecycle events. /// You can provide your own delegate for a custom survey presentation. + /// With Swift Package Manager, the delegate is only called when the `Surveys` package trait is enabled, which it is by default. /// /// Defaults to `PostHogSurveysDefaultDelegate` which provides a standard survey UI. public var surveysDelegate: PostHogSurveysDelegate = PostHogSurveysDefaultDelegate() diff --git a/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift b/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift index ac98150fd..49132efe9 100644 --- a/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift +++ b/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 18/06/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import UIKit #else import Foundation @@ -14,7 +14,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { var supportsSurveyResume: Bool { true } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) private var surveysWindow: UIWindow? private var displayController: SurveyDisplayController? private var pendingDisplayWorkItem: DispatchWorkItem? @@ -28,7 +28,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { onSurveyResponse: @escaping OnPostHogSurveyResponse, onSurveyClosed: @escaping OnPostHogSurveyClosed ) { - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) if displayController == nil { // setup window for first-time display setupWindow() @@ -50,7 +50,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { } func updateSurvey(_ survey: PostHogDisplaySurvey) { - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) // If the survey is still waiting out its display delay, refresh the queued copy so // it gets shown with the latest content. if pendingSurvey?.id == survey.id { @@ -62,7 +62,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { } func cleanupSurveys() { - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) dismissPendingSurveyIfNeeded() displayController?.dismissSurvey() // dismiss any active surveys surveysWindow?.rootViewController?.dismiss(animated: true) { @@ -73,7 +73,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { #endif } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) private func setupWindow() { if let activeWindow = UIApplication.getCurrentWindow(), let activeScene = activeWindow.windowScene { let controller = SurveyDisplayController() @@ -141,7 +141,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { #endif } -#if os(iOS) && TESTING +#if os(iOS) && TESTING && (!SWIFT_PACKAGE || Surveys) extension PostHogSurveysDefaultDelegate { /// Injects a display controller so tests can drive rendering without a `UIWindowScene` func setDisplayControllerForTesting(_ controller: SurveyDisplayController) { diff --git a/PostHog/Surveys/QuestionHeader.swift b/PostHog/Surveys/QuestionHeader.swift index 4a8ccf28d..b621492a2 100644 --- a/PostHog/Surveys/QuestionHeader.swift +++ b/PostHog/Surveys/QuestionHeader.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct QuestionHeader: View { diff --git a/PostHog/Surveys/QuestionTypes.swift b/PostHog/Surveys/QuestionTypes.swift index 0fdf6099d..bd26a9d9a 100644 --- a/PostHog/Surveys/QuestionTypes.swift +++ b/PostHog/Surveys/QuestionTypes.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct OpenTextQuestionView: View { diff --git a/PostHog/Surveys/SurveyDisplayController.swift b/PostHog/Surveys/SurveyDisplayController.swift index a56e7d85f..07e30329a 100644 --- a/PostHog/Surveys/SurveyDisplayController.swift +++ b/PostHog/Surveys/SurveyDisplayController.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 07/03/2025. // -#if os(iOS) || Testing +#if (os(iOS) || Testing) && (!SWIFT_PACKAGE || Surveys) import SwiftUI final class SurveyDisplayController: ObservableObject { diff --git a/PostHog/Surveys/SurveyProgressStore.swift b/PostHog/Surveys/SurveyProgressStore.swift index f4c967b62..f5b3e1b68 100644 --- a/PostHog/Surveys/SurveyProgressStore.swift +++ b/PostHog/Surveys/SurveyProgressStore.swift @@ -1,6 +1,6 @@ import Foundation -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) struct SurveyProgress: Codable { var version = 2 let resetEpoch: String diff --git a/PostHog/Surveys/SurveySheet.swift b/PostHog/Surveys/SurveySheet.swift index 89334a20f..511851586 100644 --- a/PostHog/Surveys/SurveySheet.swift +++ b/PostHog/Surveys/SurveySheet.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 12/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI diff --git a/PostHog/Surveys/SurveysRootView.swift b/PostHog/Surveys/SurveysRootView.swift index 60089c3c9..96662d195 100644 --- a/PostHog/Surveys/SurveysRootView.swift +++ b/PostHog/Surveys/SurveysRootView.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 07/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct SurveysRootView: View { diff --git a/PostHog/Surveys/SurveysWindow.swift b/PostHog/Surveys/SurveysWindow.swift index d27750d7e..245451cc8 100644 --- a/PostHog/Surveys/SurveysWindow.swift +++ b/PostHog/Surveys/SurveysWindow.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 06/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI import UIKit diff --git a/PostHog/Surveys/Utils/EdgeBorder.swift b/PostHog/Surveys/Utils/EdgeBorder.swift index d03196c92..be0c11e63 100644 --- a/PostHog/Surveys/Utils/EdgeBorder.swift +++ b/PostHog/Surveys/Utils/EdgeBorder.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 22/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct EdgeBorder: Shape { diff --git a/PostHog/Surveys/Utils/EmojiRating.swift b/PostHog/Surveys/Utils/EmojiRating.swift index a41af6a0d..b87235890 100644 --- a/PostHog/Surveys/Utils/EmojiRating.swift +++ b/PostHog/Surveys/Utils/EmojiRating.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct EmojiRating: View { diff --git a/PostHog/Surveys/Utils/MultipleChoiceOptions.swift b/PostHog/Surveys/Utils/MultipleChoiceOptions.swift index 3459498a0..1c63f43b4 100644 --- a/PostHog/Surveys/Utils/MultipleChoiceOptions.swift +++ b/PostHog/Surveys/Utils/MultipleChoiceOptions.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct MultipleChoiceOptions: View { diff --git a/PostHog/Surveys/Utils/NumberRating.swift b/PostHog/Surveys/Utils/NumberRating.swift index 66c053dbf..2bafac328 100644 --- a/PostHog/Surveys/Utils/NumberRating.swift +++ b/PostHog/Surveys/Utils/NumberRating.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct NumberRating: View { diff --git a/PostHog/Surveys/Utils/PostHogSurveyMatching.swift b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift index 9b432a95f..990b95b27 100644 --- a/PostHog/Surveys/Utils/PostHogSurveyMatching.swift +++ b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift @@ -9,7 +9,7 @@ // the integration stays within the file-length limit. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation diff --git a/PostHog/Surveys/Utils/Resources.swift b/PostHog/Surveys/Utils/Resources.swift index b8b0f2930..c8a66d15a 100644 --- a/PostHog/Surveys/Utils/Resources.swift +++ b/PostHog/Surveys/Utils/Resources.swift @@ -8,7 +8,7 @@ // see: https://github.com/bring-shrubbery/SVG-to-SwiftUI // swiftlint:disable line_length -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct VeryDissatisfiedEmoji: Shape { diff --git a/PostHog/Surveys/Utils/SegmentedControl.swift b/PostHog/Surveys/Utils/SegmentedControl.swift index 2e152aaca..31f6c0917 100644 --- a/PostHog/Surveys/Utils/SegmentedControl.swift +++ b/PostHog/Surveys/Utils/SegmentedControl.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct SegmentedControl: View { diff --git a/PostHog/Surveys/Utils/Survey+Util.swift b/PostHog/Surveys/Utils/Survey+Util.swift index e0ee21930..c25a66f6d 100644 --- a/PostHog/Surveys/Utils/Survey+Util.swift +++ b/PostHog/Surveys/Utils/Survey+Util.swift @@ -24,7 +24,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI import UIKit diff --git a/PostHog/Surveys/Utils/SurveyButton.swift b/PostHog/Surveys/Utils/SurveyButton.swift index b4c2252fa..6bf1c4588 100644 --- a/PostHog/Surveys/Utils/SurveyButton.swift +++ b/PostHog/Surveys/Utils/SurveyButton.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI diff --git a/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift b/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift index cdac5b7a5..9736a3390 100644 --- a/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift +++ b/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 22/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI diff --git a/PostHog/Surveys/Utils/SurveyTranslationResolver.swift b/PostHog/Surveys/Utils/SurveyTranslationResolver.swift index 106c5afd2..6cc23c904 100644 --- a/PostHog/Surveys/Utils/SurveyTranslationResolver.swift +++ b/PostHog/Surveys/Utils/SurveyTranslationResolver.swift @@ -5,7 +5,7 @@ // Created by PostHog Code on 2026-05-13. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation diff --git a/PostHog/Surveys/Utils/SwiftUI+Util.swift b/PostHog/Surveys/Utils/SwiftUI+Util.swift index 960deeb79..62d4ddbf9 100644 --- a/PostHog/Surveys/Utils/SwiftUI+Util.swift +++ b/PostHog/Surveys/Utils/SwiftUI+Util.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 10/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI extension View { diff --git a/PostHog/Utils/UIImage+WebP.swift b/PostHog/Utils/UIImage+WebP.swift index fca34b6cc..3fa2ceeea 100644 --- a/PostHog/Utils/UIImage+WebP.swift +++ b/PostHog/Utils/UIImage+WebP.swift @@ -6,7 +6,7 @@ // // Adapted from: https://github.com/SDWebImage/SDWebImageWebPCoder/blob/master/SDWebImageWebPCoder/Classes/SDImageWebPCoder.m -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Accelerate import CoreGraphics import Foundation diff --git a/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift index d9b646a4e..40aa2549f 100644 --- a/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift +++ b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift @@ -11,8 +11,13 @@ import Testing /// /// In Swift 6 mode, a closure written in main-actor code and passed as a non-`Sendable` /// parameter is main-actor isolated, and the runtime traps if the SDK calls it off the main thread. -@MainActor -@Suite(.timeLimit(.minutes(1))) +#if os(macOS) + // `.timeLimit` needs iOS 16, above the package's iOS 15 minimum. + @MainActor + @Suite(.timeLimit(.minutes(1))) +#else + @MainActor +#endif struct PostHogCallbackIsolationTests { @MainActor final class AppState { var reloadResult: PostHogFeatureFlagsLoaded? diff --git a/PostHogTests/PostHogConsoleLogInterceptorTest.swift b/PostHogTests/PostHogConsoleLogInterceptorTest.swift index 5d95de857..d20579ef0 100644 --- a/PostHogTests/PostHogConsoleLogInterceptorTest.swift +++ b/PostHogTests/PostHogConsoleLogInterceptorTest.swift @@ -5,7 +5,7 @@ // Regression coverage for https://github.com/PostHog/posthog-ios/issues/780 // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift index acfabeac1..8fae4fdc6 100644 --- a/PostHogTests/PostHogCrashReportProcessorTest.swift +++ b/PostHogTests/PostHogCrashReportProcessorTest.swift @@ -9,7 +9,7 @@ import Foundation @testable import PostHog import Testing -#if os(iOS) || os(macOS) || os(tvOS) +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) import PHPLCrashReporter /// A live report snapshots every thread while the process keeps running, so a thread starting or diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index 7030babd8..2139544b7 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -1478,7 +1478,7 @@ enum PostHogFeatureFlagsTest { } } - #if SWIFT_PACKAGE + #if SWIFT_PACKAGE && os(macOS) @Suite("Test concurrent flag reload coalescing", .timeLimit(.minutes(1))) #else @Suite("Test concurrent flag reload coalescing") diff --git a/PostHogTests/PostHogIntegrationInstallationTest.swift b/PostHogTests/PostHogIntegrationInstallationTest.swift index 4d936284e..d5d9302c3 100644 --- a/PostHogTests/PostHogIntegrationInstallationTest.swift +++ b/PostHogTests/PostHogIntegrationInstallationTest.swift @@ -82,7 +82,7 @@ class PostHogIntegrationInstallationTest { return PostHogSDK.with(config) } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) @Test("replay integration installed only once, on first instance") func replayIntegrationInstalledOnce() { let first = getSut(projectToken: "test_project_token", sessionReplay: true) @@ -186,7 +186,7 @@ class PostHogIntegrationInstallationTest { // MARK: - Error tracking integration - #if os(iOS) || os(macOS) || os(tvOS) + #if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) @Test("error tracking integration installed on first launch before remote config arrives") func errorTrackingInstalledBeforeRemoteConfig() { // disableRemoteConfig=true means hasFetchedRemoteConfig stays false. @@ -350,6 +350,27 @@ class PostHogIntegrationInstallationTest { sut.optIn() // triggers re-install with a fetched-but-failed remote config #expect(sut.getErrorTrackingIntegration() != nil) } + #elseif os(iOS) || os(macOS) || os(tvOS) + @Test("error tracking integration is skipped when the CrashReporting trait is disabled") + func errorTrackingIntegrationSkippedWithoutCrashReportingTrait() { + let sut = getSut(projectToken: "test_project_token", errorTrackingAutoCapture: true) + + #expect(sut.getErrorTrackingIntegration() == nil) + #expect(PostHogErrorTrackingAutoCaptureIntegration().install(sut) == .skipped(.disabledByPackageTrait)) + + sut.close() + } + #endif + + #if os(iOS) && SWIFT_PACKAGE && !Surveys + @Test("surveys integration is skipped when the Surveys trait is disabled") + func surveysIntegrationSkippedWithoutSurveysTrait() { + let sut = getSut(projectToken: "test_project_token") + + #expect(PostHogSurveyIntegration().install(sut) == .skipped(.disabledByPackageTrait)) + + sut.close() + } #endif #if os(iOS) || os(macOS) diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index b9b90f91c..7902f09ec 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -7,7 +7,7 @@ // while it fades out. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import CoreGraphics import Foundation @testable import PostHog diff --git a/PostHogTests/PostHogMaskScenarioTest.swift b/PostHogTests/PostHogMaskScenarioTest.swift index aa4551905..2d3c4f855 100644 --- a/PostHogTests/PostHogMaskScenarioTest.swift +++ b/PostHogTests/PostHogMaskScenarioTest.swift @@ -10,7 +10,7 @@ // layer-scan logic is covered separately in PostHogMaskingCharacterizationTest; // rendered-pixel coverage lives in PostHogMaskSnapshotTest. -#if os(iOS) && canImport(SwiftUI) +#if os(iOS) && canImport(SwiftUI) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import SwiftUI diff --git a/PostHogTests/PostHogMaskingCharacterizationTest.swift b/PostHogTests/PostHogMaskingCharacterizationTest.swift index 45ab9ecce..efb449ec6 100644 --- a/PostHogTests/PostHogMaskingCharacterizationTest.swift +++ b/PostHogTests/PostHogMaskingCharacterizationTest.swift @@ -5,7 +5,7 @@ // Created by Jiri Urbasek on 16/07/2026. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import SwiftUI diff --git a/PostHogTests/PostHogReactNativeMaskingTest.swift b/PostHogTests/PostHogReactNativeMaskingTest.swift index 310e6ada2..0e49fd6f0 100644 --- a/PostHogTests/PostHogReactNativeMaskingTest.swift +++ b/PostHogTests/PostHogReactNativeMaskingTest.swift @@ -9,7 +9,7 @@ // classes do in a React Native host app. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogReplayCameraTest.swift b/PostHogTests/PostHogReplayCameraTest.swift index fdc331346..fc851ec57 100644 --- a/PostHogTests/PostHogReplayCameraTest.swift +++ b/PostHogTests/PostHogReplayCameraTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) @testable import PostHog import Testing import UIKit @@ -118,7 +118,7 @@ } #endif -#if os(iOS) && TEST_CAMERA_REPLAY +#if os(iOS) && TEST_CAMERA_REPLAY && (!SWIFT_PACKAGE || SessionReplay) @Suite("System camera replay regression", .serialized) @MainActor struct PostHogSystemCameraReplayTest { diff --git a/PostHogTests/PostHogReplayCaptureTouchesTest.swift b/PostHogTests/PostHogReplayCaptureTouchesTest.swift index ef673d604..48392dbcf 100644 --- a/PostHogTests/PostHogReplayCaptureTouchesTest.swift +++ b/PostHogTests/PostHogReplayCaptureTouchesTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogReplayScreenshotDedupTest.swift b/PostHogTests/PostHogReplayScreenshotDedupTest.swift index d02150201..c86a65207 100644 --- a/PostHogTests/PostHogReplayScreenshotDedupTest.swift +++ b/PostHogTests/PostHogReplayScreenshotDedupTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 908df5d4f..f24e7e13b 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -383,38 +383,40 @@ final class PostHogSDKTests { } #if os(iOS) - @Test("captures $recording_status on every event and the full replay debug bundle on the first eligible SDK event only") - func capturesRecordingStatusAndReplayDebugBundle() throws { - server.reset(batchCount: 1) - let sut = getSut(flushAt: 3) - - sut.capture("test event") - sut.screen("theScreen") - sut.capture("$exception", properties: ["foo": "bar"]) - - let events = getBatchedEvents(server) - try #require(events.count == 3) - - #expect(events[0].properties["$recording_status"] as? String == "disabled") - #expect(events[0].properties["$sdk_debug_session_start"] == nil) - #expect(events[0].properties["$sdk_debug_replay_capture_mode"] == nil) - - #expect(events[1].properties["$recording_status"] as? String == "disabled") - #expect(events[1].properties["$sdk_debug_replay_capture_mode"] as? String == "wireframe") - #expect(events[1].properties["$sdk_debug_session_start"] != nil) - - // Inside the 30s window opened by $screen. - #expect(events[2].properties["$recording_status"] as? String == "disabled") - #expect(events[2].properties["$sdk_debug_session_start"] == nil) - #expect(events[2].properties["$sdk_debug_replay_capture_mode"] == nil) + #if !SWIFT_PACKAGE || SessionReplay + @Test("captures $recording_status on every event and the full replay debug bundle on the first eligible SDK event only") + func capturesRecordingStatusAndReplayDebugBundle() throws { + server.reset(batchCount: 1) + let sut = getSut(flushAt: 3) + + sut.capture("test event") + sut.screen("theScreen") + sut.capture("$exception", properties: ["foo": "bar"]) + + let events = getBatchedEvents(server) + try #require(events.count == 3) + + #expect(events[0].properties["$recording_status"] as? String == "disabled") + #expect(events[0].properties["$sdk_debug_session_start"] == nil) + #expect(events[0].properties["$sdk_debug_replay_capture_mode"] == nil) + + #expect(events[1].properties["$recording_status"] as? String == "disabled") + #expect(events[1].properties["$sdk_debug_replay_capture_mode"] as? String == "wireframe") + #expect(events[1].properties["$sdk_debug_session_start"] != nil) + + // Inside the 30s window opened by $screen. + #expect(events[2].properties["$recording_status"] as? String == "disabled") + #expect(events[2].properties["$sdk_debug_session_start"] == nil) + #expect(events[2].properties["$sdk_debug_replay_capture_mode"] == nil) + + for event in events { + #expect(event.properties["$sdk_debug_pending_queue_size"] != nil) + } - for event in events { - #expect(event.properties["$sdk_debug_pending_queue_size"] != nil) + sut.reset() + sut.close() } - - sut.reset() - sut.close() - } + #endif @Test("excludes $recording_status and $sdk_debug_* properties from $snapshot events") func excludesRecordingStatusAndDebugPropertiesFromSnapshotEvents() throws { @@ -451,22 +453,24 @@ final class PostHogSDKTests { sut.close() } - @Test("reports screenshot capture mode for the flutter host") - func reportsScreenshotCaptureModeForFlutterHost() { - server.reset(batchCount: 1) - let original = postHogSdkName - postHogSdkName = "posthog-flutter" - defer { postHogSdkName = original } + #if !SWIFT_PACKAGE || SessionReplay + @Test("reports screenshot capture mode for the flutter host") + func reportsScreenshotCaptureModeForFlutterHost() { + server.reset(batchCount: 1) + let original = postHogSdkName + postHogSdkName = "posthog-flutter" + defer { postHogSdkName = original } - let sut = getSut() - sut.screen("theScreen") + let sut = getSut() + sut.screen("theScreen") - let events = getBatchedEvents(server) - #expect(events.first?.properties["$sdk_debug_replay_capture_mode"] as? String == "screenshot") + let events = getBatchedEvents(server) + #expect(events.first?.properties["$sdk_debug_replay_capture_mode"] as? String == "screenshot") - sut.reset() - sut.close() - } + sut.reset() + sut.close() + } + #endif @Test("SDK-computed debug keys win over a same-named registered super property") func sdkComputedDebugKeysWinOverRegisteredSuperProperty() throws { diff --git a/PostHogTests/PostHogSamplingTest.swift b/PostHogTests/PostHogSamplingTest.swift index c7614409b..6d64cf844 100644 --- a/PostHogTests/PostHogSamplingTest.swift +++ b/PostHogTests/PostHogSamplingTest.swift @@ -100,7 +100,7 @@ class PostHogSamplingTests { } } -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) // MARK: - parseSampleRate Tests diff --git a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift index 365eaf22a..088ec47e5 100644 --- a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift +++ b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @_spi(PostHogInternal) @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSessionReplayManualModeTest.swift b/PostHogTests/PostHogSessionReplayManualModeTest.swift index 86290a346..cedcb141f 100644 --- a/PostHogTests/PostHogSessionReplayManualModeTest.swift +++ b/PostHogTests/PostHogSessionReplayManualModeTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @_spi(PostHogInternal) @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index a4741aff8..148c3f049 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @_spi(PostHogInternal) @testable import PostHog #if !SWIFT_PACKAGE diff --git a/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift b/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift index b770ff784..9949e90ab 100644 --- a/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift +++ b/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @_spi(PostHogInternal) @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSessionReplayTest.swift b/PostHogTests/PostHogSessionReplayTest.swift index c8c0ee731..ee0f40fb5 100644 --- a/PostHogTests/PostHogSessionReplayTest.swift +++ b/PostHogTests/PostHogSessionReplayTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSurveyEventsTest.swift b/PostHogTests/PostHogSurveyEventsTest.swift index 7a513666e..012d4fe12 100644 --- a/PostHogTests/PostHogSurveyEventsTest.swift +++ b/PostHogTests/PostHogSurveyEventsTest.swift @@ -5,962 +5,880 @@ // Created by Ioannis Josephides on 03/10/2025. // -import Foundation -@_spi(PostHogInternal) @testable import PostHog -import Testing - -@Suite("Test Survey Events", .serialized) -class PostHogSurveyEventsTest { - let server: MockPostHogServer - - init() { - server = MockPostHogServer() - server.start() - } - - deinit { - server.stop() - } - - var defaultQuestions: [PostHogSurveyQuestion] = [ - .open(PostHogOpenSurveyQuestion( - id: "qID1", - question: "What do you think about our product?", - description: "Please share your thoughts", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 0, - branching: nil, - translations: nil - )), - .singleChoice(PostHogMultipleSurveyQuestion( - id: "qID2", - question: "How likely are you to recommend us?", - description: "Please select one option", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 1, - branching: nil, - translations: nil, - choices: ["Very likely", "Somewhat likely", "Not likely"], - hasOpenChoice: false, - shuffleOptions: false - )), - .rating(PostHogRatingSurveyQuestion( - id: "qID3", - question: "Rate your experience", - description: "1 = Poor, 5 = Excellent", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 2, - branching: nil, - translations: nil, - display: .number, - scale: .fivePoint, - lowerBoundLabel: "Poor", - upperBoundLabel: "Excellent" - )), - ] - - func getTestSurvey( - id: String = "test-survey-id", - name: String = "Test Survey", - questions: [PostHogSurveyQuestion], - currentIteration: Int? = nil, - currentIterationStartDate: Date? = nil - ) -> PostHogSurvey { - PostHogSurvey( - id: id, - name: name, - type: .popover, - questions: questions, - featureFlagKeys: nil, - linkedFlagKey: nil, - targetingFlagKey: nil, - internalTargetingFlagKey: nil, - conditions: nil, - appearance: nil, - currentIteration: currentIteration, - currentIterationStartDate: currentIterationStartDate, - startDate: Date(), - endDate: nil, - schedule: nil, - translations: nil - ) - } - - func getSut(resetStorage: Bool = true, installSurveys: Bool = true) -> PostHogSDK { - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = installSurveys - config.disableRemoteConfigForTesting = true - config.flushAt = 1 - config.disableReachabilityForTesting = true - config.disableQueueTimerForTesting = true - config.disableFlushOnBackgroundForTesting = true - config.captureApplicationLifecycleEvents = false - config.captureScreenViews = false - - if resetStorage { PostHogStorage(config).reset() } - return PostHogSDK.with(config) - } - - func getSurveyIntegration(_ postHog: PostHogSDK) throws -> PostHogSurveyIntegration { - PostHogSurveyIntegration.clearInstalls() - let integration = PostHogSurveyIntegration() - let installResult = integration.install(postHog) - try #require(installResult == .installed) - // These tests drive callbacks directly; remote refreshes must not replace their fixture surveys. - integration.stop() - return integration - } +#if !SWIFT_PACKAGE || Surveys + import Foundation + @_spi(PostHogInternal) @testable import PostHog + import Testing + + @Suite("Test Survey Events", .serialized) + class PostHogSurveyEventsTest { + let server: MockPostHogServer + + init() { + server = MockPostHogServer() + server.start() + } - func surveyJSON(questions: [[String: Any]]) throws -> PostHogSurvey { - let json: [String: Any] = [ - "id": "branch-survey", - "name": "Branch survey", - "type": "popover", - "questions": questions, - ] - let data = try JSONSerialization.data(withJSONObject: json) - return try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - } + deinit { + server.stop() + } - func partialResponseSurvey(enabled: Bool?, branching: [String: Any]? = nil, properties: [String: Any] = [:]) throws -> PostHogSurvey { - var first: [String: Any] = ["id": "first", "type": "open", "question": "First?", "optional": true] - first["branching"] = branching - var json: [String: Any] = [ - "id": "partial-survey", "name": "Partial survey", "type": "popover", - "questions": [first, ["id": "second", "type": "open", "question": "Second?"]], + var defaultQuestions: [PostHogSurveyQuestion] = [ + .open(PostHogOpenSurveyQuestion( + id: "qID1", + question: "What do you think about our product?", + description: "Please share your thoughts", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: nil + )), + .singleChoice(PostHogMultipleSurveyQuestion( + id: "qID2", + question: "How likely are you to recommend us?", + description: "Please select one option", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 1, + branching: nil, + translations: nil, + choices: ["Very likely", "Somewhat likely", "Not likely"], + hasOpenChoice: false, + shuffleOptions: false + )), + .rating(PostHogRatingSurveyQuestion( + id: "qID3", + question: "Rate your experience", + description: "1 = Poor, 5 = Excellent", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 2, + branching: nil, + translations: nil, + display: .number, + scale: .fivePoint, + lowerBoundLabel: "Poor", + upperBoundLabel: "Excellent" + )), ] - json["enable_partial_responses"] = enabled - json.merge(properties) { _, new in new } - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(PostHogSurvey.self, from: JSONSerialization.data(withJSONObject: json)) - } - @Test("surveys with a question type the SDK can't render are not shown") - func unsupportedQuestionTypeIsNotRendered() throws { - let integration = PostHogSurveyIntegration() - let first: [String: Any] = ["id": "first", "type": "open", "question": "First?"] - let second: [String: Any] = ["id": "second", "type": "open", "question": "Second?"] - let future: [String: Any] = ["id": "future", "type": "future_question_type", "question": "Future?"] - - #expect(try integration.canRenderSurvey(survey: surveyJSON(questions: [first, second]))) - // Rendering only the known questions would shift `second` to index 1 while branching and - // responses still treat index 1 as `future`, so the survey could never complete. - #expect(try !integration.canRenderSurvey(survey: surveyJSON(questions: [first, future, second]))) - } - - @Test("resumed completion and dismissal preserve seen history", arguments: [false, true]) - func resumedSurveyPreservesSeenHistory(closeWithoutCompleting: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + func getTestSurvey( + id: String = "test-survey-id", + name: String = "Test Survey", + questions: [PostHogSurveyQuestion], + currentIteration: Int? = nil, + currentIterationStartDate: Date? = nil + ) -> PostHogSurvey { + PostHogSurvey( + id: id, + name: name, + type: .popover, + questions: questions, + featureFlagKeys: nil, + linkedFlagKey: nil, + targetingFlagKey: nil, + internalTargetingFlagKey: nil, + conditions: nil, + appearance: nil, + currentIteration: currentIteration, + currentIterationStartDate: currentIterationStartDate, + startDate: Date(), + endDate: nil, + schedule: nil, + translations: nil + ) } - let storage = try #require(postHog.storage) - storage.setDictionary(forKey: .surveySeen, contents: ["seenSurvey_previous": true]) - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let first = try getSurveyIntegration(postHog) - first.setSurveys([survey]) - var matching: [PostHogSurvey] = [] - first.getActiveMatchingSurveys { matching = $0 } - try #require(matching.count == 1) - first.setShownSurvey(survey) - _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) - try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) - try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) - first.uninstall(postHog) - let resumed = try getSurveyIntegration(postHog) - resumed.setSurveys([survey]) - resumed.getActiveMatchingSurveys { matching = $0 } - try #require(matching.count == 1) - resumed.setShownSurvey(survey) - try #require(resumed.testActiveQuestionIndex == 1) - if !closeWithoutCompleting { - _ = resumed.getNextQuestion(index: 1, response: .openEnded("Final")) + + func getSut(resetStorage: Bool = true, installSurveys: Bool = true) -> PostHogSDK { + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = installSurveys + config.disableRemoteConfigForTesting = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + + if resetStorage { PostHogStorage(config).reset() } + return PostHogSDK.with(config) } - resumed.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) - #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - resumed.uninstall(postHog) - } - @Test("a survey shown before a restart starts the wait period without marking it seen") - func shownSurveyStartsWaitPeriod() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + func getSurveyIntegration(_ postHog: PostHogSDK) throws -> PostHogSurveyIntegration { + PostHogSurveyIntegration.clearInstalls() + let integration = PostHogSurveyIntegration() + let installResult = integration.install(postHog) + try #require(installResult == .installed) + // These tests drive callbacks directly; remote refreshes must not replace their fixture surveys. + integration.stop() + return integration } - let storage = try #require(postHog.storage) - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: false, properties: ["start_date": 0]) - let first = try getSurveyIntegration(postHog) - first.setShownSurvey(survey) - first.testHandleSurveyShown(survey: survey.toDisplaySurvey()) - #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] == nil) - first.uninstall(postHog) - let waiting = try partialResponseSurvey(enabled: false, properties: [ - "id": "waiting-survey", "start_date": 0, "conditions": ["seenSurveyWaitPeriodInDays": 7], - ]) - let relaunched = try getSurveyIntegration(postHog) - relaunched.setSurveys([waiting]) - var matching: [PostHogSurvey] = [] - relaunched.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - relaunched.uninstall(postHog) - } - @Test("reset invalidates an attempt before its first answer", arguments: [false, true]) - func resetBeforeFirstAnswer(keepAnonymousId: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + func surveyJSON(questions: [[String: Any]]) throws -> PostHogSurvey { + let json: [String: Any] = [ + "id": "branch-survey", + "name": "Branch survey", + "type": "popover", + "questions": questions, + ] + let data = try JSONSerialization.data(withJSONObject: json) + return try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) } - postHog.config.reuseAnonymousId = keepAnonymousId - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil + + func partialResponseSurvey(enabled: Bool?, branching: [String: Any]? = nil, properties: [String: Any] = [:]) throws -> PostHogSurvey { + var first: [String: Any] = ["id": "first", "type": "open", "question": "First?", "optional": true] + first["branching"] = branching + var json: [String: Any] = [ + "id": "partial-survey", "name": "Partial survey", "type": "popover", + "questions": [first, ["id": "second", "type": "open", "question": "Second?"]], + ] + json["enable_partial_responses"] = enabled + json.merge(properties) { _, new in new } + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(PostHogSurvey.self, from: JSONSerialization.data(withJSONObject: json)) } - integration.setShownSurvey(survey) - postHog.reset() - #expect(integration.getNextQuestion(index: 0, response: .openEnded("Stale")) == nil) - #expect(events.isEmpty) - #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) - integration.uninstall(postHog) - } - @Test("reset in a dismissal hook preserves the new identity's attempt") - func resetDuringDismissalPreservesNewAttempt() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + @Test("surveys with a question type the SDK can't render are not shown") + func unsupportedQuestionTypeIsNotRendered() throws { + let integration = PostHogSurveyIntegration() + let first: [String: Any] = ["id": "first", "type": "open", "question": "First?"] + let second: [String: Any] = ["id": "second", "type": "open", "question": "Second?"] + let future: [String: Any] = ["id": "future", "type": "future_question_type", "question": "Future?"] + + #expect(try integration.canRenderSurvey(survey: surveyJSON(questions: [first, second]))) + // Rendering only the known questions would shift `second` to index 1 while branching and + // responses still treat index 1 as `future`, so the survey could never complete. + #expect(try !integration.canRenderSurvey(survey: surveyJSON(questions: [first, future, second]))) } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var switchedIdentity = false - postHog.config.setBeforeSend { event in - if event.event == "survey dismissed", !switchedIdentity { - switchedIdentity = true - DispatchQueue.global().sync { postHog.reset() } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + + @Test("resumed completion and dismissal preserve seen history", arguments: [false, true]) + func resumedSurveyPreservesSeenHistory(closeWithoutCompleting: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() } - return nil + let storage = try #require(postHog.storage) + storage.setDictionary(forKey: .surveySeen, contents: ["seenSurvey_previous": true]) + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let first = try getSurveyIntegration(postHog) + first.setSurveys([survey]) + var matching: [PostHogSurvey] = [] + first.getActiveMatchingSurveys { matching = $0 } + try #require(matching.count == 1) + first.setShownSurvey(survey) + _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) + try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) + try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) + first.uninstall(postHog) + let resumed = try getSurveyIntegration(postHog) + resumed.setSurveys([survey]) + resumed.getActiveMatchingSurveys { matching = $0 } + try #require(matching.count == 1) + resumed.setShownSurvey(survey) + try #require(resumed.testActiveQuestionIndex == 1) + if !closeWithoutCompleting { + _ = resumed.getNextQuestion(index: 1, response: .openEnded("Final")) + } + resumed.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) + #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) + resumed.uninstall(postHog) } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - let oldSubmission = integration.testActiveSubmissionId - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - try #require(switchedIdentity) - let storage = try #require(postHog.storage) - let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) - #expect(progress.submissionId != oldSubmission) - #expect(progress.responses["$survey_response_first"]?.text == "New identity") - #expect(integration.testActiveSubmissionId == progress.submissionId) - #expect(integration.testActiveQuestionIndex == 1) - integration.uninstall(postHog) - } - @Test("callbacks from an old render cannot mutate a new attempt", arguments: [false, true]) - func staleRenderCallbacks(reset: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil + @Test("a survey shown before a restart starts the wait period without marking it seen") + func shownSurveyStartsWaitPeriod() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: false, properties: ["start_date": 0]) + let first = try getSurveyIntegration(postHog) + first.setShownSurvey(survey) + first.testHandleSurveyShown(survey: survey.toDisplaySurvey()) + #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] == nil) + first.uninstall(postHog) + let waiting = try partialResponseSurvey(enabled: false, properties: [ + "id": "waiting-survey", "start_date": 0, "conditions": ["seenSurveyWaitPeriodInDays": 7], + ]) + let relaunched = try getSurveyIntegration(postHog) + relaunched.setSurveys([waiting]) + var matching: [PostHogSurvey] = [] + relaunched.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) + relaunched.uninstall(postHog) } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("First render")) - let oldCallbacks = integration.testSurveyCallbacks() - if reset { postHog.reset() } - integration.setShownSurvey(survey) - if reset { _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) } - let submissionId = integration.testActiveSubmissionId - let eventCount = events.count - let display = survey.toDisplaySurvey() - oldCallbacks.shown(display) - #expect(oldCallbacks.response(display, 1, .openEnded("Stale response")) == nil) - oldCallbacks.closed(display) - #expect(events.count == eventCount) - #expect(integration.testActiveSubmissionId == submissionId) - let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) - #expect(progress.submissionId == submissionId) - #expect(progress.responses["$survey_response_first"]?.text == (reset ? "New identity" : "First render")) - integration.uninstall(postHog) - } - @Test("SDK reset waits for a survey storage transaction") - func resetSerializesSurveyState() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let integration = try getSurveyIntegration(postHog) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - postHog.identify("identity-before-reset") - let oldDistinctId = postHog.getDistinctId() - let oldAnonymousId = postHog.getAnonymousId() - let resetStarted = DispatchSemaphore(value: 0) - let resetFinished = DispatchSemaphore(value: 0) - let oldGeneration = storage.withSurveyState { generation in - DispatchQueue.global().async { - resetStarted.signal() - postHog.reset() - resetFinished.signal() + @Test("reset invalidates an attempt before its first answer", arguments: [false, true]) + func resetBeforeFirstAnswer(keepAnonymousId: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() } - #expect(resetStarted.wait(timeout: .now() + 5) == .success) - // Reset must not delete identities or progress halfway through a read-modify-write. - #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) - #expect(postHog.getDistinctId() == oldDistinctId) - #expect(storage.getString(forKey: .distinctId) == oldDistinctId) - #expect(postHog.getAnonymousId() == oldAnonymousId) - #expect(storage.getString(forKey: .anonymousId) == oldAnonymousId) - integration.updateSurveyCache([survey], events: [:]) - return generation + postHog.config.reuseAnonymousId = keepAnonymousId + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + integration.setShownSurvey(survey) + postHog.reset() + #expect(integration.getNextQuestion(index: 0, response: .openEnded("Stale")) == nil) + #expect(events.isEmpty) + #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) + integration.uninstall(postHog) } - try #require(resetFinished.wait(timeout: .now() + 5) == .success) - #expect(storage.withSurveyState { $0 } != oldGeneration) - #expect(postHog.getDistinctId() != oldDistinctId) - #expect(postHog.getAnonymousId() != oldAnonymousId) - #expect(storage.getString(forKey: .distinctId) == nil) - #expect(postHog.getDistinctId() == postHog.getAnonymousId()) - #expect(storage.getString(forKey: .anonymousId) == postHog.getAnonymousId()) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) - integration.updateSurveyCache([survey], events: [:]) - #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "New identity") - integration.uninstall(postHog) - } - @Test("reset during a progress read cannot restore or remove another identity's state", arguments: ["load", "save", "remove", "reconcile"], [false, true]) - func resetDuringProgressRead(operation: String, startNewAttempt: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let store = SurveyProgressStore(storage: storage) - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let integration = try getSurveyIntegration(postHog) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - let oldProgress = try #require(store.load(survey)) - storage.testOnSurveyProgressRead = { - storage.testOnSurveyProgressRead = nil - postHog.reset() - if startNewAttempt { - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + @Test("reset in a dismissal hook preserves the new identity's attempt") + func resetDuringDismissalPreservesNewAttempt() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() } - } - switch operation { - case "load": #expect(store.load(survey) == nil) - case "save": store.save(oldProgress, for: survey) - case "remove": store.remove(survey) - default: integration.updateSurveyCache([survey], events: [:]) - } - #expect(storage.testOnSurveyProgressRead == nil) - let progress = store.load(survey) - if startNewAttempt { - let progress = try #require(progress) - #expect(progress.submissionId != oldProgress.submissionId) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var switchedIdentity = false + postHog.config.setBeforeSend { event in + if event.event == "survey dismissed", !switchedIdentity { + switchedIdentity = true + DispatchQueue.global().sync { postHog.reset() } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + } + return nil + } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + let oldSubmission = integration.testActiveSubmissionId + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + try #require(switchedIdentity) + let storage = try #require(postHog.storage) + let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) + #expect(progress.submissionId != oldSubmission) #expect(progress.responses["$survey_response_first"]?.text == "New identity") - } else { - #expect(progress == nil) + #expect(integration.testActiveSubmissionId == progress.submissionId) + #expect(integration.testActiveQuestionIndex == 1) + integration.uninstall(postHog) } - integration.uninstall(postHog) - } - @Test("reset during response validation invalidates the pending answer") - func resetDuringResponseValidation() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil - } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - storage.testOnSurveyProgressRead = { - storage.testOnSurveyProgressRead = nil - postHog.reset() + @Test("callbacks from an old render cannot mutate a new attempt", arguments: [false, true]) + func staleRenderCallbacks(reset: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("First render")) + let oldCallbacks = integration.testSurveyCallbacks() + if reset { postHog.reset() } + integration.setShownSurvey(survey) + if reset { _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) } + let submissionId = integration.testActiveSubmissionId + let eventCount = events.count + let display = survey.toDisplaySurvey() + oldCallbacks.shown(display) + #expect(oldCallbacks.response(display, 1, .openEnded("Stale response")) == nil) + oldCallbacks.closed(display) + #expect(events.count == eventCount) + #expect(integration.testActiveSubmissionId == submissionId) + let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) + #expect(progress.submissionId == submissionId) + #expect(progress.responses["$survey_response_first"]?.text == (reset ? "New identity" : "First render")) + integration.uninstall(postHog) } - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) - #expect(events.count == 1) - #expect(integration.testActiveSubmissionId == nil) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - integration.uninstall(postHog) - } - @Test("dismissal and reset remove saved progress", arguments: [false, true]) - func clearSavedProgress(reset: Bool) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - let store = SurveyProgressStore(storage: try #require(postHog.storage)) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - #expect(store.load(survey)?.questionIndex == 1) - if reset { - postHog.reset() - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) - } else { - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + @Test("SDK reset waits for a survey storage transaction") + func resetSerializesSurveyState() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let integration = try getSurveyIntegration(postHog) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + postHog.identify("identity-before-reset") + let oldDistinctId = postHog.getDistinctId() + let oldAnonymousId = postHog.getAnonymousId() + let resetStarted = DispatchSemaphore(value: 0) + let resetFinished = DispatchSemaphore(value: 0) + let oldGeneration = storage.withSurveyState { generation in + DispatchQueue.global().async { + resetStarted.signal() + postHog.reset() + resetFinished.signal() + } + #expect(resetStarted.wait(timeout: .now() + 5) == .success) + // Reset must not delete identities or progress halfway through a read-modify-write. + #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) + #expect(postHog.getDistinctId() == oldDistinctId) + #expect(storage.getString(forKey: .distinctId) == oldDistinctId) + #expect(postHog.getAnonymousId() == oldAnonymousId) + #expect(storage.getString(forKey: .anonymousId) == oldAnonymousId) + integration.updateSurveyCache([survey], events: [:]) + return generation + } + try #require(resetFinished.wait(timeout: .now() + 5) == .success) + #expect(storage.withSurveyState { $0 } != oldGeneration) + #expect(postHog.getDistinctId() != oldDistinctId) + #expect(postHog.getAnonymousId() != oldAnonymousId) + #expect(storage.getString(forKey: .distinctId) == nil) + #expect(postHog.getDistinctId() == postHog.getAnonymousId()) + #expect(storage.getString(forKey: .anonymousId) == postHog.getAnonymousId()) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + integration.updateSurveyCache([survey], events: [:]) + #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "New identity") + integration.uninstall(postHog) } - #expect(store.load(survey) == nil) - } - @Test("invalid persisted progress is discarded", arguments: ["version", "questionIndex", "questionOrder", "responses"]) - func invalidSavedProgress(field: String) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("reset during a progress read cannot restore or remove another identity's state", arguments: ["load", "save", "remove", "reconcile"], [false, true]) + func resetDuringProgressRead(operation: String, startNewAttempt: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let store = SurveyProgressStore(storage: storage) + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let integration = try getSurveyIntegration(postHog) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + let oldProgress = try #require(store.load(survey)) + storage.testOnSurveyProgressRead = { + storage.testOnSurveyProgressRead = nil + postHog.reset() + if startNewAttempt { + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + } + } + switch operation { + case "load": #expect(store.load(survey) == nil) + case "save": store.save(oldProgress, for: survey) + case "remove": store.remove(survey) + default: integration.updateSurveyCache([survey], events: [:]) + } + #expect(storage.testOnSurveyProgressRead == nil) + let progress = store.load(survey) + if startNewAttempt { + let progress = try #require(progress) + #expect(progress.submissionId != oldProgress.submissionId) + #expect(progress.responses["$survey_response_first"]?.text == "New identity") + } else { + #expect(progress == nil) + } + integration.uninstall(postHog) } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - let storage = try #require(postHog.storage) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - var records = try #require(storage.getDictionary(forKey: .surveyProgress)) - var record = try #require(records["partial-survey/0"] as? [String: Any]) - let invalidValues: [String: Any] = [ - "version": 99, "questionIndex": 9, - "questionOrder": ["open:second", "open:first"], - "responses": ["first": ["type": 99]], - ] - record[field] = invalidValues[field] - records["partial-survey/0"] = record - storage.setDictionary(forKey: .surveyProgress, contents: records) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - #expect(storage.getDictionary(forKey: .surveyProgress)?["partial-survey/0"] == nil) - } - #if os(iOS) - @Test("an invalidated attempt closes the displayed survey") - @MainActor - func invalidatedDisplayQuestion() throws { - let survey = try partialResponseSurvey(enabled: true).toDisplaySurvey(initialQuestionIndex: 1) - let controller = SurveyDisplayController() - controller.onSurveyResponse = { _, _, _ in nil } - controller.showSurvey(survey) - controller.onNextQuestion(index: 1, response: .openEnded("Stale")) - #expect(controller.displayedSurvey == nil) + @Test("reset during response validation invalidates the pending answer") + func resetDuringResponseValidation() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + storage.testOnSurveyProgressRead = { + storage.testOnSurveyProgressRead = nil + postHog.reset() + } + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) + #expect(events.count == 1) + #expect(integration.testActiveSubmissionId == nil) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + integration.uninstall(postHog) } - #endif - @Test("unfinished surveys remain eligible after partial answers mark them seen") - func resumeEligibility() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("dismissal and reset remove saved progress", arguments: [false, true]) + func clearSavedProgress(reset: Bool) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + let store = SurveyProgressStore(storage: try #require(postHog.storage)) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + #expect(store.load(survey)?.questionIndex == 1) + if reset { + postHog.reset() + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) + } else { + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + } + #expect(store.load(survey) == nil) } - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let integration = try getSurveyIntegration(postHog) - integration.setSurveys([survey]) - var matching: [PostHogSurvey] = [] - integration.getActiveMatchingSurveys { matching = $0 } - #expect(matching.count == 1) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - #expect(postHog.storage?.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) - integration.uninstall(postHog) - let resumed = try getSurveyIntegration(postHog) - let withInternalFlag = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) - resumed.setSurveys([withInternalFlag]) - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.count == 1) - let gated = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "linked_flag_key": "disabled-product-flag"]) - resumed.setSurveys([gated]) - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - } - @Test("next branching on the last question completes the survey") - func nextBranchingOnLastQuestionCompletesSurvey() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + @Test("invalid persisted progress is discarded", arguments: ["version", "questionIndex", "questionOrder", "responses"]) + func invalidSavedProgress(field: String) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + let storage = try #require(postHog.storage) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + var records = try #require(storage.getDictionary(forKey: .surveyProgress)) + var record = try #require(records["partial-survey/0"] as? [String: Any]) + let invalidValues: [String: Any] = [ + "version": 99, "questionIndex": 9, + "questionOrder": ["open:second", "open:first"], + "responses": ["first": ["type": 99]], + ] + record[field] = invalidValues[field] + records["partial-survey/0"] = record + storage.setDictionary(forKey: .surveyProgress, contents: records) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + #expect(storage.getDictionary(forKey: .surveyProgress)?["partial-survey/0"] == nil) } - let survey = try surveyJSON(questions: [[ - "id": "only", - "type": "open", - "question": "Only?", - "branching": ["type": "next_question"], - ]]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) - #expect(next == (0, true)) - } - @Test("next branching before the last question advances") - func nextBranchingBeforeLastQuestionAdvances() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + #if os(iOS) + @Test("an invalidated attempt closes the displayed survey") + @MainActor + func invalidatedDisplayQuestion() throws { + let survey = try partialResponseSurvey(enabled: true).toDisplaySurvey(initialQuestionIndex: 1) + let controller = SurveyDisplayController() + controller.onSurveyResponse = { _, _, _ in nil } + controller.showSurvey(survey) + controller.onNextQuestion(index: 1, response: .openEnded("Stale")) + #expect(controller.displayedSurvey == nil) + } + #endif + + @Test("unfinished surveys remain eligible after partial answers mark them seen") + func resumeEligibility() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let integration = try getSurveyIntegration(postHog) + integration.setSurveys([survey]) + var matching: [PostHogSurvey] = [] + integration.getActiveMatchingSurveys { matching = $0 } + #expect(matching.count == 1) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + #expect(postHog.storage?.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) + integration.uninstall(postHog) + let resumed = try getSurveyIntegration(postHog) + let withInternalFlag = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) + resumed.setSurveys([withInternalFlag]) + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.count == 1) + let gated = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "linked_flag_key": "disabled-product-flag"]) + resumed.setSurveys([gated]) + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) } - let survey = try surveyJSON(questions: [ - [ - "id": "first", + + @Test("next branching on the last question completes the survey") + func nextBranchingOnLastQuestionCompletesSurvey() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [[ + "id": "only", "type": "open", - "question": "First?", + "question": "Only?", "branching": ["type": "next_question"], - ], - ["id": "second", "type": "open", "question": "Second?"], - ]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) - #expect(next == (1, false)) - } + ]]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) - @Test("unmapped response branching on the last question completes the survey") - func unmappedResponseBranchingOnLastQuestionCompletesSurvey() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) + #expect(next == (0, true)) } - let survey = try surveyJSON(questions: [[ - "id": "only", - "type": "single_choice", - "question": "Pick", - "choices": ["Yes", "No"], - "branching": ["type": "response_based", "responseValues": ["0": "end"]], - ]]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) - #expect(next == (0, true)) - } - @Test("unmapped response branching before the last question advances") - func unmappedResponseBranchingBeforeLastQuestionAdvances() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + @Test("next branching before the last question advances") + func nextBranchingBeforeLastQuestionAdvances() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [ + [ + "id": "first", + "type": "open", + "question": "First?", + "branching": ["type": "next_question"], + ], + ["id": "second", "type": "open", "question": "Second?"], + ]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + + let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) + #expect(next == (1, false)) } - let survey = try surveyJSON(questions: [ - [ - "id": "first", + + @Test("unmapped response branching on the last question completes the survey") + func unmappedResponseBranchingOnLastQuestionCompletesSurvey() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [[ + "id": "only", "type": "single_choice", "question": "Pick", "choices": ["Yes", "No"], "branching": ["type": "response_based", "responseValues": ["0": "end"]], - ], - ["id": "second", "type": "open", "question": "Second?"], - ]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) - #expect(next == (1, false)) - } + ]]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) - @Test("custom delegates resume only when opted in", arguments: ["legacy", "disabled", "enabled"]) - func customDelegateResume(capability: String) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil - } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) - let first = try getSurveyIntegration(postHog) - first.setShownSurvey(survey) - _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) - let originalId = try #require(first.testActiveSubmissionId) - first.uninstall(postHog) - - let supportsResume = capability == "enabled" - postHog.config._surveysConfig.surveysDelegate = capability == "legacy" - ? LegacySurveyDelegate() : ResumeSurveyDelegate(supportsResume) - let resumed = try getSurveyIntegration(postHog) - defer { resumed.uninstall(postHog) } - resumed.setSurveys([survey]) - var matching: [PostHogSurvey] = [] - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.count == (supportsResume ? 1 : 0)) - - resumed.setShownSurvey(survey) - #expect(resumed.testActiveQuestionIndex == (supportsResume ? 1 : 0)) - #expect((resumed.testActiveSubmissionId == originalId) == supportsResume) - let callbacks = resumed.testSurveyCallbacks() - if !supportsResume { - let next = try #require(callbacks.response(survey.toDisplaySurvey(), 0, .openEnded(nil))) - #expect(next.questionIndex == 1) + let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) + #expect(next == (0, true)) } - let completed = try #require(callbacks.response(survey.toDisplaySurvey(), 1, .openEnded("Final"))) - #expect(completed.isSurveyCompleted) - let sent = try #require(events.last { $0.event == "survey sent" }) - #expect(sent.properties["$survey_response_first"] as? String == (supportsResume ? "Saved" : nil)) - } - @Test("new iterations and ended or removed surveys discard old progress", arguments: [false, true]) - func staleSurveyProgress(ended: Bool) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("unmapped response branching before the last question advances") + func unmappedResponseBranchingBeforeLastQuestionAdvances() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [ + [ + "id": "first", + "type": "single_choice", + "question": "Pick", + "choices": ["Yes", "No"], + "branching": ["type": "response_based", "responseValues": ["0": "end"]], + ], + ["id": "second", "type": "open", "question": "Second?"], + ]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + + let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) + #expect(next == (1, false)) } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 1]) - let nextIteration = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 2]) - let integration = try getSurveyIntegration(postHog) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let store = SurveyProgressStore(storage: try #require(postHog.storage)) - #expect(store.load(survey) != nil) - #expect(store.load(nextIteration) == nil) - store.reconcile([nextIteration]) - #expect(store.load(survey) == nil) - integration.setShownSurvey(nextIteration) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let endedSurvey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "end_date": 1, "current_iteration": 2]) - store.reconcile(ended ? [endedSurvey] : []) - #expect(store.load(nextIteration) == nil) - } - @Test("unavailable config preserves progress while an authoritative empty list removes it") - func unavailableConfigPreservesProgress() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - postHog.config.setBeforeSend { _ in nil } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - integration.setSurveys([survey]) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved before going offline")) - let store = SurveyProgressStore(storage: try #require(postHog.storage)) - let saved = try #require(store.load(survey)) - var callbacks = 0 - integration.decodeAndSetSurveys(remoteConfig: nil) { - callbacks += 1 - #expect($0.isEmpty) - } - #expect(callbacks == 1) - #expect(store.load(survey)?.submissionId == saved.submissionId) - #expect(store.load(survey)?.responses["$survey_response_first"]?.text == "Saved before going offline") - var matching: [PostHogSurvey] = [] - integration.getActiveMatchingSurveys { matching = $0 } - #expect(matching.map(\.id) == [survey.id]) - - integration.decodeAndSetSurveys(remoteConfig: ["surveys": []]) { - callbacks += 1 - #expect($0.isEmpty) + @Test("custom delegates resume only when opted in", arguments: ["legacy", "disabled", "enabled"]) + func customDelegateResume(capability: String) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) + let first = try getSurveyIntegration(postHog) + first.setShownSurvey(survey) + _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) + let originalId = try #require(first.testActiveSubmissionId) + first.uninstall(postHog) + + let supportsResume = capability == "enabled" + postHog.config._surveysConfig.surveysDelegate = capability == "legacy" + ? LegacySurveyDelegate() : ResumeSurveyDelegate(supportsResume) + let resumed = try getSurveyIntegration(postHog) + defer { resumed.uninstall(postHog) } + resumed.setSurveys([survey]) + var matching: [PostHogSurvey] = [] + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.count == (supportsResume ? 1 : 0)) + + resumed.setShownSurvey(survey) + #expect(resumed.testActiveQuestionIndex == (supportsResume ? 1 : 0)) + #expect((resumed.testActiveSubmissionId == originalId) == supportsResume) + let callbacks = resumed.testSurveyCallbacks() + if !supportsResume { + let next = try #require(callbacks.response(survey.toDisplaySurvey(), 0, .openEnded(nil))) + #expect(next.questionIndex == 1) + } + let completed = try #require(callbacks.response(survey.toDisplaySurvey(), 1, .openEnded("Final"))) + #expect(completed.isSurveyCompleted) + let sent = try #require(events.last { $0.event == "survey sent" }) + #expect(sent.properties["$survey_response_first"] as? String == (supportsResume ? "Saved" : nil)) } - #expect(callbacks == 2) - #expect(store.load(survey) == nil) - integration.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - } - @Test("showing a survey alone does not create resumable progress") - func noProgressBeforeAnswer() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("new iterations and ended or removed surveys discard old progress", arguments: [false, true]) + func staleSurveyProgress(ended: Bool) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 1]) + let nextIteration = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 2]) + let integration = try getSurveyIntegration(postHog) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let store = SurveyProgressStore(storage: try #require(postHog.storage)) + #expect(store.load(survey) != nil) + #expect(store.load(nextIteration) == nil) + store.reconcile([nextIteration]) + #expect(store.load(survey) == nil) + integration.setShownSurvey(nextIteration) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let endedSurvey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "end_date": 1, "current_iteration": 2]) + store.reconcile(ended ? [endedSurvey] : []) + #expect(store.load(nextIteration) == nil) } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - integration.setShownSurvey(survey) - #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) - } - @Test("partial responses emit cumulative answers with one submission id", arguments: [true, false, nil] as [Bool?]) - func partialResponses(enabled: Bool?) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: enabled) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { event in - events.append(event) - return nil + @Test("unavailable config preserves progress while an authoritative empty list removes it") + func unavailableConfigPreservesProgress() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + postHog.config.setBeforeSend { _ in nil } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + integration.setSurveys([survey]) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved before going offline")) + let store = SurveyProgressStore(storage: try #require(postHog.storage)) + let saved = try #require(store.load(survey)) + var callbacks = 0 + integration.decodeAndSetSurveys(remoteConfig: nil) { + callbacks += 1 + #expect($0.isEmpty) + } + #expect(callbacks == 1) + #expect(store.load(survey)?.submissionId == saved.submissionId) + #expect(store.load(survey)?.responses["$survey_response_first"]?.text == "Saved before going offline") + var matching: [PostHogSurvey] = [] + integration.getActiveMatchingSurveys { matching = $0 } + #expect(matching.map(\.id) == [survey.id]) + + integration.decodeAndSetSurveys(remoteConfig: ["surveys": []]) { + callbacks += 1 + #expect($0.isEmpty) + } + #expect(callbacks == 2) + #expect(store.load(survey) == nil) + integration.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) } - integration.setShownSurvey(survey) - let first = try #require(integration.getNextQuestion(index: 0, response: .openEnded("First answer"))) - #expect(!first.1) - #expect(!integration.canShowNextSurvey()) - #expect(events.count == (enabled == true ? 1 : 0)) - if enabled == true { - let partial = try #require(events.first) - #expect(partial.event == "survey sent") - #expect(partial.properties["$survey_completed"] as? Bool == false) - #expect(partial.properties["$survey_response_first"] as? String == "First answer") - #expect(partial.properties["$survey_response_second"] == nil) + @Test("showing a survey alone does not create resumable progress") + func noProgressBeforeAnswer() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + integration.setShownSurvey(survey) + #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) } - _ = integration.getNextQuestion(index: 1, response: .openEnded("Second answer")) - #expect(events.count == (enabled == true ? 2 : 1)) - let completed = try #require(events.last) - #expect(completed.event == "survey sent") - #expect(completed.properties["$survey_completed"] as? Bool == true) - #expect(completed.properties["$survey_response_first"] as? String == "First answer") - #expect(completed.properties["$survey_response_second"] as? String == "Second answer") - let submissionId = try #require(completed.properties["$survey_submission_id"] as? String) - #expect(UUID(uuidString: submissionId) != nil) - #expect(events.allSatisfy { $0.properties["$survey_submission_id"] as? String == submissionId }) - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - #expect(events.count == (enabled == true ? 2 : 1)) - } - - @Test("dismissed partial response keeps submission id and next attempt gets a new id") - func partialResponseDismissal() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { event in - events.append(event) - return nil - } + @Test("partial responses emit cumulative answers with one submission id", arguments: [true, false, nil] as [Bool?]) + func partialResponses(enabled: Bool?) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: enabled) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { event in + events.append(event) + return nil + } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let sent = try #require(events.last) - #expect(sent.event == "survey sent") - let submissionId = try #require(sent.properties["$survey_submission_id"] as? String) - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - let dismissed = try #require(events.last) - #expect(dismissed.event == "survey dismissed") - #expect(dismissed.properties["$survey_submission_id"] as? String == submissionId) - #expect(dismissed.properties["$survey_partially_completed"] as? Bool == true) - #expect(dismissed.properties["$survey_response_first"] as? String == "Saved") - - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New answer")) - let nextId = try #require(events.last?.properties["$survey_submission_id"] as? String) - #expect(nextId != submissionId) - #expect(events.count == 3) - } + integration.setShownSurvey(survey) + let first = try #require(integration.getNextQuestion(index: 0, response: .openEnded("First answer"))) + #expect(!first.1) + #expect(!integration.canShowNextSurvey()) + #expect(events.count == (enabled == true ? 1 : 0)) + if enabled == true { + let partial = try #require(events.first) + #expect(partial.event == "survey sent") + #expect(partial.properties["$survey_completed"] as? Bool == false) + #expect(partial.properties["$survey_response_first"] as? String == "First answer") + #expect(partial.properties["$survey_response_second"] == nil) + } - @Test("branching to end marks a partial-enabled survey complete") - func partialResponseBranching() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true, branching: ["type": "end"]) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { event in - events.append(event) - return nil + _ = integration.getNextQuestion(index: 1, response: .openEnded("Second answer")) + #expect(events.count == (enabled == true ? 2 : 1)) + let completed = try #require(events.last) + #expect(completed.event == "survey sent") + #expect(completed.properties["$survey_completed"] as? Bool == true) + #expect(completed.properties["$survey_response_first"] as? String == "First answer") + #expect(completed.properties["$survey_response_second"] as? String == "Second answer") + let submissionId = try #require(completed.properties["$survey_submission_id"] as? String) + #expect(UUID(uuidString: submissionId) != nil) + #expect(events.allSatisfy { $0.properties["$survey_submission_id"] as? String == submissionId }) + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + #expect(events.count == (enabled == true ? 2 : 1)) } - integration.setShownSurvey(survey) - let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded(nil))) - #expect(next.1) - #expect(events.count == 1) - #expect(events.first?.properties["$survey_completed"] as? Bool == true) - #expect(events.first?.properties["$survey_response_second"] == nil) - } - - // MARK: - Survey Shown Event Tests - - @Test("survey shown event has correct event name and properties") - func surveyShownEventHasCorrectNameAndBaseProperties() async throws { - let postHog = getSut() - - let integration = try getSurveyIntegration(postHog) - - let survey = getTestSurvey( - id: "survey-123", - name: "Test Survey Name", - questions: defaultQuestions, - currentIteration: 2, - currentIterationStartDate: Date(timeIntervalSince1970: 1609459200) // 2021-01-01 - ) - - integration.testSendSurveyShownEvent(survey: survey) - - let events = try await getServerEvents(server) - - #expect(events.count == 1) - let event = events[0] - - #expect(event.event == "survey shown") - #expect(event.properties["$survey_name"] as? String == "Test Survey Name") - #expect(event.properties["$survey_id"] as? String == "survey-123") - #expect(event.properties["$survey_iteration"] as? Int == 2) - #expect(event.properties["$survey_iteration_start_date"] as? String == "2021-01-01T00:00:00.000Z") - postHog.close() - postHog.reset() - } - - @Test("survey shown event without iteration has correct properties") - func surveyShownEventWithoutIterationHasCorrectProperties() async throws { - let postHog = getSut() + @Test("dismissed partial response keeps submission id and next attempt gets a new id") + func partialResponseDismissal() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { event in + events.append(event) + return nil + } - let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let sent = try #require(events.last) + #expect(sent.event == "survey sent") + let submissionId = try #require(sent.properties["$survey_submission_id"] as? String) + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + let dismissed = try #require(events.last) + #expect(dismissed.event == "survey dismissed") + #expect(dismissed.properties["$survey_submission_id"] as? String == submissionId) + #expect(dismissed.properties["$survey_partially_completed"] as? Bool == true) + #expect(dismissed.properties["$survey_response_first"] as? String == "Saved") + + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New answer")) + let nextId = try #require(events.last?.properties["$survey_submission_id"] as? String) + #expect(nextId != submissionId) + #expect(events.count == 3) + } - let survey = getTestSurvey( - id: "survey-id-456", - name: "Some Simple Survey", - questions: defaultQuestions - ) + @Test("branching to end marks a partial-enabled survey complete") + func partialResponseBranching() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true, branching: ["type": "end"]) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { event in + events.append(event) + return nil + } + integration.setShownSurvey(survey) + let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded(nil))) + #expect(next.1) + #expect(events.count == 1) + #expect(events.first?.properties["$survey_completed"] as? Bool == true) + #expect(events.first?.properties["$survey_response_second"] == nil) + } - integration.testSendSurveyShownEvent(survey: survey) + // MARK: - Survey Shown Event Tests - let events = try await getServerEvents(server) + @Test("survey shown event has correct event name and properties") + func surveyShownEventHasCorrectNameAndBaseProperties() async throws { + let postHog = getSut() - #expect(events.count == 1) - let event = events[0] + let integration = try getSurveyIntegration(postHog) - #expect(event.event == "survey shown") - #expect(event.properties["$survey_name"] as? String == "Some Simple Survey") - #expect(event.properties["$survey_id"] as? String == "survey-id-456") - #expect(event.properties["$survey_iteration"] == nil) - #expect(event.properties["$survey_iteration_start_date"] == nil) + let survey = getTestSurvey( + id: "survey-123", + name: "Test Survey Name", + questions: defaultQuestions, + currentIteration: 2, + currentIterationStartDate: Date(timeIntervalSince1970: 1609459200) // 2021-01-01 + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyShownEvent(survey: survey) - // MARK: - Survey Sent Event Tests + let events = try await getServerEvents(server) - @Test("survey sent event has correct event name and response properties") - func surveySentEventHasCorrectNameAndResponseProperties() async throws { - let postHog = getSut() + #expect(events.count == 1) + let event = events[0] - let integration = try getSurveyIntegration(postHog) + #expect(event.event == "survey shown") + #expect(event.properties["$survey_name"] as? String == "Test Survey Name") + #expect(event.properties["$survey_id"] as? String == "survey-123") + #expect(event.properties["$survey_iteration"] as? Int == 2) + #expect(event.properties["$survey_iteration_start_date"] as? String == "2021-01-01T00:00:00.000Z") - let survey = getTestSurvey(questions: [ - .open(PostHogOpenSurveyQuestion( - id: "qID1", - question: "What do you think about our product?", - description: "Please share your thoughts", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 0, - branching: nil, - translations: nil - )), - .singleChoice(PostHogMultipleSurveyQuestion( - id: "qID2", - question: "How likely are you to recommend us?", - description: "Please select one option", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 1, - branching: nil, - translations: nil, - choices: ["Very likely", "Somewhat likely", "Not likely"], - hasOpenChoice: false, - shuffleOptions: false - )), - .rating(PostHogRatingSurveyQuestion( - id: "qID3", - question: "Rate your experience", - description: "1 = Poor, 5 = Excellent", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 2, - branching: nil, - translations: nil, - display: .number, - scale: .fivePoint, - lowerBoundLabel: "Poor", - upperBoundLabel: "Excellent" - )), - ]) + postHog.close() + postHog.reset() + } - let responses: [String: PostHogSurveyResponse] = [ - integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), - integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), - integration.testGetResponseKey(questionId: "qID3"): .rating(4), - ] + @Test("survey shown event without iteration has correct properties") + func surveyShownEventWithoutIterationHasCorrectProperties() async throws { + let postHog = getSut() - integration.testSendSurveySentEvent(survey: survey, responses: responses) + let integration = try getSurveyIntegration(postHog) - let events = try await getServerEvents(server) + let survey = getTestSurvey( + id: "survey-id-456", + name: "Some Simple Survey", + questions: defaultQuestions + ) - #expect(events.count == 1) - let event = events[0] + integration.testSendSurveyShownEvent(survey: survey) - #expect(event.event == "survey sent") - #expect(event.properties["$survey_name"] as? String == survey.name) - #expect(event.properties["$survey_id"] as? String == survey.id) + let events = try await getServerEvents(server) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_responded/\(survey.id)"] as? Bool == true) + #expect(events.count == 1) + let event = events[0] - let questions = event.properties["$survey_questions"] as? [[String: Any]] - #expect(questions?.count == 3) + #expect(event.event == "survey shown") + #expect(event.properties["$survey_name"] as? String == "Some Simple Survey") + #expect(event.properties["$survey_id"] as? String == "survey-id-456") + #expect(event.properties["$survey_iteration"] == nil) + #expect(event.properties["$survey_iteration_start_date"] == nil) - #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") - #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") - #expect(event.properties["$survey_response_qID3"] as? String == "4") + postHog.close() + postHog.reset() + } - postHog.close() - postHog.reset() - } + // MARK: - Survey Sent Event Tests - @Test("survey sent event with a single response") - func surveySentEventWithSingleResponse() async throws { - let postHog = getSut() + @Test("survey sent event has correct event name and response properties") + func surveySentEventHasCorrectNameAndResponseProperties() async throws { + let postHog = getSut() - let integration = try getSurveyIntegration(postHog) + let integration = try getSurveyIntegration(postHog) - let survey = getTestSurvey( - id: "single-response-survey", - name: "Single Response Survey", - questions: [ + let survey = getTestSurvey(questions: [ .open(PostHogOpenSurveyQuestion( id: "qID1", question: "What do you think about our product?", @@ -972,272 +890,356 @@ class PostHogSurveyEventsTest { branching: nil, translations: nil )), + .singleChoice(PostHogMultipleSurveyQuestion( + id: "qID2", + question: "How likely are you to recommend us?", + description: "Please select one option", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 1, + branching: nil, + translations: nil, + choices: ["Very likely", "Somewhat likely", "Not likely"], + hasOpenChoice: false, + shuffleOptions: false + )), + .rating(PostHogRatingSurveyQuestion( + id: "qID3", + question: "Rate your experience", + description: "1 = Poor, 5 = Excellent", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 2, + branching: nil, + translations: nil, + display: .number, + scale: .fivePoint, + lowerBoundLabel: "Poor", + upperBoundLabel: "Excellent" + )), + ]) + + let responses: [String: PostHogSurveyResponse] = [ + integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), + integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), + integration.testGetResponseKey(questionId: "qID3"): .rating(4), ] - ) - let responses: [String: PostHogSurveyResponse] = [ - integration.testGetResponseKey(questionId: "qID1"): .openEnded("Excellent product!"), - ] + integration.testSendSurveySentEvent(survey: survey, responses: responses) - integration.testSendSurveySentEvent(survey: survey, responses: responses) + let events = try await getServerEvents(server) - let events = try await getServerEvents(server) + #expect(events.count == 1) + let event = events[0] - #expect(events.count == 1) - let event = events[0] + #expect(event.event == "survey sent") + #expect(event.properties["$survey_name"] as? String == survey.name) + #expect(event.properties["$survey_id"] as? String == survey.id) - #expect(event.event == "survey sent") - #expect(event.properties["$survey_name"] as? String == "Single Response Survey") - #expect(event.properties["$survey_id"] as? String == "single-response-survey") - #expect(event.properties["$survey_response_qID1"] as? String == "Excellent product!") + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_responded/\(survey.id)"] as? Bool == true) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_responded/single-response-survey"] as? Bool == true) + let questions = event.properties["$survey_questions"] as? [[String: Any]] + #expect(questions?.count == 3) - postHog.close() - postHog.reset() - } + #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") + #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") + #expect(event.properties["$survey_response_qID3"] as? String == "4") + + postHog.close() + postHog.reset() + } + + @Test("survey sent event with a single response") + func surveySentEventWithSingleResponse() async throws { + let postHog = getSut() + + let integration = try getSurveyIntegration(postHog) + + let survey = getTestSurvey( + id: "single-response-survey", + name: "Single Response Survey", + questions: [ + .open(PostHogOpenSurveyQuestion( + id: "qID1", + question: "What do you think about our product?", + description: "Please share your thoughts", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: nil + )), + ] + ) + + let responses: [String: PostHogSurveyResponse] = [ + integration.testGetResponseKey(questionId: "qID1"): .openEnded("Excellent product!"), + ] + + integration.testSendSurveySentEvent(survey: survey, responses: responses) - // MARK: - Survey Dismissed Event Tests + let events = try await getServerEvents(server) - @Test("survey dismissed event has correct name and properties") - func surveyDismissedEventHasCorrectNameAndProperties() async throws { - let postHog = getSut() + #expect(events.count == 1) + let event = events[0] - let integration = try getSurveyIntegration(postHog) + #expect(event.event == "survey sent") + #expect(event.properties["$survey_name"] as? String == "Single Response Survey") + #expect(event.properties["$survey_id"] as? String == "single-response-survey") + #expect(event.properties["$survey_response_qID1"] as? String == "Excellent product!") - let survey = getTestSurvey( - id: "dismissed-survey", - name: "Dismissed Survey", - questions: defaultQuestions - ) + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_responded/single-response-survey"] as? Bool == true) - integration.testSendSurveyDismissedEvent(survey: survey) + postHog.close() + postHog.reset() + } - let events = try await getServerEvents(server) + // MARK: - Survey Dismissed Event Tests - #expect(events.count == 1) - let event = events[0] + @Test("survey dismissed event has correct name and properties") + func surveyDismissedEventHasCorrectNameAndProperties() async throws { + let postHog = getSut() - #expect(event.event == "survey dismissed") - #expect(event.properties["$survey_name"] as? String == "Dismissed Survey") - #expect(event.properties["$survey_id"] as? String == "dismissed-survey") + let integration = try getSurveyIntegration(postHog) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_dismissed/dismissed-survey"] as? Bool == true) + let survey = getTestSurvey( + id: "dismissed-survey", + name: "Dismissed Survey", + questions: defaultQuestions + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey) - @Test("survey dismissed event includes responses and partial completion when there are answers") - func surveyDismissedEventIncludesResponsesWhenThereAreAnswers() async throws { - let postHog = getSut() - - let integration = try getSurveyIntegration(postHog) - - let survey = getTestSurvey( - id: "dismissed-responses-survey", - name: "Dismissed Responses Survey", - questions: defaultQuestions - ) - - let responses: [String: PostHogSurveyResponse] = [ - integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), - integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), - integration.testGetResponseKey(questionId: "qID3"): .rating(4), - "$survey_response": .openEnded("Great product!"), - "$survey_response_1": .singleChoice("Very likely"), - "$survey_response_2": .rating(4), - ] + let events = try await getServerEvents(server) - integration.testSendSurveyDismissedEvent(survey: survey, responses: responses) + #expect(events.count == 1) + let event = events[0] - let events = try await getServerEvents(server) + #expect(event.event == "survey dismissed") + #expect(event.properties["$survey_name"] as? String == "Dismissed Survey") + #expect(event.properties["$survey_id"] as? String == "dismissed-survey") - #expect(events.count == 1) - let event = events[0] + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_dismissed/dismissed-survey"] as? Bool == true) - #expect(event.event == "survey dismissed") - #expect(event.properties["$survey_partially_completed"] as? Bool == true) - #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") - #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") - #expect(event.properties["$survey_response_qID3"] as? String == "4") - #expect(event.properties["$survey_response"] as? String == "Great product!") - #expect(event.properties["$survey_response_1"] as? String == "Very likely") - #expect(event.properties["$survey_response_2"] as? String == "4") + postHog.close() + postHog.reset() + } - let questions = event.properties["$survey_questions"] as? [[String: Any]] - #expect(questions?.count == 3) - #expect(questions?[0]["response"] as? String == "Great product!") - #expect(questions?[1]["response"] as? String == "Very likely") - #expect(questions?[2]["response"] as? String == "4") + @Test("survey dismissed event includes responses and partial completion when there are answers") + func surveyDismissedEventIncludesResponsesWhenThereAreAnswers() async throws { + let postHog = getSut() + + let integration = try getSurveyIntegration(postHog) + + let survey = getTestSurvey( + id: "dismissed-responses-survey", + name: "Dismissed Responses Survey", + questions: defaultQuestions + ) + + let responses: [String: PostHogSurveyResponse] = [ + integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), + integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), + integration.testGetResponseKey(questionId: "qID3"): .rating(4), + "$survey_response": .openEnded("Great product!"), + "$survey_response_1": .singleChoice("Very likely"), + "$survey_response_2": .rating(4), + ] - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey, responses: responses) - @Test("survey dismissed event marks partial completion false when there are no answers") - func surveyDismissedEventMarksPartialCompletionFalseWhenThereAreNoAnswers() async throws { - let postHog = getSut() + let events = try await getServerEvents(server) - let integration = try getSurveyIntegration(postHog) + #expect(events.count == 1) + let event = events[0] - let survey = getTestSurvey( - id: "dismissed-empty-survey", - name: "Dismissed Empty Survey", - questions: defaultQuestions - ) + #expect(event.event == "survey dismissed") + #expect(event.properties["$survey_partially_completed"] as? Bool == true) + #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") + #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") + #expect(event.properties["$survey_response_qID3"] as? String == "4") + #expect(event.properties["$survey_response"] as? String == "Great product!") + #expect(event.properties["$survey_response_1"] as? String == "Very likely") + #expect(event.properties["$survey_response_2"] as? String == "4") - integration.testSendSurveyDismissedEvent(survey: survey, responses: [:]) + let questions = event.properties["$survey_questions"] as? [[String: Any]] + #expect(questions?.count == 3) + #expect(questions?[0]["response"] as? String == "Great product!") + #expect(questions?[1]["response"] as? String == "Very likely") + #expect(questions?[2]["response"] as? String == "4") - let events = try await getServerEvents(server) + postHog.close() + postHog.reset() + } - #expect(events.count == 1) - let event = events[0] + @Test("survey dismissed event marks partial completion false when there are no answers") + func surveyDismissedEventMarksPartialCompletionFalseWhenThereAreNoAnswers() async throws { + let postHog = getSut() - #expect(event.event == "survey dismissed") - #expect(event.properties["$survey_partially_completed"] as? Bool == false) + let integration = try getSurveyIntegration(postHog) - let questions = event.properties["$survey_questions"] as? [[String: Any]] - #expect(questions?.count == 3) - #expect(questions?[0]["response"] == nil) - #expect(questions?[1]["response"] == nil) - #expect(questions?[2]["response"] == nil) + let survey = getTestSurvey( + id: "dismissed-empty-survey", + name: "Dismissed Empty Survey", + questions: defaultQuestions + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey, responses: [:]) + + let events = try await getServerEvents(server) - @Test("survey dismissed event with iteration has correct interaction property") - func surveyDismissedEventWithIterationHasCorrectInteractionProperty() async throws { - let postHog = getSut() + #expect(events.count == 1) + let event = events[0] - let integration = try getSurveyIntegration(postHog) + #expect(event.event == "survey dismissed") + #expect(event.properties["$survey_partially_completed"] as? Bool == false) - let survey = getTestSurvey( - id: "iter-dismissed-survey", - name: "Iteration Dismissed Survey", - questions: defaultQuestions, - currentIteration: 2 - ) + let questions = event.properties["$survey_questions"] as? [[String: Any]] + #expect(questions?.count == 3) + #expect(questions?[0]["response"] == nil) + #expect(questions?[1]["response"] == nil) + #expect(questions?[2]["response"] == nil) - integration.testSendSurveyDismissedEvent(survey: survey) + postHog.close() + postHog.reset() + } - let events = try await getServerEvents(server) + @Test("survey dismissed event with iteration has correct interaction property") + func surveyDismissedEventWithIterationHasCorrectInteractionProperty() async throws { + let postHog = getSut() - #expect(events.count == 1) - let event = events[0] + let integration = try getSurveyIntegration(postHog) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_dismissed/iter-dismissed-survey/2"] as? Bool == true) + let survey = getTestSurvey( + id: "iter-dismissed-survey", + name: "Iteration Dismissed Survey", + questions: defaultQuestions, + currentIteration: 2 + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey) + + let events = try await getServerEvents(server) - // MARK: - Base Survey Event Properties Tests + #expect(events.count == 1) + let event = events[0] - @Test("base survey event properties include all required fields") - func baseSurveyEventPropertiesIncludeAllRequiredFields() async throws { - let postHog = getSut() + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_dismissed/iter-dismissed-survey/2"] as? Bool == true) - let integration = try getSurveyIntegration(postHog) + postHog.close() + postHog.reset() + } - let survey = getTestSurvey( - id: "complete-survey-id", - name: "Complete Survey", - questions: defaultQuestions, - currentIteration: 5, - currentIterationStartDate: Date(timeIntervalSince1970: 1640995200) // 2022-01-01 - ) + // MARK: - Base Survey Event Properties Tests - let properties = integration.testGetBaseSurveyEventProperties(for: survey) + @Test("base survey event properties include all required fields") + func baseSurveyEventPropertiesIncludeAllRequiredFields() async throws { + let postHog = getSut() - #expect(properties["$survey_name"] as? String == "Complete Survey") - #expect(properties["$survey_id"] as? String == "complete-survey-id") - #expect(properties["$survey_iteration"] as? Int == 5) - #expect(properties["$survey_iteration_start_date"] as? String == "2022-01-01T00:00:00.000Z") + let integration = try getSurveyIntegration(postHog) - postHog.close() - postHog.reset() - } + let survey = getTestSurvey( + id: "complete-survey-id", + name: "Complete Survey", + questions: defaultQuestions, + currentIteration: 5, + currentIterationStartDate: Date(timeIntervalSince1970: 1640995200) // 2022-01-01 + ) + + let properties = integration.testGetBaseSurveyEventProperties(for: survey) - @Test("base survey event properties exclude nil values") - func baseSurveyEventPropertiesExcludeNilValues() async throws { - let postHog = getSut() + #expect(properties["$survey_name"] as? String == "Complete Survey") + #expect(properties["$survey_id"] as? String == "complete-survey-id") + #expect(properties["$survey_iteration"] as? Int == 5) + #expect(properties["$survey_iteration_start_date"] as? String == "2022-01-01T00:00:00.000Z") - let integration = try getSurveyIntegration(postHog) + postHog.close() + postHog.reset() + } - let survey = getTestSurvey( - id: "minimal-survey-id", - name: "Minimal Survey", - questions: defaultQuestions - ) + @Test("base survey event properties exclude nil values") + func baseSurveyEventPropertiesExcludeNilValues() async throws { + let postHog = getSut() - let properties = integration.testGetBaseSurveyEventProperties(for: survey) + let integration = try getSurveyIntegration(postHog) - #expect(properties["$survey_name"] as? String == "Minimal Survey") - #expect(properties["$survey_id"] as? String == "minimal-survey-id") - #expect(properties["$survey_iteration"] == nil) - #expect(properties["$survey_iteration_start_date"] == nil) + let survey = getTestSurvey( + id: "minimal-survey-id", + name: "Minimal Survey", + questions: defaultQuestions + ) - postHog.close() - postHog.reset() - } + let properties = integration.testGetBaseSurveyEventProperties(for: survey) + + #expect(properties["$survey_name"] as? String == "Minimal Survey") + #expect(properties["$survey_id"] as? String == "minimal-survey-id") + #expect(properties["$survey_iteration"] == nil) + #expect(properties["$survey_iteration_start_date"] == nil) - @Test("survey interaction property formats correctly") - func surveyInteractionPropertyFormatsCorrectly() async throws { - let postHog = getSut() - - let integration = try getSurveyIntegration(postHog) - - let surveyWithoutIteration = getTestSurvey( - id: "test-survey", - name: "Test Survey", - questions: defaultQuestions - ) - - let propertyWithoutIteration = integration.testGetSurveyInteractionProperty( - survey: surveyWithoutIteration, - property: "responded" - ) - #expect(propertyWithoutIteration == "$survey_responded/test-survey") - - let surveyWithIteration = getTestSurvey( - id: "test-survey", - name: "Test Survey", - questions: defaultQuestions, - currentIteration: 3 - ) - - let propertyWithIteration = integration.testGetSurveyInteractionProperty( - survey: surveyWithIteration, - property: "dismissed" - ) - #expect(propertyWithIteration == "$survey_dismissed/test-survey/3") - - postHog.close() - postHog.reset() + postHog.close() + postHog.reset() + } + + @Test("survey interaction property formats correctly") + func surveyInteractionPropertyFormatsCorrectly() async throws { + let postHog = getSut() + + let integration = try getSurveyIntegration(postHog) + + let surveyWithoutIteration = getTestSurvey( + id: "test-survey", + name: "Test Survey", + questions: defaultQuestions + ) + + let propertyWithoutIteration = integration.testGetSurveyInteractionProperty( + survey: surveyWithoutIteration, + property: "responded" + ) + #expect(propertyWithoutIteration == "$survey_responded/test-survey") + + let surveyWithIteration = getTestSurvey( + id: "test-survey", + name: "Test Survey", + questions: defaultQuestions, + currentIteration: 3 + ) + + let propertyWithIteration = integration.testGetSurveyInteractionProperty( + survey: surveyWithIteration, + property: "dismissed" + ) + #expect(propertyWithIteration == "$survey_dismissed/test-survey/3") + + postHog.close() + postHog.reset() + } } -} -private class LegacySurveyDelegate: NSObject, PostHogSurveysDelegate { - func renderSurvey( - _: PostHogDisplaySurvey, - onSurveyShown _: @escaping OnPostHogSurveyShown, - onSurveyResponse _: @escaping OnPostHogSurveyResponse, - onSurveyClosed _: @escaping OnPostHogSurveyClosed - ) {} + private class LegacySurveyDelegate: NSObject, PostHogSurveysDelegate { + func renderSurvey( + _: PostHogDisplaySurvey, + onSurveyShown _: @escaping OnPostHogSurveyShown, + onSurveyResponse _: @escaping OnPostHogSurveyResponse, + onSurveyClosed _: @escaping OnPostHogSurveyClosed + ) {} - func cleanupSurveys() {} -} + func cleanupSurveys() {} + } -private final class ResumeSurveyDelegate: LegacySurveyDelegate { - @objc let supportsSurveyResume: Bool + private final class ResumeSurveyDelegate: LegacySurveyDelegate { + @objc let supportsSurveyResume: Bool - init(_ supportsSurveyResume: Bool) { - self.supportsSurveyResume = supportsSurveyResume - super.init() + init(_ supportsSurveyResume: Bool) { + self.supportsSurveyResume = supportsSurveyResume + super.init() + } } -} +#endif diff --git a/PostHogTests/PostHogSurveyResumeTest.swift b/PostHogTests/PostHogSurveyResumeTest.swift index 7da10dbea..550dc2524 100644 --- a/PostHogTests/PostHogSurveyResumeTest.swift +++ b/PostHogTests/PostHogSurveyResumeTest.swift @@ -2,7 +2,7 @@ import Foundation @testable import PostHog import Testing -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) extension PostHogSurveyEventsTest { @MainActor private func renderingSDK(resetStorage: Bool, language: String) -> (PostHogSDK, PostHogSurveyIntegration, SurveyDisplayController) { diff --git a/PostHogTests/PostHogSurveySharedStorageTest.swift b/PostHogTests/PostHogSurveySharedStorageTest.swift index ba28c10c8..c3c1c8035 100644 --- a/PostHogTests/PostHogSurveySharedStorageTest.swift +++ b/PostHogTests/PostHogSurveySharedStorageTest.swift @@ -1,272 +1,274 @@ -import Foundation -@testable import PostHog -import Testing +#if !SWIFT_PACKAGE || Surveys + import Foundation + @testable import PostHog + import Testing -extension PostHogSurveyEventsTest { - @Test("a failed epoch rotation revokes progress for every storage instance") - func immutableEpochRevokesSharedProgress() throws { - let first = getSut() - defer { first.close() } - let second = getSut(resetStorage: false) - defer { second.close() } - second.config.setBeforeSend { _ in nil } - let storage = try #require(first.storage) - let epochFile = storage.url(forKey: .surveyResetEpoch) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(first) - integration.setShownSurvey(survey) - var events: [PostHogEvent] = [] - first.config.setBeforeSend { events.append($0) - return nil - } - try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: epochFile.path) - defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) + extension PostHogSurveyEventsTest { + @Test("a failed epoch rotation revokes progress for every storage instance") + func immutableEpochRevokesSharedProgress() throws { + let first = getSut() + defer { first.close() } + let second = getSut(resetStorage: false) + defer { second.close() } + second.config.setBeforeSend { _ in nil } + let storage = try #require(first.storage) + let epochFile = storage.url(forKey: .surveyResetEpoch) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(first) + integration.setShownSurvey(survey) + var events: [PostHogEvent] = [] + first.config.setBeforeSend { events.append($0) + return nil + } + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: epochFile.path) + defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) + second.reset() + } second.reset() + #expect(integration.getNextQuestion(index: 0, response: .openEnded("Old process")) == nil) + #expect(events.isEmpty) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) + first.reset() + let recovered = try getSurveyIntegration(second) + recovered.setShownSurvey(survey) + _ = recovered.getNextQuestion(index: 0, response: .openEnded("Recovered")) + #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "Recovered") } - second.reset() - #expect(integration.getNextQuestion(index: 0, response: .openEnded("Old process")) == nil) - #expect(events.isEmpty) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) - first.reset() - let recovered = try getSurveyIntegration(second) - recovered.setShownSurvey(survey) - _ = recovered.getNextQuestion(index: 0, response: .openEnded("Recovered")) - #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "Recovered") - } - @Test("a new shared-storage attempt captures events before resetting SDK warms its identity") - func surveyEventsDuringResetIdentityGap() throws { - let first = getSut() - defer { first.close() } - let storage = try #require(first.storage) - var generateDuringReset: (() -> Void)? - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.disableRemoteConfigForTesting = true - config.captureApplicationLifecycleEvents = false - config.disableQueueTimerForTesting = true - config.getAnonymousId = { uuid in - generateDuringReset?() - // Preserve the identity created by the other process during this callback. - return storage.getString(forKey: .anonymousId).flatMap(UUID.init(uuidString:)) ?? uuid - } - let second = PostHogSDK.with(config) - defer { second.close() } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(first) - var events: [PostHogEvent] = [] - first.config.setBeforeSend { events.append($0) - return nil + @Test("a new shared-storage attempt captures events before resetting SDK warms its identity") + func surveyEventsDuringResetIdentityGap() throws { + let first = getSut() + defer { first.close() } + let storage = try #require(first.storage) + var generateDuringReset: (() -> Void)? + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.disableRemoteConfigForTesting = true + config.captureApplicationLifecycleEvents = false + config.disableQueueTimerForTesting = true + config.getAnonymousId = { uuid in + generateDuringReset?() + // Preserve the identity created by the other process during this callback. + return storage.getString(forKey: .anonymousId).flatMap(UUID.init(uuidString:)) ?? uuid + } + let second = PostHogSDK.with(config) + defer { second.close() } + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(first) + var events: [PostHogEvent] = [] + first.config.setBeforeSend { events.append($0) + return nil + } + var enteredGap = false + generateDuringReset = { + enteredGap = true + #expect(storage.getString(forKey: .anonymousId) == nil) + #expect(storage.getString(forKey: .distinctId) == nil) + integration.setShownSurvey(survey) + integration.testHandleSurveyShown(survey: survey.toDisplaySurvey()) + _ = integration.getNextQuestion(index: 0, response: .openEnded("First")) + _ = integration.getNextQuestion(index: 1, response: .openEnded("Final")) + } + defer { generateDuringReset = nil } + second.reset() + try #require(enteredGap) + #expect(events.map(\.event) == ["survey shown", "survey sent", "survey sent"]) + #expect(events.allSatisfy { $0.distinctId == second.getDistinctId() }) + #expect(events.last?.properties["$survey_response_first"] as? String == "First") } - var enteredGap = false - generateDuringReset = { - enteredGap = true - #expect(storage.getString(forKey: .anonymousId) == nil) - #expect(storage.getString(forKey: .distinctId) == nil) + + @Test("fresh SDK shown events use the newly persisted anonymous identity") + func freshSurveyEventIdentity() throws { + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.disableRemoteConfigForTesting = true + config.captureApplicationLifecycleEvents = false + config.disableQueueTimerForTesting = true + let storage = PostHogStorage(config) + storage.reset() + try #require(storage.getString(forKey: .anonymousId) == nil) + try #require(storage.getString(forKey: .distinctId) == nil) + let postHog = PostHogSDK.with(config) + defer { postHog.close() } + var captured: PostHogEvent? + config.setBeforeSend { captured = $0 + return nil + } + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(survey) integration.testHandleSurveyShown(survey: survey.toDisplaySurvey()) - _ = integration.getNextQuestion(index: 0, response: .openEnded("First")) - _ = integration.getNextQuestion(index: 1, response: .openEnded("Final")) + let event = try #require(captured) + #expect(event.event == "survey shown") + #expect(event.distinctId == storage.getString(forKey: .anonymousId)) } - defer { generateDuringReset = nil } - second.reset() - try #require(enteredGap) - #expect(events.map(\.event) == ["survey shown", "survey sent", "survey sent"]) - #expect(events.allSatisfy { $0.distinctId == second.getDistinctId() }) - #expect(events.last?.properties["$survey_response_first"] as? String == "First") - } - @Test("fresh SDK shown events use the newly persisted anonymous identity") - func freshSurveyEventIdentity() throws { - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.disableRemoteConfigForTesting = true - config.captureApplicationLifecycleEvents = false - config.disableQueueTimerForTesting = true - let storage = PostHogStorage(config) - storage.reset() - try #require(storage.getString(forKey: .anonymousId) == nil) - try #require(storage.getString(forKey: .distinctId) == nil) - let postHog = PostHogSDK.with(config) - defer { postHog.close() } - var captured: PostHogEvent? - config.setBeforeSend { captured = $0 - return nil + @Test("reset atomically replaces a read-only epoch file") + func resetReplacesReadOnlyEpoch() throws { + let postHog = getSut() + defer { postHog.close() } + let storage = try #require(postHog.storage) + let oldEpoch = storage.withSurveyState { $0 } + let epochFile = storage.url(forKey: .surveyResetEpoch) + try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: epochFile.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: epochFile.path) } + postHog.reset() + #expect(storage.getString(forKey: .surveyResetEpoch) != oldEpoch) + #expect(storage.isSurveyGenerationCurrent(oldEpoch) == false) + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity answer")) + let progress = try #require(SurveyProgressStore(storage: PostHogStorage(postHog.config)).load(survey)) + #expect(progress.questionIndex == 1) + #expect(progress.responses["$survey_response_first"]?.text == "New identity answer") } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - integration.testHandleSurveyShown(survey: survey.toDisplaySurvey()) - let event = try #require(captured) - #expect(event.event == "survey shown") - #expect(event.distinctId == storage.getString(forKey: .anonymousId)) - } - - @Test("reset atomically replaces a read-only epoch file") - func resetReplacesReadOnlyEpoch() throws { - let postHog = getSut() - defer { postHog.close() } - let storage = try #require(postHog.storage) - let oldEpoch = storage.withSurveyState { $0 } - let epochFile = storage.url(forKey: .surveyResetEpoch) - try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: epochFile.path) - defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: epochFile.path) } - postHog.reset() - #expect(storage.getString(forKey: .surveyResetEpoch) != oldEpoch) - #expect(storage.isSurveyGenerationCurrent(oldEpoch) == false) - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity answer")) - let progress = try #require(SurveyProgressStore(storage: PostHogStorage(postHog.config)).load(survey)) - #expect(progress.questionIndex == 1) - #expect(progress.responses["$survey_response_first"]?.text == "New identity answer") - } - @Test("progress without a reset epoch is discarded") - func ownerlessProgressIsDiscarded() throws { - let postHog = getSut() - defer { postHog.close() } - postHog.config.setBeforeSend { _ in nil } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Ownerless")) - var records = try #require(storage.getDictionary(forKey: .surveyProgress)) - let key = try #require(records.keys.first) - var record = try #require(records[key] as? [String: Any]) - record.removeValue(forKey: "resetEpoch") - record["version"] = 1 - records[key] = record - storage.setDictionary(forKey: .surveyProgress, contents: records) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - #expect(storage.getDictionary(forKey: .surveyProgress)?.isEmpty == true) - } + @Test("progress without a reset epoch is discarded") + func ownerlessProgressIsDiscarded() throws { + let postHog = getSut() + defer { postHog.close() } + postHog.config.setBeforeSend { _ in nil } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Ownerless")) + var records = try #require(storage.getDictionary(forKey: .surveyProgress)) + let key = try #require(records.keys.first) + var record = try #require(records[key] as? [String: Any]) + record.removeValue(forKey: "resetEpoch") + record["version"] = 1 + records[key] = record + storage.setDictionary(forKey: .surveyProgress, contents: records) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + #expect(storage.getDictionary(forKey: .surveyProgress)?.isEmpty == true) + } - @Test("survey progress fails closed when the shared lock cannot be opened") - func unavailableSurveyCoordination() throws { - let postHog = getSut() - defer { postHog.close() } - postHog.config.setBeforeSend { _ in nil } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let store = SurveyProgressStore(storage: storage) - let progress = try #require(store.load(survey)) - let lockFile = storage.appFolderUrl.appendingPathComponent("posthog.surveyState.lock") - try FileManager.default.removeItem(at: lockFile) - try FileManager.default.createDirectory(at: lockFile, withIntermediateDirectories: false) - defer { try? FileManager.default.removeItem(at: lockFile) } - #expect(store.load(survey) == nil) - store.save(progress, for: survey) - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Rejected")) == nil) - } + @Test("survey progress fails closed when the shared lock cannot be opened") + func unavailableSurveyCoordination() throws { + let postHog = getSut() + defer { postHog.close() } + postHog.config.setBeforeSend { _ in nil } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let store = SurveyProgressStore(storage: storage) + let progress = try #require(store.load(survey)) + let lockFile = storage.appFolderUrl.appendingPathComponent("posthog.surveyState.lock") + try FileManager.default.removeItem(at: lockFile) + try FileManager.default.createDirectory(at: lockFile, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: lockFile) } + #expect(store.load(survey) == nil) + store.save(progress, for: survey) + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Rejected")) == nil) + } - @Test("independent storage instances initialize one durable survey epoch") - func sharedSurveyEpochInitialization() async throws { - let postHog = getSut() - defer { postHog.close() } - let storage = try #require(postHog.storage) - storage.remove(key: .surveyResetEpoch) - let instances = (0 ..< 8).map { _ in PostHogStorage(postHog.config) } - let epochs = await withTaskGroup(of: String.self, returning: [String].self) { group in - for instance in instances { - group.addTask { instance.withSurveyState { $0 } } - } - var epochs: [String] = [] - for await epoch in group { - epochs.append(epoch) + @Test("independent storage instances initialize one durable survey epoch") + func sharedSurveyEpochInitialization() async throws { + let postHog = getSut() + defer { postHog.close() } + let storage = try #require(postHog.storage) + storage.remove(key: .surveyResetEpoch) + let instances = (0 ..< 8).map { _ in PostHogStorage(postHog.config) } + let epochs = await withTaskGroup(of: String.self, returning: [String].self) { group in + for instance in instances { + group.addTask { instance.withSurveyState { $0 } } + } + var epochs: [String] = [] + for await epoch in group { + epochs.append(epoch) + } + return epochs } - return epochs + #expect(Set(epochs).count == 1) + #expect(epochs.first == storage.getString(forKey: .surveyResetEpoch)) } - #expect(Set(epochs).count == 1) - #expect(epochs.first == storage.getString(forKey: .surveyResetEpoch)) - } - @Test("shared storage rejects an old process's progress after reset", arguments: [false, true]) - func sharedStorageResetRejectsStaleProgress(keepAnonymousId: Bool) throws { - let first = getSut() - defer { first.close() } - first.config.setBeforeSend { _ in nil } - if !keepAnonymousId { first.identify("old-process-user") } - let second = getSut(resetStorage: false) - defer { second.reset() - second.close() - } - second.config.reuseAnonymousId = keepAnonymousId - second.config.setBeforeSend { _ in nil } - let firstStorage = try #require(first.storage) - let secondStorage = try #require(second.storage) - try #require(firstStorage !== secondStorage) - try #require(firstStorage.appFolderUrl == secondStorage.appFolderUrl) - let oldIdentity = first.getDistinctId() - try #require(second.getDistinctId() == oldIdentity) - let survey = try partialResponseSurvey(enabled: true) - let oldIntegration = try getSurveyIntegration(first) - oldIntegration.setShownSurvey(survey) - _ = oldIntegration.getNextQuestion(index: 0, response: .openEnded("Old process")) - let firstStore = SurveyProgressStore(storage: firstStorage) - let oldProgress = try #require(firstStore.load(survey)) - second.reset() - #expect((second.getDistinctId() == oldIdentity) == keepAnonymousId) - firstStore.save(oldProgress, for: survey) - #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) - let freshIntegration = try getSurveyIntegration(second) - freshIntegration.setShownSurvey(survey) - _ = freshIntegration.getNextQuestion(index: 0, response: .openEnded("New process")) - let freshSubmission = freshIntegration.testActiveSubmissionId - firstStore.save(oldProgress, for: survey) - #expect(firstStore.load(survey)?.submissionId == freshSubmission) - #expect(oldIntegration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) - let resumed = try getSurveyIntegration(first) - resumed.setShownSurvey(survey) - #expect(resumed.testActiveSubmissionId == freshSubmission) - var captured: PostHogEvent? - first.config.setBeforeSend { captured = $0 - return nil + @Test("shared storage rejects an old process's progress after reset", arguments: [false, true]) + func sharedStorageResetRejectsStaleProgress(keepAnonymousId: Bool) throws { + let first = getSut() + defer { first.close() } + first.config.setBeforeSend { _ in nil } + if !keepAnonymousId { first.identify("old-process-user") } + let second = getSut(resetStorage: false) + defer { second.reset() + second.close() + } + second.config.reuseAnonymousId = keepAnonymousId + second.config.setBeforeSend { _ in nil } + let firstStorage = try #require(first.storage) + let secondStorage = try #require(second.storage) + try #require(firstStorage !== secondStorage) + try #require(firstStorage.appFolderUrl == secondStorage.appFolderUrl) + let oldIdentity = first.getDistinctId() + try #require(second.getDistinctId() == oldIdentity) + let survey = try partialResponseSurvey(enabled: true) + let oldIntegration = try getSurveyIntegration(first) + oldIntegration.setShownSurvey(survey) + _ = oldIntegration.getNextQuestion(index: 0, response: .openEnded("Old process")) + let firstStore = SurveyProgressStore(storage: firstStorage) + let oldProgress = try #require(firstStore.load(survey)) + second.reset() + #expect((second.getDistinctId() == oldIdentity) == keepAnonymousId) + firstStore.save(oldProgress, for: survey) + #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) + let freshIntegration = try getSurveyIntegration(second) + freshIntegration.setShownSurvey(survey) + _ = freshIntegration.getNextQuestion(index: 0, response: .openEnded("New process")) + let freshSubmission = freshIntegration.testActiveSubmissionId + firstStore.save(oldProgress, for: survey) + #expect(firstStore.load(survey)?.submissionId == freshSubmission) + #expect(oldIntegration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) + let resumed = try getSurveyIntegration(first) + resumed.setShownSurvey(survey) + #expect(resumed.testActiveSubmissionId == freshSubmission) + var captured: PostHogEvent? + first.config.setBeforeSend { captured = $0 + return nil + } + _ = resumed.getNextQuestion(index: 1, response: .openEnded("Fresh response")) + let event = try #require(captured) + #expect(event.distinctId == second.getDistinctId()) + #expect(event.properties["$survey_response_first"] as? String == "New process") } - _ = resumed.getNextQuestion(index: 1, response: .openEnded("Fresh response")) - let event = try #require(captured) - #expect(event.distinctId == second.getDistinctId()) - #expect(event.properties["$survey_response_first"] as? String == "New process") - } - @Test("reset in another storage instance waits for an in-flight progress write") - func sharedStorageResetWaitsForWrite() throws { - let first = getSut() - defer { first.close() } - first.config.setBeforeSend { _ in nil } - let second = getSut(resetStorage: false) - defer { second.reset() - second.close() - } - let storage = try #require(first.storage) - let secondStorage = try #require(second.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(first) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old process")) - let store = SurveyProgressStore(storage: storage) - let progress = try #require(store.load(survey)) - let resetStarted = DispatchSemaphore(value: 0) - let resetFinished = DispatchSemaphore(value: 0) - storage.testOnSurveyProgressRead = { - storage.testOnSurveyProgressRead = nil - DispatchQueue.global().async { - resetStarted.signal() - second.reset() - resetFinished.signal() + @Test("reset in another storage instance waits for an in-flight progress write") + func sharedStorageResetWaitsForWrite() throws { + let first = getSut() + defer { first.close() } + first.config.setBeforeSend { _ in nil } + let second = getSut(resetStorage: false) + defer { second.reset() + second.close() + } + let storage = try #require(first.storage) + let secondStorage = try #require(second.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(first) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old process")) + let store = SurveyProgressStore(storage: storage) + let progress = try #require(store.load(survey)) + let resetStarted = DispatchSemaphore(value: 0) + let resetFinished = DispatchSemaphore(value: 0) + storage.testOnSurveyProgressRead = { + storage.testOnSurveyProgressRead = nil + DispatchQueue.global().async { + resetStarted.signal() + second.reset() + resetFinished.signal() + } + #expect(resetStarted.wait(timeout: .now() + 5) == .success) + #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) } - #expect(resetStarted.wait(timeout: .now() + 5) == .success) - #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) + store.save(progress, for: survey) + try #require(resetFinished.wait(timeout: .now() + 5) == .success) + #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) } - store.save(progress, for: survey) - try #require(resetFinished.wait(timeout: .now() + 5) == .success) - #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) } -} +#endif diff --git a/PostHogTests/PostHogSurveySheetDetentsTest.swift b/PostHogTests/PostHogSurveySheetDetentsTest.swift index 012132513..6a2ca3d2e 100644 --- a/PostHogTests/PostHogSurveySheetDetentsTest.swift +++ b/PostHogTests/PostHogSurveySheetDetentsTest.swift @@ -5,7 +5,7 @@ // Created by Anna Garcia on 28/09/2026. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSurveyTranslationsTest.swift b/PostHogTests/PostHogSurveyTranslationsTest.swift index 7eb9f72c6..89b89094d 100644 --- a/PostHogTests/PostHogSurveyTranslationsTest.swift +++ b/PostHogTests/PostHogSurveyTranslationsTest.swift @@ -5,7 +5,7 @@ // Created by PostHog Code on 2026-05-13. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation @testable import PostHog diff --git a/PostHogTests/PostHogSurveysTest.swift b/PostHogTests/PostHogSurveysTest.swift index 3c87843cc..9668023bd 100644 --- a/PostHogTests/PostHogSurveysTest.swift +++ b/PostHogTests/PostHogSurveysTest.swift @@ -5,2555 +5,2557 @@ // Created by Yiannis Josephides on 21/01/2025. // -import Foundation -import OHHTTPStubs -import OHHTTPStubsSwift -@testable import PostHog -import Testing - -@Suite("Test Surveys") -enum PostHogSurveysTest { - @Suite("Auto-submit on selection") - struct AutoSubmit { - private func displayQuestion(type: String, skipSubmit: Bool?, hasOpenChoice: Bool = false, display: String = "number") throws -> PostHogDisplaySurveyQuestion { - var json: [String: Any] = [ - "id": "auto-submit", "type": type, "question": "Choose", - "choices": ["First", "Other"], "hasOpenChoice": hasOpenChoice, - "display": display, "scale": 5, - ] - json["skipSubmitButton"] = skipSubmit - let question = try PostHogApi.jsonDecoder.decode(PostHogSurveyQuestion.self, from: JSONSerialization.data(withJSONObject: json)) - return try #require(question.toDisplayQuestion()) - } - - #if os(iOS) - @Test("rating selection emits one answer immediately and clearing emits none", arguments: [true, false], ["number", "emoji"]) - @MainActor - func ratingSelection(skipSubmit: Bool, display: String) throws { - let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) - var responses: [Int?] = [] - let view = RatingQuestionView(question: question, onNextQuestion: { responses.append($0) }) - view.selection.wrappedValue = 4 - #expect(responses == (skipSubmit ? [4] : [])) - view.selection.wrappedValue = nil - #expect(responses == (skipSubmit ? [4] : [])) - } - - @Test("single-choice selection emits the answer only when eligible", arguments: [true, false], [false, true]) - @MainActor - func choiceSelection(skipSubmit: Bool, hasOpenChoice: Bool) throws { - let question = try #require(displayQuestion(type: "single_choice", skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) - var responses: [String?] = [] - let view = SingleChoiceQuestionView(question: question, onNextQuestion: { responses.append($0) }) - view.selection.wrappedValue = [0] - let expected: [String?] = skipSubmit && !hasOpenChoice ? ["First"] : [] - #expect(responses == expected) - view.selection.wrappedValue = [] - #expect(responses == expected) - } - #endif - - @Test("rating auto-submit survives decoding and display mapping", arguments: [true, false, nil] as [Bool?], ["number", "emoji"]) - func rating(skipSubmit: Bool?, display: String) throws { - let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) - #expect(question.skipSubmitButton == (skipSubmit == true)) - } - - @Test("only single choice without an open choice auto-submits", arguments: [true, false, nil] as [Bool?], [false, true]) - func choices(skipSubmit: Bool?, hasOpenChoice: Bool) throws { - for type in ["single_choice", "multiple_choice"] { - let question = try #require(displayQuestion(type: type, skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) - #expect(question.skipSubmitButton == (skipSubmit == true)) - #expect(question.shouldAutoSubmit == (skipSubmit == true && type == "single_choice" && !hasOpenChoice)) +#if !SWIFT_PACKAGE || Surveys + import Foundation + import OHHTTPStubs + import OHHTTPStubsSwift + @testable import PostHog + import Testing + + @Suite("Test Surveys") + enum PostHogSurveysTest { + @Suite("Auto-submit on selection") + struct AutoSubmit { + private func displayQuestion(type: String, skipSubmit: Bool?, hasOpenChoice: Bool = false, display: String = "number") throws -> PostHogDisplaySurveyQuestion { + var json: [String: Any] = [ + "id": "auto-submit", "type": type, "question": "Choose", + "choices": ["First", "Other"], "hasOpenChoice": hasOpenChoice, + "display": display, "scale": 5, + ] + json["skipSubmitButton"] = skipSubmit + let question = try PostHogApi.jsonDecoder.decode(PostHogSurveyQuestion.self, from: JSONSerialization.data(withJSONObject: json)) + return try #require(question.toDisplayQuestion()) } - } - } - - @Suite("Test decoding surveys from remote config") - struct TestDecodingSurveys { - @Test("survey decodes correctly") - func surveyDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_basic") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - - #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") - #expect(sut.name == "Core Web Vitals feature request") - #expect(sut.type == .popover) - #expect(sut.featureFlagKeys == [ - PostHogSurveyFeatureFlagKeyValue(key: "flag1", value: "linked-flag-key"), - PostHogSurveyFeatureFlagKeyValue(key: "flag2", value: "survey-targeting-flag-key"), - ]) - #expect(sut.targetingFlagKey == "survey-targeting-flag-key") - #expect(sut.internalTargetingFlagKey == "survey-targeting-88af4df282-custom") - #expect(sut.questions.count == 1) - - if case let .open(question) = sut.questions[0] { - #expect(question.id == "300") - #expect(question.question == "What would you like to see in our Core Web Vitals product?") - #expect(question.description == "Core Web Vitals just launched and we're interested in hearing (reading?) from you on what you think we're missing with this product") - #expect(question.originalQuestionIndex == 0) - #expect(question.descriptionContentType == .text) - } else { - throw TestError("Expected basic question type") - } - - #expect(sut.conditions?.url == "core-web-vitals") - #expect(sut.appearance?.position == .right) - #expect(sut.appearance?.fontFamily == "system-ui") - #expect(sut.appearance?.whiteLabel == false) - #expect(sut.appearance?.borderColor == "#c9c6c6") - #expect(sut.appearance?.placeholder == "Start typing...") - #expect(sut.appearance?.backgroundColor == "#eeeded") - #expect(sut.appearance?.ratingButtonColor == "white") - #expect(sut.appearance?.submitButtonColor == "black") - #expect(sut.appearance?.submitButtonTextColor == "white") - #expect(sut.appearance?.thankYouMessageHeader == "Thank you for your feedback!") - #expect(sut.appearance?.displayThankYouMessage == true) - #expect(sut.appearance?.ratingButtonActiveColor == "black") - #expect(sut.appearance?.surveyPopupDelaySeconds == 5) - #expect(sut.startDate.map(ISO8601DateFormatter().string) == "2025-01-16T22:23:38Z") - #expect(sut.endDate == nil) - #expect(sut.currentIteration == 1) - #expect(sut.currentIterationStartDate.map(ISO8601DateFormatter().string) == "2024-12-12T18:58:22Z") - } - @Test("survey with unknown type decodes correctly") - func surveyWithUnknownTypeDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_unknown_type") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + #if os(iOS) + @Test("rating selection emits one answer immediately and clearing emits none", arguments: [true, false], ["number", "emoji"]) + @MainActor + func ratingSelection(skipSubmit: Bool, display: String) throws { + let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) + var responses: [Int?] = [] + let view = RatingQuestionView(question: question, onNextQuestion: { responses.append($0) }) + view.selection.wrappedValue = 4 + #expect(responses == (skipSubmit ? [4] : [])) + view.selection.wrappedValue = nil + #expect(responses == (skipSubmit ? [4] : [])) + } - #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") - #expect(sut.name == "Core Web Vitals feature request") + @Test("single-choice selection emits the answer only when eligible", arguments: [true, false], [false, true]) + @MainActor + func choiceSelection(skipSubmit: Bool, hasOpenChoice: Bool) throws { + let question = try #require(displayQuestion(type: "single_choice", skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) + var responses: [String?] = [] + let view = SingleChoiceQuestionView(question: question, onNextQuestion: { responses.append($0) }) + view.selection.wrappedValue = [0] + let expected: [String?] = skipSubmit && !hasOpenChoice ? ["First"] : [] + #expect(responses == expected) + view.selection.wrappedValue = [] + #expect(responses == expected) + } + #endif - // Verify that an unknown survey type is correctly captured - if case let .unknown(type) = sut.type { - #expect(type == "future_type_not_yet_supported") - } else { - throw TestError("Expected unknown survey type") + @Test("rating auto-submit survives decoding and display mapping", arguments: [true, false, nil] as [Bool?], ["number", "emoji"]) + func rating(skipSubmit: Bool?, display: String) throws { + let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) + #expect(question.skipSubmitButton == (skipSubmit == true)) } - // Verify that other properties are still correctly decoded - #expect(sut.questions.count == 1) - #expect(sut.conditions?.url == "core-web-vitals") - #expect(sut.appearance?.position == .right) - } - - @Test("survey with unknown question type decodes correctly") - func surveyWithUnknownQuestionTypeDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_unknown_question_type") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - - #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") - #expect(sut.name == "Core Web Vitals feature request") - #expect(sut.type == .popover) - #expect(sut.questions.count == 1) - - // Verify that an unknown question type is correctly captured - if case let .unknown(type) = sut.questions[0] { - #expect(type == "future_question_type") - } else { - throw TestError("Expected unknown question type") + @Test("only single choice without an open choice auto-submits", arguments: [true, false, nil] as [Bool?], [false, true]) + func choices(skipSubmit: Bool?, hasOpenChoice: Bool) throws { + for type in ["single_choice", "multiple_choice"] { + let question = try #require(displayQuestion(type: type, skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) + #expect(question.skipSubmitButton == (skipSubmit == true)) + #expect(question.shouldAutoSubmit == (skipSubmit == true && type == "single_choice" && !hasOpenChoice)) + } } - - // Verify that other properties are still correctly decoded - #expect(sut.conditions?.url == "core-web-vitals") - #expect(sut.appearance?.position == .right) } - @Suite("Survey question types decode correctly") - struct SurveyQuestionTypeDecodeTests { - @Test("basic question decodes correctly") - func basicQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_basic") + @Suite("Test decoding surveys from remote config") + struct TestDecodingSurveys { + @Test("survey decodes correctly") + func surveyDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_basic") let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") + #expect(sut.name == "Core Web Vitals feature request") + #expect(sut.type == .popover) + #expect(sut.featureFlagKeys == [ + PostHogSurveyFeatureFlagKeyValue(key: "flag1", value: "linked-flag-key"), + PostHogSurveyFeatureFlagKeyValue(key: "flag2", value: "survey-targeting-flag-key"), + ]) + #expect(sut.targetingFlagKey == "survey-targeting-flag-key") + #expect(sut.internalTargetingFlagKey == "survey-targeting-88af4df282-custom") + #expect(sut.questions.count == 1) + if case let .open(question) = sut.questions[0] { + #expect(question.id == "300") #expect(question.question == "What would you like to see in our Core Web Vitals product?") #expect(question.description == "Core Web Vitals just launched and we're interested in hearing (reading?) from you on what you think we're missing with this product") - #expect(question.descriptionContentType == .text) #expect(question.originalQuestionIndex == 0) + #expect(question.descriptionContentType == .text) } else { - throw TestError("Expected a basic question") + throw TestError("Expected basic question type") } - } - @Test("single-choice question decodes correctly") - func singleChoiceQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_single_choice") + #expect(sut.conditions?.url == "core-web-vitals") + #expect(sut.appearance?.position == .right) + #expect(sut.appearance?.fontFamily == "system-ui") + #expect(sut.appearance?.whiteLabel == false) + #expect(sut.appearance?.borderColor == "#c9c6c6") + #expect(sut.appearance?.placeholder == "Start typing...") + #expect(sut.appearance?.backgroundColor == "#eeeded") + #expect(sut.appearance?.ratingButtonColor == "white") + #expect(sut.appearance?.submitButtonColor == "black") + #expect(sut.appearance?.submitButtonTextColor == "white") + #expect(sut.appearance?.thankYouMessageHeader == "Thank you for your feedback!") + #expect(sut.appearance?.displayThankYouMessage == true) + #expect(sut.appearance?.ratingButtonActiveColor == "black") + #expect(sut.appearance?.surveyPopupDelaySeconds == 5) + #expect(sut.startDate.map(ISO8601DateFormatter().string) == "2025-01-16T22:23:38Z") + #expect(sut.endDate == nil) + #expect(sut.currentIteration == 1) + #expect(sut.currentIterationStartDate.map(ISO8601DateFormatter().string) == "2024-12-12T18:58:22Z") + } + + @Test("survey with unknown type decodes correctly") + func surveyWithUnknownTypeDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_unknown_type") let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .singleChoice(question) = sut.questions[0] { - #expect(question.choices == ["Heck yeah", "Nope, I'm good"]) - #expect(question.question == "Would you be interested in being part of a PostHog community event in your city?") - #expect(question.buttonText == "Submit") - #expect(question.originalQuestionIndex == 0) - #expect(question.descriptionContentType == .text) + #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") + #expect(sut.name == "Core Web Vitals feature request") + + // Verify that an unknown survey type is correctly captured + if case let .unknown(type) = sut.type { + #expect(type == "future_type_not_yet_supported") } else { - throw TestError("Expected a single choice question") + throw TestError("Expected unknown survey type") } + + // Verify that other properties are still correctly decoded + #expect(sut.questions.count == 1) + #expect(sut.conditions?.url == "core-web-vitals") + #expect(sut.appearance?.position == .right) } - @Test("multiple-choice question decodes correctly") - func multipleChoiceQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_multiple_choice") + @Test("survey with unknown question type decodes correctly") + func surveyWithUnknownQuestionTypeDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_unknown_question_type") let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .multipleChoice(question) = sut.questions[0] { - #expect(question.choices == [ - "Meet new people", - "Build stuff (hackathon style)", - "Product growth hack (evolve your existing product, not start from scratch)", - "Learn things (talks, discussions)", - "Eat, drink and vibe (chill)", - "Something else", - ]) - #expect(question.question == "Let's say there's a meetup. What would you hope to get out of it? ") - #expect(question.buttonText == "Submit") - #expect(question.hasOpenChoice == true) - #expect(question.originalQuestionIndex == 2) - #expect(question.descriptionContentType == .text) + #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") + #expect(sut.name == "Core Web Vitals feature request") + #expect(sut.type == .popover) + #expect(sut.questions.count == 1) + + // Verify that an unknown question type is correctly captured + if case let .unknown(type) = sut.questions[0] { + #expect(type == "future_question_type") } else { - throw TestError("Expected a multiple choice question") + throw TestError("Expected unknown question type") } - } - @Test("link question decodes correctly") - func linkQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_link") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + // Verify that other properties are still correctly decoded + #expect(sut.conditions?.url == "core-web-vitals") + #expect(sut.appearance?.position == .right) + } + + @Suite("Survey question types decode correctly") + struct SurveyQuestionTypeDecodeTests { + @Test("basic question decodes correctly") + func basicQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_basic") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .open(question) = sut.questions[0] { + #expect(question.question == "What would you like to see in our Core Web Vitals product?") + #expect(question.description == "Core Web Vitals just launched and we're interested in hearing (reading?) from you on what you think we're missing with this product") + #expect(question.descriptionContentType == .text) + #expect(question.originalQuestionIndex == 0) + } else { + throw TestError("Expected a basic question") + } + } - if case let .link(question) = sut.questions[0] { - #expect(question.link == "https://cal.com/david-posthog/user-interview") - #expect(question.question == "Share your thoughts") - #expect(question.buttonText == "Schedule") - #expect(question.description == "We'd love to get your first impressions of the feature on a video call") - } else { - throw TestError("Expected a link question") + @Test("single-choice question decodes correctly") + func singleChoiceQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_single_choice") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .singleChoice(question) = sut.questions[0] { + #expect(question.choices == ["Heck yeah", "Nope, I'm good"]) + #expect(question.question == "Would you be interested in being part of a PostHog community event in your city?") + #expect(question.buttonText == "Submit") + #expect(question.originalQuestionIndex == 0) + #expect(question.descriptionContentType == .text) + } else { + throw TestError("Expected a single choice question") + } } - } - @Test("rating question decodes correctly") - func ratingQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_rating") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("multiple-choice question decodes correctly") + func multipleChoiceQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_multiple_choice") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .multipleChoice(question) = sut.questions[0] { + #expect(question.choices == [ + "Meet new people", + "Build stuff (hackathon style)", + "Product growth hack (evolve your existing product, not start from scratch)", + "Learn things (talks, discussions)", + "Eat, drink and vibe (chill)", + "Something else", + ]) + #expect(question.question == "Let's say there's a meetup. What would you hope to get out of it? ") + #expect(question.buttonText == "Submit") + #expect(question.hasOpenChoice == true) + #expect(question.originalQuestionIndex == 2) + #expect(question.descriptionContentType == .text) + } else { + throw TestError("Expected a multiple choice question") + } + } - if case let .rating(question) = sut.questions[0] { - #expect(question.scale == .tenPoint) - #expect(question.display == .number) - #expect(question.question == "How likely are you to recommend us to a friend?") - #expect(question.lowerBoundLabel == "Unlikely") - #expect(question.upperBoundLabel == "Very likely") - #expect(question.originalQuestionIndex == 0) - #expect(question.descriptionContentType == .text) - } else { - throw TestError("Expected a rating question") + @Test("link question decodes correctly") + func linkQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_link") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .link(question) = sut.questions[0] { + #expect(question.link == "https://cal.com/david-posthog/user-interview") + #expect(question.question == "Share your thoughts") + #expect(question.buttonText == "Schedule") + #expect(question.description == "We'd love to get your first impressions of the feature on a video call") + } else { + throw TestError("Expected a link question") + } } - } - @Test("rating question decodes without bound labels") - func ratingQuestionDecodesWithoutBoundLabels() throws { - let data = try loadFixture("fixture_survey_question_rating_no_bounds") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("rating question decodes correctly") + func ratingQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_rating") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .rating(question) = sut.questions[0] { + #expect(question.scale == .tenPoint) + #expect(question.display == .number) + #expect(question.question == "How likely are you to recommend us to a friend?") + #expect(question.lowerBoundLabel == "Unlikely") + #expect(question.upperBoundLabel == "Very likely") + #expect(question.originalQuestionIndex == 0) + #expect(question.descriptionContentType == .text) + } else { + throw TestError("Expected a rating question") + } + } - if case let .rating(question) = sut.questions[0] { - #expect(question.scale == .tenPoint) - #expect(question.display == .number) - #expect(question.lowerBoundLabel == nil) - #expect(question.upperBoundLabel == nil) - } else { - throw TestError("Expected a rating question") + @Test("rating question decodes without bound labels") + func ratingQuestionDecodesWithoutBoundLabels() throws { + let data = try loadFixture("fixture_survey_question_rating_no_bounds") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .rating(question) = sut.questions[0] { + #expect(question.scale == .tenPoint) + #expect(question.display == .number) + #expect(question.lowerBoundLabel == nil) + #expect(question.upperBoundLabel == nil) + } else { + throw TestError("Expected a rating question") + } } } - } - @Suite("Question branching decodes correctly") - struct QuestionBranchingDecodesCorrectly { - @Test("next branching decodes correctly") - func nextBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_next") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Question branching decodes correctly") + struct QuestionBranchingDecodesCorrectly { + @Test("next branching decodes correctly") + func nextBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_next") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case .next = sut.questions[0].branching { - // nothing - } else { - throw TestError("Expected next branching") + if case .next = sut.questions[0].branching { + // nothing + } else { + throw TestError("Expected next branching") + } } - } - @Test("end branching decodes correctly") - func endBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_end") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("end branching decodes correctly") + func endBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_end") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case .end = sut.questions[0].branching { - // nothing - } else { - throw TestError("Expected end branching") + if case .end = sut.questions[0].branching { + // nothing + } else { + throw TestError("Expected end branching") + } } - } - @Test("specific question branching decodes correctly") - func specificQuestionBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_specific") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("specific question branching decodes correctly") + func specificQuestionBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_specific") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .specificQuestion(index: index) = sut.questions[0].branching { - #expect(index == 2) - } else { - throw TestError("Expected specific question branching") + if case let .specificQuestion(index: index) = sut.questions[0].branching { + #expect(index == 2) + } else { + throw TestError("Expected specific question branching") + } } - } - @Test("response-based branching decodes correctly") - func responseBasedBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_response_based") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("response-based branching decodes correctly") + func responseBasedBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_response_based") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .responseBased(values) = sut.questions[0].branching { - #expect(values["0"] as? Int == 1) - #expect(values["1"] as? String == "end") - } else { - throw TestError("Expected response based branching") + if case let .responseBased(values) = sut.questions[0].branching { + #expect(values["0"] as? Int == 1) + #expect(values["1"] as? String == "end") + } else { + throw TestError("Expected response based branching") + } } - } - - @Test("response-based with linkert branching decodes correctly") - func responseBasedWithLinkertBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_response_based_linkert") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .responseBased(values) = sut.questions[0].branching { - #expect(values["negative"] as? Int == 1) - #expect(values["neutral"] as? Int == 2) - #expect(values["positive"] as? Int == 3) - } else { - throw TestError("Expected response based branching") + @Test("response-based with linkert branching decodes correctly") + func responseBasedWithLinkertBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_response_based_linkert") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .responseBased(values) = sut.questions[0].branching { + #expect(values["negative"] as? Int == 1) + #expect(values["neutral"] as? Int == 2) + #expect(values["positive"] as? Int == 3) + } else { + throw TestError("Expected response based branching") + } } - } - @Test("response-based with empty values branching decodes correctly") - func responseBasedWithEmptyValuesBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_response_based_empty") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("response-based with empty values branching decodes correctly") + func responseBasedWithEmptyValuesBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_response_based_empty") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .responseBased(values) = sut.questions[0].branching { - #expect(values.isEmpty) - } else { - throw TestError("Expected response based branching") + if case let .responseBased(values) = sut.questions[0].branching { + #expect(values.isEmpty) + } else { + throw TestError("Expected response based branching") + } } } - } - @Suite("Question display conditions decodes correctly") - struct DisplayConditionsDecodesCorrectly { - @Test("event condition decodes correctly") - func eventConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_event") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Question display conditions decodes correctly") + struct DisplayConditionsDecodesCorrectly { + @Test("event condition decodes correctly") + func eventConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_event") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let events = sut.conditions?.events { - #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) - } else { - throw TestError("Expected event display condition") + if let events = sut.conditions?.events { + #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) + } else { + throw TestError("Expected event display condition") + } } - } - @Test("repeated event condition decodes correctly") - func repeatedEventConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_event_repeated") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("repeated event condition decodes correctly") + func repeatedEventConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_event_repeated") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let events = sut.conditions?.events { - #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) - #expect(events.repeatedActivation == true) - } else { - throw TestError("Expected repeated event display condition") + if let events = sut.conditions?.events { + #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) + #expect(events.repeatedActivation == true) + } else { + throw TestError("Expected repeated event display condition") + } } - } - @Test("event condition with property filters decodes correctly") - func eventConditionWithPropertyFiltersDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_event_property_filters") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("event condition with property filters decodes correctly") + func eventConditionWithPropertyFiltersDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_event_property_filters") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if let events = sut.conditions?.events { + #expect(events.values.count == 1) + #expect(events.values[0].name == "purchase_completed") + + let filters = events.values[0].propertyFilters + #expect(filters != nil) + #expect(filters?["amount"]?.matchOperator == .gt) + #expect(filters?["amount"]?.values == ["100"]) + #expect(filters?["category"]?.matchOperator == .exact) + #expect(filters?["category"]?.values == ["electronics"]) + } else { + throw TestError("Expected event display condition with property filters") + } + } - if let events = sut.conditions?.events { - #expect(events.values.count == 1) - #expect(events.values[0].name == "purchase_completed") + @Test("device type condition decodes correctly") + func deviceTypeConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_device_type") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - let filters = events.values[0].propertyFilters - #expect(filters != nil) - #expect(filters?["amount"]?.matchOperator == .gt) - #expect(filters?["amount"]?.values == ["100"]) - #expect(filters?["category"]?.matchOperator == .exact) - #expect(filters?["category"]?.values == ["electronics"]) - } else { - throw TestError("Expected event display condition with property filters") + if let types = sut.conditions?.deviceTypes { + #expect(types == ["Mobile"]) + } else { + throw TestError("Expected event display condition") + } } - } - @Test("device type condition decodes correctly") - func deviceTypeConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_device_type") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("url condition decodes correctly") + func urlConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_url") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let types = sut.conditions?.deviceTypes { - #expect(types == ["Mobile"]) - } else { - throw TestError("Expected event display condition") + if let url = sut.conditions?.url { + #expect(url == "ScreenName") + } else { + throw TestError("Expected url display condition") + } } } - @Test("url condition decodes correctly") - func urlConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_url") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Url match types decode correctly") + struct UrlMatchTypeDecodesCorrectly { + @Test("exact url match type decodes correctly") + func exactUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_exact") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url { - #expect(url == "ScreenName") - } else { - throw TestError("Expected url display condition") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .exact) + } else { + throw TestError("Expected url match type") + } } - } - } - @Suite("Url match types decode correctly") - struct UrlMatchTypeDecodesCorrectly { - @Test("exact url match type decodes correctly") - func exactUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_exact") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("is_not url match type decodes correctly") + func isNotRegexUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_is_not") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .exact) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .isNot) + } else { + throw TestError("Expected url match type") + } } - } - @Test("is_not url match type decodes correctly") - func isNotRegexUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_is_not") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("icontains url match type decodes correctly") + func iContainsUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_icontains") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .isNot) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .iContains) + } else { + throw TestError("Expected url match type") + } } - } - @Test("icontains url match type decodes correctly") - func iContainsUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_icontains") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("not_icontains url match type decodes correctly") + func notIContainsUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_not_icontains") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .iContains) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .notIContains) + } else { + throw TestError("Expected url match type") + } } - } - @Test("not_icontains url match type decodes correctly") - func notIContainsUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_not_icontains") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("regex url match type decodes correctly") + func regexUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_regex") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .notIContains) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .regex) + } else { + throw TestError("Expected url match type") + } } - } - @Test("regex url match type decodes correctly") - func regexUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_regex") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("not_regex url match type decodes correctly") + func notRegexUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_not_regex") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .regex) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .notRegex) + } else { + throw TestError("Expected url match type") + } } } + } - @Test("not_regex url match type decodes correctly") - func notRegexUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_not_regex") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Test SurveyUrlMatchType match function") + struct TestSurveyUrlMatching { + static let regexMap: [(url: String, regex: String, shouldMatch: Bool)] = [ + // url + (url: "https://example.com/survey/123", regex: "https://example.com/survey/(\\d+)", shouldMatch: true), + // screen name + (url: "MyScreenName", regex: "^My", shouldMatch: true), + // case sensitivity + (url: "myScreenName", regex: "^My", shouldMatch: false), + // special characters + (url: "User.Profile-Page/123", regex: "\\.[A-Za-z]+-[A-Za-z]+/\\d+", shouldMatch: true), + // empty strings + (url: "", regex: ".*", shouldMatch: true), + // word boundaries + (url: "UserProfile", regex: "\\bProfile\\b", shouldMatch: false), + (url: "User Profile", regex: "\\bProfile\\b", shouldMatch: true), + ] - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .notRegex) - } else { - throw TestError("Expected url match type") - } + @Test("matches regex correctly", arguments: regexMap) + func matchesRegex(url: String, regex: String, shouldMatch: Bool) { + let sut: PostHogSurveyMatchType = .regex + let matches = sut.matchFunction + + #expect(matches([regex], url) == shouldMatch) } - } - } - @Suite("Test SurveyUrlMatchType match function") - struct TestSurveyUrlMatching { - static let regexMap: [(url: String, regex: String, shouldMatch: Bool)] = [ - // url - (url: "https://example.com/survey/123", regex: "https://example.com/survey/(\\d+)", shouldMatch: true), - // screen name - (url: "MyScreenName", regex: "^My", shouldMatch: true), - // case sensitivity - (url: "myScreenName", regex: "^My", shouldMatch: false), - // special characters - (url: "User.Profile-Page/123", regex: "\\.[A-Za-z]+-[A-Za-z]+/\\d+", shouldMatch: true), - // empty strings - (url: "", regex: ".*", shouldMatch: true), - // word boundaries - (url: "UserProfile", regex: "\\bProfile\\b", shouldMatch: false), - (url: "User Profile", regex: "\\bProfile\\b", shouldMatch: true), - ] - - @Test("matches regex correctly", arguments: regexMap) - func matchesRegex(url: String, regex: String, shouldMatch: Bool) { - let sut: PostHogSurveyMatchType = .regex - let matches = sut.matchFunction - - #expect(matches([regex], url) == shouldMatch) - } + @Test("matches not_regex correctly", arguments: regexMap) + func matchesNotRegex(url: String, regex: String, shouldMatch: Bool) { + let sut: PostHogSurveyMatchType = .notRegex + let matches = sut.matchFunction - @Test("matches not_regex correctly", arguments: regexMap) - func matchesNotRegex(url: String, regex: String, shouldMatch: Bool) { - let sut: PostHogSurveyMatchType = .notRegex - let matches = sut.matchFunction + #expect(matches([regex], url) != shouldMatch) + } - #expect(matches([regex], url) != shouldMatch) - } + @Test("matches icontains correctly") + func matchesIcontains() { + let sut: PostHogSurveyMatchType = .iContains + let matches = sut.matchFunction - @Test("matches icontains correctly") - func matchesIcontains() { - let sut: PostHogSurveyMatchType = .iContains - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == true) + #expect(matches(["Hello"], "HeLLo") == true) + #expect(matches(["Hello"], "heLLo") == true) + #expect(matches(["PostHog"], "PostHogTest") == true) + #expect(matches(["Hello"], "PostHog") == false) + } - #expect(matches(["Hello"], "hello") == true) - #expect(matches(["Hello"], "HeLLo") == true) - #expect(matches(["Hello"], "heLLo") == true) - #expect(matches(["PostHog"], "PostHogTest") == true) - #expect(matches(["Hello"], "PostHog") == false) - } + @Test("matches not_icontains correctly") + func matchesNotIcontains() { + let sut: PostHogSurveyMatchType = .notIContains + let matches = sut.matchFunction - @Test("matches not_icontains correctly") - func matchesNotIcontains() { - let sut: PostHogSurveyMatchType = .notIContains - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == false) + #expect(matches(["Hello"], "HeLLo") == false) + #expect(matches(["Hello"], "heLLo") == false) + #expect(matches(["PostHog"], "PostHogTest") == false) + #expect(matches(["Hello"], "PostHog") == true) + } - #expect(matches(["Hello"], "hello") == false) - #expect(matches(["Hello"], "HeLLo") == false) - #expect(matches(["Hello"], "heLLo") == false) - #expect(matches(["PostHog"], "PostHogTest") == false) - #expect(matches(["Hello"], "PostHog") == true) - } + @Test("matches exact correctly") + func matchesExact() { + let sut: PostHogSurveyMatchType = .exact + let matches = sut.matchFunction - @Test("matches exact correctly") - func matchesExact() { - let sut: PostHogSurveyMatchType = .exact - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == false) + #expect(matches(["HeLLo"], "hello") == false) + #expect(matches(["HeLlo"], "hello") == false) + #expect(matches(["Hello"], "Hello") == true) + #expect(matches(["Hello", "PostHog"], "Hello") == true) + } - #expect(matches(["Hello"], "hello") == false) - #expect(matches(["HeLLo"], "hello") == false) - #expect(matches(["HeLlo"], "hello") == false) - #expect(matches(["Hello"], "Hello") == true) - #expect(matches(["Hello", "PostHog"], "Hello") == true) - } + @Test("matches is_not correctly") + func matchesIsNot() { + let sut: PostHogSurveyMatchType = .isNot + let matches = sut.matchFunction - @Test("matches is_not correctly") - func matchesIsNot() { - let sut: PostHogSurveyMatchType = .isNot - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == true) + #expect(matches(["Hello"], "HeLLo") == true) + #expect(matches(["Hello"], "heLLo") == true) + #expect(matches(["Hello"], "Hello") == false) + #expect(matches(["Hello", "PostHog"], "Hello") == false) + } - #expect(matches(["Hello"], "hello") == true) - #expect(matches(["Hello"], "HeLLo") == true) - #expect(matches(["Hello"], "heLLo") == true) - #expect(matches(["Hello"], "Hello") == false) - #expect(matches(["Hello", "PostHog"], "Hello") == false) + @Test("matches gt and lt correctly") + func matchesGtLt() { + #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "150") == true) + #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "50") == false) + #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "50") == true) + #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "150") == false) + #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "not_a_number") == false) + } } - @Test("matches gt and lt correctly") - func matchesGtLt() { - #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "150") == true) - #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "50") == false) - #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "50") == true) - #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "150") == false) - #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "not_a_number") == false) - } - } + @Suite("Test matchPropertyFilters") + class TestMatchPropertyFilters { + let server: MockPostHogServer + let integration: PostHogSurveyIntegration + let postHog: PostHogSDK - @Suite("Test matchPropertyFilters") - class TestMatchPropertyFilters { - let server: MockPostHogServer - let integration: PostHogSurveyIntegration - let postHog: PostHogSDK + init() throws { + server = MockPostHogServer() + server.start() - init() throws { - server = MockPostHogServer() - server.start() + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.captureApplicationLifecycleEvents = false - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.flushAt = 1 - config.disableReachabilityForTesting = true - config.disableQueueTimerForTesting = true - config.captureApplicationLifecycleEvents = false + let storage = PostHogStorage(config) + storage.reset() - let storage = PostHogStorage(config) - storage.reset() + postHog = PostHogSDK.with(config) - postHog = PostHogSDK.with(config) + PostHogSurveyIntegration.clearInstalls() + integration = PostHogSurveyIntegration() + let installOutcome = integration.install(postHog) + try #require(installOutcome == .installed) + } - PostHogSurveyIntegration.clearInstalls() - integration = PostHogSurveyIntegration() - let installOutcome = integration.install(postHog) - try #require(installOutcome == .installed) - } + deinit { + server.stop() + postHog.close() + postHog.reset() + } - deinit { - server.stop() - postHog.close() - postHog.reset() - } + @Test("returns true when no filters, false when property is missing") + func edgeCases() { + #expect(integration.testMatchPropertyFilters(nil, eventProperties: [:]) == true) + let filters = ["category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact)] + #expect(integration.testMatchPropertyFilters(filters, eventProperties: [:]) == false) + } - @Test("returns true when no filters, false when property is missing") - func edgeCases() { - #expect(integration.testMatchPropertyFilters(nil, eventProperties: [:]) == true) - let filters = ["category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact)] - #expect(integration.testMatchPropertyFilters(filters, eventProperties: [:]) == false) + @Test("icontains matches value against filter (case-insensitive)") + func icontainsOperatorWorks() { + let filters = ["query": PostHogPropertyFilter(values: ["product"], matchOperator: .iContains)] + #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "new PRODUCT features"]) == true) + #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "nothing here"]) == false) + } } - @Test("icontains matches value against filter (case-insensitive)") - func icontainsOperatorWorks() { - let filters = ["query": PostHogPropertyFilter(values: ["product"], matchOperator: .iContains)] - #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "new PRODUCT features"]) == true) - #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "nothing here"]) == false) - } - } + @Suite("Test onEvent with property filters") + final class TestOnEventPropertyFilters { + let server: MockPostHogServer + let integration: PostHogSurveyIntegration + let postHog: PostHogSDK - @Suite("Test onEvent with property filters") - final class TestOnEventPropertyFilters { - let server: MockPostHogServer - let integration: PostHogSurveyIntegration - let postHog: PostHogSDK - - init() throws { - server = MockPostHogServer() - server.start() - - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.flushAt = 1 - config.disableReachabilityForTesting = true - config.disableQueueTimerForTesting = true - config.disableRemoteConfigForTesting = true - config.captureApplicationLifecycleEvents = false - - let storage = PostHogStorage(config) - storage.reset() - - postHog = PostHogSDK.with(config) - - PostHogSurveyIntegration.clearInstalls() - integration = PostHogSurveyIntegration() - let installOutcome = integration.install(postHog) - try #require(installOutcome == .installed) - } + init() throws { + server = MockPostHogServer() + server.start() - deinit { - integration.uninstall(postHog) - postHog.close() - postHog.reset() - server.stop() - } + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableRemoteConfigForTesting = true + config.captureApplicationLifecycleEvents = false - @Test("activates survey when event name and properties match") - func eventWithPropertiesActivatesSurvey() { - let condition = PostHogEventCondition( - name: "purchase", - propertyFilters: ["amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt)] - ) - integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + let storage = PostHogStorage(config) + storage.reset() - integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150], timestamp: Date())) - #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) - } + postHog = PostHogSDK.with(config) - @Test("activates survey when event has no property filters (backward compat)") - func eventWithNoFiltersActivatesSurvey() { - let condition = PostHogEventCondition(name: "purchase") - integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + PostHogSurveyIntegration.clearInstalls() + integration = PostHogSurveyIntegration() + let installOutcome = integration.install(postHog) + try #require(installOutcome == .installed) + } - integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: [:], timestamp: Date())) - #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) - } + deinit { + integration.uninstall(postHog) + postHog.close() + postHog.reset() + server.stop() + } - @Test("does not activate survey when one of multiple property filters fails") - func partialFilterMismatchPreventsActivation() { - let condition = PostHogEventCondition( - name: "purchase", - propertyFilters: [ - "amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt), - "category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact), - ] - ) - integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + @Test("activates survey when event name and properties match") + func eventWithPropertiesActivatesSurvey() { + let condition = PostHogEventCondition( + name: "purchase", + propertyFilters: ["amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt)] + ) + integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) - integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150, "category": "clothing"], timestamp: Date())) - #expect(integration.testIsEventActivated(surveyId: "survey-1") == false) - } - } + integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150], timestamp: Date())) + #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) + } - @Suite("Test canActivateRepeatedly") - struct TestCanActivateRepeatedly { - private func getSut( - repeatedActivation: Bool?, - values: [PostHogEventCondition], - schedule: PostHogSurveySchedule? = nil - ) -> PostHogSurvey { - PostHogSurvey( - id: "id", - name: "name", - type: .popover, - questions: [], - featureFlagKeys: nil, - linkedFlagKey: nil, - targetingFlagKey: nil, - internalTargetingFlagKey: nil, - conditions: PostHogSurveyConditions( - url: nil, - urlMatchType: nil, - selector: nil, - deviceTypes: nil, - deviceTypesMatchType: nil, - seenSurveyWaitPeriodInDays: nil, - events: PostHogSurveyEventConditions( - repeatedActivation: repeatedActivation, - values: values + @Test("activates survey when event has no property filters (backward compat)") + func eventWithNoFiltersActivatesSurvey() { + let condition = PostHogEventCondition(name: "purchase") + integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + + integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: [:], timestamp: Date())) + #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) + } + + @Test("does not activate survey when one of multiple property filters fails") + func partialFilterMismatchPreventsActivation() { + let condition = PostHogEventCondition( + name: "purchase", + propertyFilters: [ + "amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt), + "category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact), + ] + ) + integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + + integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150, "category": "clothing"], timestamp: Date())) + #expect(integration.testIsEventActivated(surveyId: "survey-1") == false) + } + } + + @Suite("Test canActivateRepeatedly") + struct TestCanActivateRepeatedly { + private func getSut( + repeatedActivation: Bool?, + values: [PostHogEventCondition], + schedule: PostHogSurveySchedule? = nil + ) -> PostHogSurvey { + PostHogSurvey( + id: "id", + name: "name", + type: .popover, + questions: [], + featureFlagKeys: nil, + linkedFlagKey: nil, + targetingFlagKey: nil, + internalTargetingFlagKey: nil, + conditions: PostHogSurveyConditions( + url: nil, + urlMatchType: nil, + selector: nil, + deviceTypes: nil, + deviceTypesMatchType: nil, + seenSurveyWaitPeriodInDays: nil, + events: PostHogSurveyEventConditions( + repeatedActivation: repeatedActivation, + values: values + ), + linkedFlagVariant: nil, + actions: nil ), - linkedFlagVariant: nil, - actions: nil - ), - appearance: nil, - currentIteration: nil, - currentIterationStartDate: nil, - startDate: nil, - endDate: nil, - schedule: schedule, - translations: nil - ) - } + appearance: nil, + currentIteration: nil, + currentIterationStartDate: nil, + startDate: nil, + endDate: nil, + schedule: schedule, + translations: nil + ) + } - @Test("returns false when survey has no events") - func returnsFalseWhenSurveyHasNoEvents() { - let sut = getSut( - repeatedActivation: true, - values: [] - ) + @Test("returns false when survey has no events") + func returnsFalseWhenSurveyHasNoEvents() { + let sut = getSut( + repeatedActivation: true, + values: [] + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns true when survey has events and repeatedActivation is true") - func returnsTrueWhenSurveyHasEventsAndRepeatedActivationIsAlsoTrue() { - let sut = getSut( - repeatedActivation: true, - values: [ - PostHogEventCondition(name: "first event"), - PostHogEventCondition(name: "second event"), - ] - ) + @Test("returns true when survey has events and repeatedActivation is true") + func returnsTrueWhenSurveyHasEventsAndRepeatedActivationIsAlsoTrue() { + let sut = getSut( + repeatedActivation: true, + values: [ + PostHogEventCondition(name: "first event"), + PostHogEventCondition(name: "second event"), + ] + ) - #expect(sut.canActivateRepeatedly == true) - } + #expect(sut.canActivateRepeatedly == true) + } - @Test("returns false when survey has events but repeatedActivation is false") - func returnsFalseWhenSurveyHasEventsButRepeatedActivationIsFalse() { - let sut = getSut( - repeatedActivation: false, - values: [ - PostHogEventCondition(name: "first event"), - PostHogEventCondition(name: "second event"), - ] - ) + @Test("returns false when survey has events but repeatedActivation is false") + func returnsFalseWhenSurveyHasEventsButRepeatedActivationIsFalse() { + let sut = getSut( + repeatedActivation: false, + values: [ + PostHogEventCondition(name: "first event"), + PostHogEventCondition(name: "second event"), + ] + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns true when schedule is always, even without events") - func returnsTrueWhenScheduleIsAlways() { - let sut = getSut( - repeatedActivation: nil, - values: [], - schedule: .always - ) + @Test("returns true when schedule is always, even without events") + func returnsTrueWhenScheduleIsAlways() { + let sut = getSut( + repeatedActivation: nil, + values: [], + schedule: .always + ) - #expect(sut.canActivateRepeatedly == true) - } + #expect(sut.canActivateRepeatedly == true) + } - @Test("returns false when schedule is once") - func returnsFalseWhenScheduleIsOnce() { - let sut = getSut( - repeatedActivation: nil, - values: [], - schedule: .once - ) + @Test("returns false when schedule is once") + func returnsFalseWhenScheduleIsOnce() { + let sut = getSut( + repeatedActivation: nil, + values: [], + schedule: .once + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns false when schedule is recurring") - func returnsFalseWhenScheduleIsRecurring() { - let sut = getSut( - repeatedActivation: nil, - values: [], - schedule: .recurring - ) + @Test("returns false when schedule is recurring") + func returnsFalseWhenScheduleIsRecurring() { + let sut = getSut( + repeatedActivation: nil, + values: [], + schedule: .recurring + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns true when schedule is always, even with repeatedActivation false") - func returnsTrueWhenScheduleIsAlwaysRegardlessOfRepeatedActivation() { - let sut = getSut( - repeatedActivation: false, - values: [ - PostHogEventCondition(name: "first event"), - ], - schedule: .always - ) + @Test("returns true when schedule is always, even with repeatedActivation false") + func returnsTrueWhenScheduleIsAlwaysRegardlessOfRepeatedActivation() { + let sut = getSut( + repeatedActivation: false, + values: [ + PostHogEventCondition(name: "first event"), + ], + schedule: .always + ) - #expect(sut.canActivateRepeatedly == true) + #expect(sut.canActivateRepeatedly == true) + } } - } - @Suite("Test getActiveMatchingSurveys", .serialized) - class TestGetActiveSurveys { - let server: MockPostHogServer - let postHog: PostHogSDK + @Suite("Test getActiveMatchingSurveys", .serialized) + class TestGetActiveSurveys { + let server: MockPostHogServer + let postHog: PostHogSDK - #if os(iOS) - final class SpySurveysDelegate: NSObject, PostHogSurveysDelegate { - var renderedSurveyIds: [String] = [] - var onRender: (() -> Void)? + #if os(iOS) + final class SpySurveysDelegate: NSObject, PostHogSurveysDelegate { + var renderedSurveyIds: [String] = [] + var onRender: (() -> Void)? + + func renderSurvey( + _ survey: PostHogDisplaySurvey, + onSurveyShown _: @escaping OnPostHogSurveyShown, + onSurveyResponse _: @escaping OnPostHogSurveyResponse, + onSurveyClosed _: @escaping OnPostHogSurveyClosed + ) { + renderedSurveyIds.append(survey.id) + onRender?() + } - func renderSurvey( - _ survey: PostHogDisplaySurvey, - onSurveyShown _: @escaping OnPostHogSurveyShown, - onSurveyResponse _: @escaping OnPostHogSurveyResponse, - onSurveyClosed _: @escaping OnPostHogSurveyClosed - ) { - renderedSurveyIds.append(survey.id) - onRender?() + func cleanupSurveys() {} } - - func cleanupSurveys() {} - } - #endif - - init() { - let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9090") - config._surveys = true - #if os(iOS) - config._surveysConfig.surveysDelegate = SpySurveysDelegate() #endif - config.enableSwizzling = false - config.disableFlushOnBackgroundForTesting = true - config.disableRemoteConfigForTesting = true - let storage = PostHogStorage(config) - storage.reset() - postHog = PostHogSDK.with(config) - server = MockPostHogServer() - server.featureFlags = [ - "linked-flag-enabled": true, - "linked-flag-disabled": false, - "survey-targeting-flag-enabled": true, - "survey-targeting-flag-disabled": false, - "internal-targeting-flag-enabled": true, - "internal-targeting-flag-disabled": false, - ] - server.start() - } - deinit { - let storage = PostHogStorage(postHog.config) - postHog.close() - deleteSafely(storage.appFolderUrl) - server.stop() - } - - let draftSurvey = - """ - { - "id": "draft_id", - "name": "Draft Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } + init() { + let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9090") + config._surveys = true + #if os(iOS) + config._surveysConfig.surveysDelegate = SpySurveysDelegate() + #endif + config.enableSwizzling = false + config.disableFlushOnBackgroundForTesting = true + config.disableRemoteConfigForTesting = true + let storage = PostHogStorage(config) + storage.reset() + postHog = PostHogSDK.with(config) + server = MockPostHogServer() + server.featureFlags = [ + "linked-flag-enabled": true, + "linked-flag-disabled": false, + "survey-targeting-flag-enabled": true, + "survey-targeting-flag-disabled": false, + "internal-targeting-flag-enabled": true, + "internal-targeting-flag-disabled": false, ] + server.start() } - """ - let activeSurvey = - """ - { - "id": "active_id", - "name": "Active Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let completedSurvey = - """ - { - "id": "completed_id", - "name": "Completed Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z", - "end_date": "2025-03-03T09:18:18.376000Z" - } - """ - - let surveyWithDesktopDevice = - """ - { - "id": "desktop_id", - "name": "Active Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "deviceTypes": ["Desktop"], - "deviceTypesMatchType": "icontains" - }, - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithMobileDevice = - """ - { - "id": "mobile_id", - "name": "Active Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "deviceTypes": ["Mobile"], - "deviceTypesMatchType": "icontains" - }, - "start_date": "2024-07-23T09:18:18.376000Z" + deinit { + let storage = PostHogStorage(postHog.config) + postHog.close() + deleteSafely(storage.appFolderUrl) + server.stop() } - """ - let surveyWithSelectorCondition = - """ - { - "id": "selector_id", - "name": "Web-only Survey (selector)", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "selector": "#my-button" - }, - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ + let draftSurvey = + """ + { + "id": "draft_id", + "name": "Draft Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ] + } + """ - let surveyWithUrlCondition = - """ - { - "id": "url_id", - "name": "Web-only Survey (url)", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "url": "https://example.com/pricing", - "urlMatchType": "icontains" - }, - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveysWithFeatureFlags = - """ - { - "name": "survey with feature flags", - "id": "survey-with-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "linked-flag-enabled" }, - { "key": "flag2", "value": "survey-targeting-flag-enabled" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - }, - { - "name": "survey with disabled feature flags", - "id": "survey-with-disabled-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "linked-flag-disabled" }, - { "key": "flag2", "value": "survey-targeting-flag-disabled" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - }, - { - "name": "survey without feature flags", - "id": "survey-without-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveysWithMissingKeysAndValues = - """ - { - "name": "survey with missing keys", - "id": "survey-with-missing-keys", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "", "value": "linked-flag-enabled" }, - { "key": "", "value": "" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - }, - { - "name": "survey with missing values", - "id": "survey-with-missing-values", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "" }, - { "key": "flag2", "value": "" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithEnabledAndDisabledFlags = - """ - { - "name": "survey with disabled and enabled feature flags", - "id": "survey-with-mixed-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "linked-flag-disabled" }, - { "key": "flag2", "value": "linked-flag-enabled" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithEnabledInternalTargetingFlag = - """ - { - "name": "survey with internal flag enabled", - "id": "survey-with-internal-flag-enabled", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "internal_targeting_flag_key": "internal-targeting-flag-enabled", - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithDisabledInternalTargetingFlag = - """ - { - "name": "survey with internal flag disabled", - "id": "survey-with-internal-flag-disabled", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "internal_targeting_flag_key": "internal-targeting-flag-disabled", - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - private func getSut(surveys: [String]) -> PostHogSurveyIntegration { - server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" - let sut = PostHogSurveyIntegration() - PostHogSurveyIntegration.clearInstalls() - let installOutcome = sut.install(postHog) - #expect(installOutcome == .installed) - return sut - } - - private func parseSurveys(_ surveys: String) throws -> [[String: Any]] { - try #require(JSONSerialization.jsonObject(with: Data("[\(surveys)]".utf8)) as? [[String: Any]]) - } - - @Test("returns surveys that are active") - func returnsActiveSurveys() async { - let surveys: [String] = [ - draftSurvey, - activeSurvey, - completedSurvey, - ] + let activeSurvey = + """ + { + "id": "active_id", + "name": "Active Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ - let sut = getSut(surveys: surveys) + let completedSurvey = + """ + { + "id": "completed_id", + "name": "Completed Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "start_date": "2024-07-23T09:18:18.376000Z", + "end_date": "2025-03-03T09:18:18.376000Z" + } + """ - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let surveyWithDesktopDevice = + """ + { + "id": "desktop_id", + "name": "Active Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "deviceTypes": ["Desktop"], + "deviceTypesMatchType": "icontains" + }, + "start_date": "2024-07-23T09:18:18.376000Z" } - } + """ - #expect(matchedSurveys.map(\.id) == ["active_id"]) - } + let surveyWithMobileDevice = + """ + { + "id": "mobile_id", + "name": "Active Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "deviceTypes": ["Mobile"], + "deviceTypesMatchType": "icontains" + }, + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ - @Test("refreshes cached surveys when remote config loads") - func refreshesCachedSurveysWhenRemoteConfigLoads() async throws { - let refreshedSurvey = + let surveyWithSelectorCondition = """ { - "id": "active_id", - "name": "Refreshed Survey", + "id": "selector_id", + "name": "Web-only Survey (selector)", "type": "popover", "questions": [ { "id": "1", "type": "open", - "question": "What do you think?", + "question": "What is a completed survey?", "originalQuestionIndex": 0 } ], "conditions": { - "events": { - "values": [ - { "name": "new-trigger" } - ] + "selector": "#my-button" + }, + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ + + let surveyWithUrlCondition = + """ + { + "id": "url_id", + "name": "Web-only Survey (url)", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 } + ], + "conditions": { + "url": "https://example.com/pricing", + "urlMatchType": "icontains" }, "start_date": "2024-07-23T09:18:18.376000Z" } """ - let initialSurvey = refreshedSurvey - .replacingOccurrences(of: "Refreshed Survey", with: "Initial Survey") - .replacingOccurrences(of: "new-trigger", with: "old-trigger") - let sut = getSut(surveys: [initialSurvey]) - let initialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(returning: $0) + let surveysWithFeatureFlags = + """ + { + "name": "survey with feature flags", + "id": "survey-with-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "linked-flag-enabled" }, + { "key": "flag2", "value": "survey-targeting-flag-enabled" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + }, + { + "name": "survey with disabled feature flags", + "id": "survey-with-disabled-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "linked-flag-disabled" }, + { "key": "flag2", "value": "survey-targeting-flag-disabled" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + }, + { + "name": "survey without feature flags", + "id": "survey-without-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "start_date": "2024-07-23T09:18:18.376000Z" } - } - #expect(initialSurveys.isEmpty) + """ - // Keep the test from presenting survey UI after activating an event. - sut.setShownSurvey(.testInstance(name: "Placeholder")) - sut.testOnEvent(event: PostHogEvent( - event: "old-trigger", - distinctId: "test", - properties: [:], - timestamp: Date() - )) - let activatedInitialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(returning: $0) + let surveysWithMissingKeysAndValues = + """ + { + "name": "survey with missing keys", + "id": "survey-with-missing-keys", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "", "value": "linked-flag-enabled" }, + { "key": "", "value": "" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + }, + { + "name": "survey with missing values", + "id": "survey-with-missing-values", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "" }, + { "key": "flag2", "value": "" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" } - } - #expect(activatedInitialSurveys.map(\.name) == ["Initial Survey"]) + """ + + let surveyWithEnabledAndDisabledFlags = + """ + { + "name": "survey with disabled and enabled feature flags", + "id": "survey-with-mixed-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "linked-flag-disabled" }, + { "key": "flag2", "value": "linked-flag-enabled" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ - server.remoteConfigSurveys = "[\(refreshedSurvey)]" - let remoteConfigLoaded = AsyncLatch() - let token = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in - DispatchQueue.main.async { - remoteConfigLoaded.signal() + let surveyWithEnabledInternalTargetingFlag = + """ + { + "name": "survey with internal flag enabled", + "id": "survey-with-internal-flag-enabled", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "internal_targeting_flag_key": "internal-targeting-flag-enabled", + "start_date": "2024-07-23T09:18:18.376000Z" } - }) - postHog.remoteConfig?.reloadRemoteConfig() - await remoteConfigLoaded.wait() + """ - let surveysBeforeNewTrigger: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(returning: $0) + let surveyWithDisabledInternalTargetingFlag = + """ + { + "name": "survey with internal flag disabled", + "id": "survey-with-internal-flag-disabled", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "internal_targeting_flag_key": "internal-targeting-flag-disabled", + "start_date": "2024-07-23T09:18:18.376000Z" } + """ + + private func getSut(surveys: [String]) -> PostHogSurveyIntegration { + server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" + let sut = PostHogSurveyIntegration() + PostHogSurveyIntegration.clearInstalls() + let installOutcome = sut.install(postHog) + #expect(installOutcome == .installed) + return sut + } + + private func parseSurveys(_ surveys: String) throws -> [[String: Any]] { + try #require(JSONSerialization.jsonObject(with: Data("[\(surveys)]".utf8)) as? [[String: Any]]) } - #expect(surveysBeforeNewTrigger.isEmpty) - sut.testOnEvent(event: PostHogEvent( - event: "new-trigger", - distinctId: "test", - properties: [:], - timestamp: Date() - )) - let refreshedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(returning: $0) + @Test("returns surveys that are active") + func returnsActiveSurveys() async { + let surveys: [String] = [ + draftSurvey, + activeSurvey, + completedSurvey, + ] + + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } + + #expect(matchedSurveys.map(\.id) == ["active_id"]) } - #expect(refreshedSurveys.map(\.name) == ["Refreshed Survey"]) - _ = token - } - #if os(iOS) - @Test("defers showing surveys until a foreground window exists") - func defersShowingSurveysUntilForegroundWindowExists() async { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - let sut = getSut(surveys: [activeSurvey]) - sut.hasActiveSurveyWindow = { false } + @Test("refreshes cached surveys when remote config loads") + func refreshesCachedSurveysWhenRemoteConfigLoads() async throws { + let refreshedSurvey = + """ + { + "id": "active_id", + "name": "Refreshed Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "events": { + "values": [ + { "name": "new-trigger" } + ] + } + }, + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ + let initialSurvey = refreshedSurvey + .replacingOccurrences(of: "Refreshed Survey", with: "Initial Survey") + .replacingOccurrences(of: "new-trigger", with: "old-trigger") - let matchingSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + let sut = getSut(surveys: [initialSurvey]) + let initialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in sut.getActiveMatchingSurveys(forceReload: true) { continuation.resume(returning: $0) } } - await withCheckedContinuation { continuation in - DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + #expect(initialSurveys.isEmpty) + + // Keep the test from presenting survey UI after activating an event. + sut.setShownSurvey(.testInstance(name: "Placeholder")) + sut.testOnEvent(event: PostHogEvent( + event: "old-trigger", + distinctId: "test", + properties: [:], + timestamp: Date() + )) + let activatedInitialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(returning: $0) + } } + #expect(activatedInitialSurveys.map(\.name) == ["Initial Survey"]) - #expect(matchingSurveys.map(\.id) == ["active_id"]) - #expect(delegate.renderedSurveyIds.isEmpty) - - let rendered = AsyncLatch() - delegate.onRender = { rendered.signal() } - sut.hasActiveSurveyWindow = { true } - DI.main.appLifecyclePublisher.onDidBecomeActive.invoke(()) - await rendered.wait() + server.remoteConfigSurveys = "[\(refreshedSurvey)]" + let remoteConfigLoaded = AsyncLatch() + let token = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in + DispatchQueue.main.async { + remoteConfigLoaded.signal() + } + }) + postHog.remoteConfig?.reloadRemoteConfig() + await remoteConfigLoaded.wait() - #expect(delegate.renderedSurveyIds == ["active_id"]) + let surveysBeforeNewTrigger: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(returning: $0) + } + } + #expect(surveysBeforeNewTrigger.isEmpty) + + sut.testOnEvent(event: PostHogEvent( + event: "new-trigger", + distinctId: "test", + properties: [:], + timestamp: Date() + )) + let refreshedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(returning: $0) + } + } + #expect(refreshedSurveys.map(\.name) == ["Refreshed Survey"]) + _ = token } - @Test("waits for fresh feature flags before showing refreshed surveys") - func waitsForFreshFeatureFlagsBeforeShowingRefreshedSurveys() async throws { - server.featureFlags = ["survey-flag": true] - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } + #if os(iOS) + @Test("defers showing surveys until a foreground window exists") + func defersShowingSurveysUntilForegroundWindowExists() async { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + let sut = getSut(surveys: [activeSurvey]) + sut.hasActiveSurveyWindow = { false } + + let matchingSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(returning: $0) + } + } + await withCheckedContinuation { continuation in + DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + } + + #expect(matchingSurveys.map(\.id) == ["active_id"]) + #expect(delegate.renderedSurveyIds.isEmpty) + + let rendered = AsyncLatch() + delegate.onRender = { rendered.signal() } + sut.hasActiveSurveyWindow = { true } + DI.main.appLifecyclePublisher.onDidBecomeActive.invoke(()) + await rendered.wait() + + #expect(delegate.renderedSurveyIds == ["active_id"]) } - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - postHog.config.preloadFeatureFlags = false - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } - server.remoteConfigSurveys = - """ - [{ + @Test("waits for fresh feature flags before showing refreshed surveys") + func waitsForFreshFeatureFlagsBeforeShowingRefreshedSurveys() async throws { + server.featureFlags = ["survey-flag": true] + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } + } + + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + postHog.config.preloadFeatureFlags = false + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + server.remoteConfigSurveys = + """ + [{ + "id": "flagged_survey", + "name": "Flagged Survey", + "type": "popover", + "questions": [{ + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + }], + "linked_flag_key": "survey-flag", + "start_date": "2024-07-23T09:18:18.376000Z" + }] + """ + + server.featureFlags = ["survey-flag": false] + let featureFlagsLoaded = AsyncLatch() + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { _ in + featureFlagsLoaded.signal() + }) + let remoteConfigLoaded = AsyncLatch() + let remoteToken = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in + DispatchQueue.main.async { DispatchQueue.main.async { remoteConfigLoaded.signal() } } + }) + postHog.remoteConfig?.reloadRemoteConfig() + await remoteConfigLoaded.wait() + try #require(remoteConfigLoaded.isSignaled) + #expect(delegate.renderedSurveyIds.isEmpty) + + await featureFlagsLoaded.wait() + try #require(featureFlagsLoaded.isSignaled) + #expect(delegate.renderedSurveyIds.isEmpty) + _ = (sut, remoteToken, flagsToken) + } + + @Test("does not use an older flag refresh for newer surveys") + func doesNotUseOlderFlagRefreshForNewerSurveys() async throws { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + + let survey: [String: Any] = [ "id": "flagged_survey", "name": "Flagged Survey", "type": "popover", - "questions": [{ + "questions": [[ "id": "1", "type": "open", "question": "What do you think?", - "originalQuestionIndex": 0 - }], + "originalQuestionIndex": 0, + ]], "linked_flag_key": "survey-flag", - "start_date": "2024-07-23T09:18:18.376000Z" - }] - """ + "start_date": "2024-07-23T09:18:18.376000Z", + ] + let requestLock = NSLock() + var requestCount = 0 + server.flagsResponseHandler = { _ in + let index = requestLock.withLock { + requestCount += 1 + return requestCount + } + let response = HTTPStubsResponse( + jsonObject: [ + "featureFlags": ["survey-flag": index == 1], + "featureFlagPayloads": [:], + "flags": [:], + ], + statusCode: 200, + headers: nil + ) + if index == 1 { response.responseTime = 0.1 } + return response + } - server.featureFlags = ["survey-flag": false] - let featureFlagsLoaded = AsyncLatch() - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { _ in - featureFlagsLoaded.signal() - }) - let remoteConfigLoaded = AsyncLatch() - let remoteToken = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in - DispatchQueue.main.async { DispatchQueue.main.async { remoteConfigLoaded.signal() } } - }) - postHog.remoteConfig?.reloadRemoteConfig() - await remoteConfigLoaded.wait() - try #require(remoteConfigLoaded.isSignaled) - #expect(delegate.renderedSurveyIds.isEmpty) + let flagsLoaded = AsyncLatch() + let loadedLock = NSLock() + var loadedCount = 0 + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in + guard flags != nil else { return } + let isSecondLoad = loadedLock.withLock { + loadedCount += 1 + return loadedCount == 2 + } + if isSecondLoad { flagsLoaded.signal() } + }) + + postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) + postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) + postHog.remoteConfig?.reloadFeatureFlags() + await flagsLoaded.wait() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + } + + #expect(requestLock.withLock { requestCount } == 2) + #expect(loadedLock.withLock { loadedCount } == 2) + #expect(delegate.renderedSurveyIds.isEmpty) + _ = (sut, flagsToken) + } + + @Test("renders surveys without flag conditions while flags load") + func rendersSurveysWithoutFlagsWhileFlagsLoad() async throws { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + + server.flagsResponseHandler = { _ in + let response = HTTPStubsResponse( + jsonObject: [ + "featureFlags": ["survey-flag": true], + "featureFlagPayloads": [:], + "flags": [:], + ], + statusCode: 200, + headers: nil + ) + response.responseTime = 0.2 + return response + } + + let stateLock = NSLock() + var featureFlagsLoaded = false + var renderedBeforeFlagsLoaded = false + let flagsLoaded = AsyncLatch() + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in + guard flags != nil else { return } + stateLock.withLock { featureFlagsLoaded = true } + flagsLoaded.signal() + }) + let rendered = AsyncLatch() + delegate.onRender = { + stateLock.withLock { renderedBeforeFlagsLoaded = !featureFlagsLoaded } + rendered.signal() + } + + let flaggedSurvey = activeSurvey + .replacingOccurrences(of: "active_id", with: "flagged_survey") + .replacingOccurrences( + of: "\"start_date\"", + with: "\"linked_flag_key\": \"survey-flag\", \"start_date\"" + ) + postHog.remoteConfig?.onRemoteConfigLoaded.invoke([ + "surveys": try parseSurveys("\(flaggedSurvey),\(activeSurvey)"), + ]) + await rendered.wait() + + #expect(delegate.renderedSurveyIds == ["active_id"]) + #expect(stateLock.withLock { renderedBeforeFlagsLoaded }) + await flagsLoaded.wait() + _ = (sut, flagsToken) + } - await featureFlagsLoaded.wait() - try #require(featureFlagsLoaded.isSignaled) - #expect(delegate.renderedSurveyIds.isEmpty) - _ = (sut, remoteToken, flagsToken) + @Test("recovers survey rendering after a feature flag refresh failure") + func recoversSurveyRenderingAfterFeatureFlagRefreshFailure() async throws { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + postHog.config.preloadFeatureFlags = false + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + server.remoteConfigSurveys = + """ + [{ + "id": "flagged_survey", + "name": "Flagged Survey", + "type": "popover", + "questions": [{ + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + }], + "linked_flag_key": "survey-flag", + "start_date": "2024-07-23T09:18:18.376000Z" + }] + """ + server.flagsResponseHandler = { _ in + HTTPStubsResponse(jsonObject: [:], statusCode: 200, headers: nil) + } + + let failed = AsyncLatch() + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in + if flags == nil { failed.signal() } + }) + postHog.remoteConfig?.reloadRemoteConfig() + await failed.wait() + try #require(failed.isSignaled) + #expect(delegate.renderedSurveyIds.isEmpty) + + server.flagsResponseHandler = nil + server.featureFlags = ["survey-flag": true] + let rendered = AsyncLatch() + delegate.onRender = { rendered.signal() } + postHog.remoteConfig?.reloadFeatureFlags() + await rendered.wait() + + #expect(delegate.renderedSurveyIds == ["flagged_survey"]) + _ = (sut, flagsToken) + } + #endif + + @Test("returns surveys that match device type") + func returnsSurveysThatMatchDeviceType() async { + let surveys: [String] = [ + draftSurvey, + activeSurvey, + completedSurvey, + surveyWithDesktopDevice, + surveyWithMobileDevice, + ] + + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } + } + + if let currentDeviceType = PostHogContext.deviceType?.lowercased() { + #expect(matchedSurveys.map(\.id) == ["active_id", "\(currentDeviceType)_id"]) + } else { + #expect(matchedSurveys.map(\.id) == ["active_id"]) + } } - @Test("does not use an older flag refresh for newer surveys") - func doesNotUseOlderFlagRefreshForNewerSurveys() async throws { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } + @Test("does not return web-only surveys with a CSS selector condition") + func doesNotReturnSurveysWithSelectorCondition() async { + let surveys: [String] = [ + activeSurvey, + surveyWithSelectorCondition, + ] - let survey: [String: Any] = [ - "id": "flagged_survey", - "name": "Flagged Survey", - "type": "popover", - "questions": [[ - "id": "1", - "type": "open", - "question": "What do you think?", - "originalQuestionIndex": 0, - ]], - "linked_flag_key": "survey-flag", - "start_date": "2024-07-23T09:18:18.376000Z", + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } + } + + #expect(matchedSurveys.map(\.id) == ["active_id"]) + } + + @Test("does not return web-only surveys with a URL condition") + func doesNotReturnSurveysWithUrlCondition() async { + let surveys: [String] = [ + activeSurvey, + surveyWithUrlCondition, ] - let requestLock = NSLock() - var requestCount = 0 - server.flagsResponseHandler = { _ in - let index = requestLock.withLock { - requestCount += 1 - return requestCount + + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) } - let response = HTTPStubsResponse( - jsonObject: [ - "featureFlags": ["survey-flag": index == 1], - "featureFlagPayloads": [:], - "flags": [:], - ], - statusCode: 200, - headers: nil - ) - if index == 1 { response.responseTime = 0.1 } - return response - } - - let flagsLoaded = AsyncLatch() - let loadedLock = NSLock() - var loadedCount = 0 - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in - guard flags != nil else { return } - let isSecondLoad = loadedLock.withLock { - loadedCount += 1 - return loadedCount == 2 + } + + #expect(matchedSurveys.map(\.id) == ["active_id"]) + } + + @Test("returns only surveys with enabled feature flags") + func returnsOnlySurveysWithEnabledFeatureFlags() async throws { + let sut = getSut(surveys: [surveysWithFeatureFlags]) + // Seed surveys separately so this matching test does not race the remote-config listener's own flag refresh. + let surveys = sut.decodeSurveys(from: [ + "surveys": try parseSurveys(surveysWithFeatureFlags), + ]) + sut.updateSurveyCache(surveys, events: [:]) + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in + continuation.resume() } - if isSecondLoad { flagsLoaded.signal() } - }) + } - postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) - postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) - postHog.remoteConfig?.reloadFeatureFlags() - await flagsLoaded.wait() + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(with: .success($0)) + } + } + + #expect(matchedSurveys.map(\.id).contains("survey-with-flags")) + #expect(matchedSurveys.map(\.id).contains("survey-without-flags")) + #expect(!matchedSurveys.map(\.id).contains("survey-with-disabled-flags")) + } + + @Test("matches the linked flag variant without bypassing other targeting", arguments: [ + ("linked-blue", "blue", true), + ("linked-blue", "red", false), + ("linked-blue", "Blue", false), + ("linked-flag-enabled", "blue", false), + ("linked-flag-disabled", "blue", false), + ("missing-flag", "blue", false), + ("linked-blue", "any", true), + ("linked-flag-enabled", "any", true), + ("linked-flag-disabled", "any", false), + ("missing-flag", "any", false), + ("linked-blue", nil, true), + ("linked-blue", "", true), + (nil, "blue", true), + ("", "blue", true), + ] as [(String?, String?, Bool)]) + func matchesLinkedFlagVariant(linkedKey: String?, variant: String?, matches: Bool) async throws { + server.featureFlags?["linked-blue"] = "blue" + var survey = try #require(try parseSurveys(activeSurvey).first) + survey["linked_flag_key"] = linkedKey + survey["conditions"] = variant.map { ["linkedFlagVariant": $0] } ?? [:] + var blockedSurvey = survey + blockedSurvey["id"] = "blocked" + blockedSurvey["targeting_flag_key"] = "survey-targeting-flag-disabled" + + let sut = getSut(surveys: []) + let surveys = sut.decodeSurveys(from: ["surveys": [survey, blockedSurvey]]) + sut.updateSurveyCache(surveys, events: [:]) await withCheckedContinuation { continuation in - DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } + } + let matched: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { continuation.resume(returning: $0) } } + #expect(matched.map(\.id) == (matches ? ["active_id"] : [])) + } - #expect(requestLock.withLock { requestCount } == 2) - #expect(loadedLock.withLock { loadedCount } == 2) - #expect(delegate.renderedSurveyIds.isEmpty) - _ = (sut, flagsToken) + @Test("Should not return surveys when any feature flag is disabled") + func shouldFilterOutSurveysWhenAnyFlagIsDisabled() async { + let sut = getSut(surveys: [surveyWithEnabledAndDisabledFlags]) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } + } + + #expect(matchedSurveys.isEmpty) } - @Test("renders surveys without flag conditions while flags load") - func rendersSurveysWithoutFlagsWhileFlagsLoad() async throws { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } - - server.flagsResponseHandler = { _ in - let response = HTTPStubsResponse( - jsonObject: [ - "featureFlags": ["survey-flag": true], - "featureFlagPayloads": [:], - "flags": [:], - ], - statusCode: 200, - headers: nil - ) - response.responseTime = 0.2 - return response - } - - let stateLock = NSLock() - var featureFlagsLoaded = false - var renderedBeforeFlagsLoaded = false - let flagsLoaded = AsyncLatch() - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in - guard flags != nil else { return } - stateLock.withLock { featureFlagsLoaded = true } - flagsLoaded.signal() - }) - let rendered = AsyncLatch() - delegate.onRender = { - stateLock.withLock { renderedBeforeFlagsLoaded = !featureFlagsLoaded } - rendered.signal() + @Test("does not require flag evaluation for surveys with missing keys or values") + func doesNotRequireFlagsForMissingKeysOrValues() throws { + let surveys = PostHogSurveyIntegration().decodeSurveys(from: [ + "surveys": try parseSurveys(surveysWithMissingKeysAndValues), + ]) + + try #require(surveys.count == 2) + #expect(surveys.allSatisfy { !$0.requiresFeatureFlagEvaluation }) + } + + @Test("skips checking flags for surveys with missing keys or values ") + func shouldIgnoreSurveysWithMissingFeatureFlagsKeysOrValues() async { + let sut = getSut(surveys: [surveysWithMissingKeysAndValues]) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - let flaggedSurvey = activeSurvey - .replacingOccurrences(of: "active_id", with: "flagged_survey") - .replacingOccurrences( - of: "\"start_date\"", - with: "\"linked_flag_key\": \"survey-flag\", \"start_date\"" - ) - postHog.remoteConfig?.onRemoteConfigLoaded.invoke([ - "surveys": try parseSurveys("\(flaggedSurvey),\(activeSurvey)"), + #expect(matchedSurveys.map(\.id).contains("survey-with-missing-keys")) + #expect(matchedSurveys.map(\.id).contains("survey-with-missing-values")) + } + + @Test("returns surveys that match internal targeting flags") + func returnsSurveysThatMatchInternalTargetingFlags() async throws { + let sut = getSut(surveys: [surveyWithEnabledInternalTargetingFlag]) + let surveys = sut.decodeSurveys(from: [ + "surveys": try parseSurveys(surveyWithEnabledInternalTargetingFlag), ]) - await rendered.wait() + sut.updateSurveyCache(surveys, events: [:]) + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in + continuation.resume() + } + } + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(with: .success($0)) + } + } - #expect(delegate.renderedSurveyIds == ["active_id"]) - #expect(stateLock.withLock { renderedBeforeFlagsLoaded }) - await flagsLoaded.wait() - _ = (sut, flagsToken) + #expect(matchedSurveys.map(\.id) == ["survey-with-internal-flag-enabled"]) } + } - @Test("recovers survey rendering after a feature flag refresh failure") - func recoversSurveyRenderingAfterFeatureFlagRefreshFailure() async throws { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - postHog.config.preloadFeatureFlags = false - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } - server.remoteConfigSurveys = - """ - [{ - "id": "flagged_survey", - "name": "Flagged Survey", - "type": "popover", - "questions": [{ + @Suite("Test survey wait period", .serialized) + class TestSurveyWaitPeriod { + let server: MockPostHogServer + let postHog: PostHogSDK + let storage: PostHogStorage + + init() { + let config = PostHogConfig(projectToken: "test_survey_wait_period", host: "http://localhost:9090") + config._surveys = true + #if os(iOS) + config._surveysConfig.surveysDelegate = TestGetActiveSurveys.SpySurveysDelegate() + #endif + config.enableSwizzling = false + config.disableFlushOnBackgroundForTesting = true + postHog = PostHogSDK.with(config) + storage = PostHogStorage(config) + storage.reset() + server = MockPostHogServer() + server.start() + } + + deinit { + server.stop() + postHog.close() + postHog.reset() + } + + let activeSurveyNoWaitPeriod = + """ + { + "id": "no-wait-period", + "name": "Survey without wait period", + "type": "popover", + "questions": [ + { "id": "1", "type": "open", "question": "What do you think?", "originalQuestionIndex": 0 - }], - "linked_flag_key": "survey-flag", - "start_date": "2024-07-23T09:18:18.376000Z" - }] - """ - server.flagsResponseHandler = { _ in - HTTPStubsResponse(jsonObject: [:], statusCode: 200, headers: nil) + } + ], + "start_date": "2024-07-23T09:18:18.376000Z" } + """ - let failed = AsyncLatch() - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in - if flags == nil { failed.signal() } - }) - postHog.remoteConfig?.reloadRemoteConfig() - await failed.wait() - try #require(failed.isSignaled) - #expect(delegate.renderedSurveyIds.isEmpty) - - server.flagsResponseHandler = nil - server.featureFlags = ["survey-flag": true] - let rendered = AsyncLatch() - delegate.onRender = { rendered.signal() } - postHog.remoteConfig?.reloadFeatureFlags() - await rendered.wait() - - #expect(delegate.renderedSurveyIds == ["flagged_survey"]) - _ = (sut, flagsToken) - } - #endif - - @Test("returns surveys that match device type") - func returnsSurveysThatMatchDeviceType() async { - let surveys: [String] = [ - draftSurvey, - activeSurvey, - completedSurvey, - surveyWithDesktopDevice, - surveyWithMobileDevice, - ] - - let sut = getSut(surveys: surveys) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let activeSurveyWithWaitPeriod = + """ + { + "id": "with-wait-period", + "name": "Survey with wait period", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "seenSurveyWaitPeriodInDays": 7 + }, + "start_date": "2024-07-23T09:18:18.376000Z" } - } + """ - if let currentDeviceType = PostHogContext.deviceType?.lowercased() { - #expect(matchedSurveys.map(\.id) == ["active_id", "\(currentDeviceType)_id"]) - } else { - #expect(matchedSurveys.map(\.id) == ["active_id"]) + private func getSut(surveys: [String]) -> PostHogSurveyIntegration { + server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" + let sut = PostHogSurveyIntegration() + PostHogSurveyIntegration.clearInstalls() + let installOutcome = sut.install(postHog) + #expect(installOutcome == .installed) + return sut } - } - @Test("does not return web-only surveys with a CSS selector condition") - func doesNotReturnSurveysWithSelectorCondition() async { - let surveys: [String] = [ - activeSurvey, - surveyWithSelectorCondition, - ] + @Test("survey without wait period is not filtered") + func surveyWithoutWaitPeriodIsNotFiltered() async { + let sut = getSut(surveys: [activeSurveyNoWaitPeriod]) - let sut = getSut(surveys: surveys) + // Set last seen date to recently + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(Date())) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - - #expect(matchedSurveys.map(\.id) == ["active_id"]) - } - @Test("does not return web-only surveys with a URL condition") - func doesNotReturnSurveysWithUrlCondition() async { - let surveys: [String] = [ - activeSurvey, - surveyWithUrlCondition, - ] - - let sut = getSut(surveys: surveys) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) - } + #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) } - #expect(matchedSurveys.map(\.id) == ["active_id"]) - } + @Test("survey with wait period passes when no survey was previously seen") + func surveyWithWaitPeriodPassesWhenNoPreviouslySeen() async { + let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - @Test("returns only surveys with enabled feature flags") - func returnsOnlySurveysWithEnabledFeatureFlags() async throws { - let sut = getSut(surveys: [surveysWithFeatureFlags]) - // Seed surveys separately so this matching test does not race the remote-config listener's own flag refresh. - let surveys = sut.decodeSurveys(from: [ - "surveys": try parseSurveys(surveysWithFeatureFlags), - ]) - sut.updateSurveyCache(surveys, events: [:]) - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in - continuation.resume() + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(with: .success($0)) - } + #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) } - #expect(matchedSurveys.map(\.id).contains("survey-with-flags")) - #expect(matchedSurveys.map(\.id).contains("survey-without-flags")) - #expect(!matchedSurveys.map(\.id).contains("survey-with-disabled-flags")) - } - - @Test("matches the linked flag variant without bypassing other targeting", arguments: [ - ("linked-blue", "blue", true), - ("linked-blue", "red", false), - ("linked-blue", "Blue", false), - ("linked-flag-enabled", "blue", false), - ("linked-flag-disabled", "blue", false), - ("missing-flag", "blue", false), - ("linked-blue", "any", true), - ("linked-flag-enabled", "any", true), - ("linked-flag-disabled", "any", false), - ("missing-flag", "any", false), - ("linked-blue", nil, true), - ("linked-blue", "", true), - (nil, "blue", true), - ("", "blue", true), - ] as [(String?, String?, Bool)]) - func matchesLinkedFlagVariant(linkedKey: String?, variant: String?, matches: Bool) async throws { - server.featureFlags?["linked-blue"] = "blue" - var survey = try #require(try parseSurveys(activeSurvey).first) - survey["linked_flag_key"] = linkedKey - survey["conditions"] = variant.map { ["linkedFlagVariant": $0] } ?? [:] - var blockedSurvey = survey - blockedSurvey["id"] = "blocked" - blockedSurvey["targeting_flag_key"] = "survey-targeting-flag-disabled" - - let sut = getSut(surveys: []) - let surveys = sut.decodeSurveys(from: ["surveys": [survey, blockedSurvey]]) - sut.updateSurveyCache(surveys, events: [:]) - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } - } - let matched: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { continuation.resume(returning: $0) } - } - #expect(matched.map(\.id) == (matches ? ["active_id"] : [])) - } + @Test("survey with wait period is filtered when period has not elapsed") + func surveyWithWaitPeriodIsFilteredWhenNotElapsed() async { + let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - @Test("Should not return surveys when any feature flag is disabled") - func shouldFilterOutSurveysWhenAnyFlagIsDisabled() async { - let sut = getSut(surveys: [surveyWithEnabledAndDisabledFlags]) + // Set last seen date to 1 day ago (wait period is 7 days) + let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - - #expect(matchedSurveys.isEmpty) - } - @Test("does not require flag evaluation for surveys with missing keys or values") - func doesNotRequireFlagsForMissingKeysOrValues() throws { - let surveys = PostHogSurveyIntegration().decodeSurveys(from: [ - "surveys": try parseSurveys(surveysWithMissingKeysAndValues), - ]) + #expect(matchedSurveys.isEmpty) + } - try #require(surveys.count == 2) - #expect(surveys.allSatisfy { !$0.requiresFeatureFlagEvaluation }) - } + @Test("survey with wait period passes when period has elapsed") + func surveyWithWaitPeriodPassesWhenElapsed() async { + let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - @Test("skips checking flags for surveys with missing keys or values ") - func shouldIgnoreSurveysWithMissingFeatureFlagsKeysOrValues() async { - let sut = getSut(surveys: [surveysWithMissingKeysAndValues]) + // Set last seen date to 10 days ago (wait period is 7 days) + let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: Date())! + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(tenDaysAgo)) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } + + #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) } - #expect(matchedSurveys.map(\.id).contains("survey-with-missing-keys")) - #expect(matchedSurveys.map(\.id).contains("survey-with-missing-values")) - } + @Test("survey without wait period is not affected by last seen date") + func surveyWithoutWaitPeriodNotAffectedByLastSeenDate() async { + let sut = getSut(surveys: [activeSurveyNoWaitPeriod, activeSurveyWithWaitPeriod]) - @Test("returns surveys that match internal targeting flags") - func returnsSurveysThatMatchInternalTargetingFlags() async throws { - let sut = getSut(surveys: [surveyWithEnabledInternalTargetingFlag]) - let surveys = sut.decodeSurveys(from: [ - "surveys": try parseSurveys(surveyWithEnabledInternalTargetingFlag), - ]) - sut.updateSurveyCache(surveys, events: [:]) - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in - continuation.resume() - } - } + // Set last seen date to 1 day ago (wait period is 7 days) + let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - #expect(matchedSurveys.map(\.id) == ["survey-with-internal-flag-enabled"]) + // Only the survey without wait period should pass + #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) + } } - } - @Suite("Test survey wait period", .serialized) - class TestSurveyWaitPeriod { - let server: MockPostHogServer - let postHog: PostHogSDK - let storage: PostHogStorage + @Suite("Test conditional branching", .serialized) + class TestConfitionalBranchingLogic { + let postHog: PostHogSDK + let server = MockPostHogServer() - init() { - let config = PostHogConfig(projectToken: "test_survey_wait_period", host: "http://localhost:9090") - config._surveys = true - #if os(iOS) - config._surveysConfig.surveysDelegate = TestGetActiveSurveys.SpySurveysDelegate() - #endif - config.enableSwizzling = false - config.disableFlushOnBackgroundForTesting = true - postHog = PostHogSDK.with(config) - storage = PostHogStorage(config) - storage.reset() - server = MockPostHogServer() - server.start() - } + init() { + server.start() + let config = PostHogConfig(projectToken: "test_survey_branching", host: "http://localhost:9090") + config._surveys = true + config.enableSwizzling = false + config.disableRemoteConfigForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + config.setBeforeSend { _ in nil } + PostHogStorage(config).reset() + postHog = PostHogSDK.with(config) + } - deinit { - server.stop() - postHog.close() - postHog.reset() - } + deinit { + postHog.close() + server.stop() + } - let activeSurveyNoWaitPeriod = - """ - { - "id": "no-wait-period", - "name": "Survey without wait period", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What do you think?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let activeSurveyWithWaitPeriod = - """ - { - "id": "with-wait-period", - "name": "Survey with wait period", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What do you think?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "seenSurveyWaitPeriodInDays": 7 - }, - "start_date": "2024-07-23T09:18:18.376000Z" + private func getSut() -> PostHogSurveyIntegration { + let sut = PostHogSurveyIntegration() + PostHogSurveyIntegration.clearInstalls() + #expect(sut.install(postHog) == .installed) + sut.stop() + return sut } - """ - private func getSut(surveys: [String]) -> PostHogSurveyIntegration { - server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" - let sut = PostHogSurveyIntegration() - PostHogSurveyIntegration.clearInstalls() - let installOutcome = sut.install(postHog) - #expect(installOutcome == .installed) - return sut - } + @Test("returns next question index when no branching") + func returnsNextQuestionIndexWhenNoBranching() throws { + let sut = getSut() - @Test("survey without wait period is not filtered") - func surveyWithoutWaitPeriodIsNotFiltered() async { - let sut = getSut(surveys: [activeSurveyNoWaitPeriod]) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1")), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + ] + ) - // Set last seen date to recently - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(Date())) + sut.setShownSurvey(survey) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { + #expect(nextIndex == 1) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") } - } - - #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) - } - @Test("survey with wait period passes when no survey was previously seen") - func surveyWithWaitPeriodPassesWhenNoPreviouslySeen() async { - let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 1, response: .openEnded("response 2")) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") } - } - - #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) - } - - @Test("survey with wait period is filtered when period has not elapsed") - func surveyWithWaitPeriodIsFilteredWhenNotElapsed() async { - let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - // Set last seen date to 1 day ago (wait period is 7 days) - let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 3")) { + #expect(nextIndex == 2) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") } } - #expect(matchedSurveys.isEmpty) - } + @Test("completes survey with single question") + func completesSurveyWithSingleQuestion() throws { + let sut = getSut() - @Test("survey with wait period passes when period has elapsed") - func surveyWithWaitPeriodPassesWhenElapsed() async { - let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1")), + ] + ) - // Set last seen date to 10 days ago (wait period is 7 days) - let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: Date())! - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(tenDaysAgo)) + sut.setShownSurvey(survey) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { + #expect(nextIndex == 0) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") } } - #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) - } + @Test("ends survey when branching is end") + func endsSurveyWhenBranchingIsEnd() throws { + let sut = getSut() - @Test("survey without wait period is not affected by last seen date") - func surveyWithoutWaitPeriodNotAffectedByLastSeenDate() async { - let sut = getSut(surveys: [activeSurveyNoWaitPeriod, activeSurveyWithWaitPeriod]) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1", branching: .end)), + .open(.testInstance(question: "q2")), + ] + ) - // Set last seen date to 1 day ago (wait period is 7 days) - let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) + sut.setShownSurvey(survey) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { + #expect(nextIndex == 0) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") } } - // Only the survey without wait period should pass - #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) - } - } + @Test("jumps to specific question when branching to specific question") + func jumpsToSpecificQuestionWhenBranchingToSpecificQuestion() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "q1", + display: .number, + scale: .tenPoint, + branching: .specificQuestion(index: 2) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - @Suite("Test conditional branching", .serialized) - class TestConfitionalBranchingLogic { - let postHog: PostHogSDK - let server = MockPostHogServer() - - init() { - server.start() - let config = PostHogConfig(projectToken: "test_survey_branching", host: "http://localhost:9090") - config._surveys = true - config.enableSwizzling = false - config.disableRemoteConfigForTesting = true - config.disableQueueTimerForTesting = true - config.disableFlushOnBackgroundForTesting = true - config.captureApplicationLifecycleEvents = false - config.setBeforeSend { _ in nil } - PostHogStorage(config).reset() - postHog = PostHogSDK.with(config) - } + sut.setShownSurvey(survey) - deinit { - postHog.close() - server.stop() - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .rating(7)) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - private func getSut() -> PostHogSurveyIntegration { - let sut = PostHogSurveyIntegration() - PostHogSurveyIntegration.clearInstalls() - #expect(sut.install(postHog) == .installed) - sut.stop() - return sut - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { + #expect(nextIndex == 3) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - @Test("returns next question index when no branching") - func returnsNextQuestionIndexWhenNoBranching() throws { - let sut = getSut() + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("response 3")) { + #expect(nextIndex == 3) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") + } + } - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1")), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - ] - ) + @Test("jumps to last question when branching is out of bounds") + func jumpsToLastQuestionWhenBranchingOutOfBounds() throws { + let sut = getSut() - sut.setShownSurvey(survey) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1", branching: .specificQuestion(index: 5))), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { - #expect(nextIndex == 1) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 1, response: .openEnded("response 2")) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 3")) { - #expect(nextIndex == 2) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { + #expect(nextIndex == 2) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") + } } - } - @Test("completes survey with single question") - func completesSurveyWithSingleQuestion() throws { - let sut = getSut() + @Test("handles single choice response based branching") + func handlesSingleChoiceResponseBasedBranching() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .singleChoice(.testInstance( + question: "How satisfied are you with our product?", + choices: ["Very Dissatisfied", "Dissatisfied", "Neutral", "Satisfied", "Very Satisfied"], + branching: .responseBased(responseValues: [ + "0": 1, // Very Dissatisfied -> Detractor path + "1": 1, // Dissatisfied -> Detractor path + "2": 2, // Neutral -> Neutral path + "3": 3, // Satisfied -> Promoter path + "4": 3, // Very Satisfied -> Promoter path + ]) + )), + .open(.testInstance(question: "detractor", branching: .specificQuestion(index: 4))), // Detractor path + .open(.testInstance(question: "neutral", branching: .specificQuestion(index: 4))), // Neutral path + .open(.testInstance(question: "promoter", branching: .specificQuestion(index: 4))), // Promoter path + .open(.testInstance(question: "Final")), // Final question + ] + ) - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1")), - ] - ) + // Test Very Satisfied/Satisfied path (promoter) + sut.setShownSurvey(survey) - sut.setShownSurvey(survey) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Very Satisfied")) { + #expect(nextIndex == 3) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { - #expect(nextIndex == 0) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") - } - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("Great product!")) { + #expect(nextIndex == 4) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - @Test("ends survey when branching is end") - func endsSurveyWhenBranchingIsEnd() throws { - let sut = getSut() + // Test Neutral path + sut.setShownSurvey(survey) - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1", branching: .end)), - .open(.testInstance(question: "q2")), - ] - ) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Neutral")) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - sut.setShownSurvey(survey) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("It's okay")) { + #expect(nextIndex == 4) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { - #expect(nextIndex == 0) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") - } - } + // Test Dissatisfied/Very Dissatisfied path (detractor) + sut.setShownSurvey(survey) - @Test("jumps to specific question when branching to specific question") - func jumpsToSpecificQuestionWhenBranchingToSpecificQuestion() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "q1", - display: .number, - scale: .tenPoint, - branching: .specificQuestion(index: 2) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + let detractor = try #require(sut.getNextQuestion(index: 0, response: .singleChoice("Very Dissatisfied"))) + #expect(detractor.0 == 1) + #expect(detractor.1 == false) + + let final = try #require(sut.getNextQuestion(index: 1, response: .openEnded("Needs work"))) + #expect(final.0 == 4) + #expect(final.1 == false) + + let completed = try #require(sut.getNextQuestion(index: 4, response: .openEnded("Yes"))) + #expect(completed.0 == 4) + #expect(completed.1 == true) + } + + @Test("handles rating response based branching for scale 3") + func handlesRatingResponseBasedBranchingForScale3() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "rating question", + display: .emoji, + scale: .threePoint, + branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - sut.setShownSurvey(survey) + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .rating(7)) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + for rating in 1 ... 3 { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == rating) + #expect(isCompleted == false) + } } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { - #expect(nextIndex == 3) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + @Test("handles rating response based branching for scale 5") + func handlesRatingResponseBasedBranchingForScale5() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "rating question", + display: .emoji, + scale: .fivePoint, + branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("response 3")) { - #expect(nextIndex == 3) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") + for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 2), (4, 3), (5, 3)] { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == expectedIndex) + #expect(isCompleted == false) + } } - } - - @Test("jumps to last question when branching is out of bounds") - func jumpsToLastQuestionWhenBranchingOutOfBounds() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1", branching: .specificQuestion(index: 5))), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - ] - ) - sut.setShownSurvey(survey) + @Test("handles rating response based branching for scale 7") + func handlesRatingResponseBasedBranchingForScale7() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "rating question", + display: .number, + scale: .sevenPoint, + branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { - #expect(nextIndex == 2) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") + for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3), (7, 3)] { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == expectedIndex) + #expect(isCompleted == false) + } } - } - - @Test("handles single choice response based branching") - func handlesSingleChoiceResponseBasedBranching() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .singleChoice(.testInstance( - question: "How satisfied are you with our product?", - choices: ["Very Dissatisfied", "Dissatisfied", "Neutral", "Satisfied", "Very Satisfied"], - branching: .responseBased(responseValues: [ - "0": 1, // Very Dissatisfied -> Detractor path - "1": 1, // Dissatisfied -> Detractor path - "2": 2, // Neutral -> Neutral path - "3": 3, // Satisfied -> Promoter path - "4": 3, // Very Satisfied -> Promoter path - ]) - )), - .open(.testInstance(question: "detractor", branching: .specificQuestion(index: 4))), // Detractor path - .open(.testInstance(question: "neutral", branching: .specificQuestion(index: 4))), // Neutral path - .open(.testInstance(question: "promoter", branching: .specificQuestion(index: 4))), // Promoter path - .open(.testInstance(question: "Final")), // Final question - ] - ) - // Test Very Satisfied/Satisfied path (promoter) - sut.setShownSurvey(survey) + @Test("handles NPS rating response based branching for scale 10") + func handlesNPSRatingResponseBasedBranchingForScale10() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "q1", + display: .number, + scale: .tenPoint, + branching: .responseBased(responseValues: ["detractors": 1, "passives": 2, "promoters": 3]) + )), + .open(.testInstance(question: "question_detractors", branching: .end)), // Detractors path + .open(.testInstance(question: "question_passives", branching: .end)), // Passives path + .open(.testInstance(question: "question_promoters", branching: .end)), // Promoters path + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Very Satisfied")) { - #expect(nextIndex == 3) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("Great product!")) { - #expect(nextIndex == 4) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + for (rating, expectedIndex) in [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 2), (8, 2), (9, 3), (10, 3)] { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == expectedIndex) + #expect(isCompleted == false) + } } + } - // Test Neutral path - sut.setShownSurvey(survey) + @Suite("Test resilient survey list decoding") + struct TestResilientSurveyListDecoding { + @Test("atomic array decode fails when one survey is malformed") + func atomicArrayDecodeFailsOnMalformedEntry() throws { + let data = try loadFixture("fixture_surveys_array_with_malformed") - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Neutral")) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + // Documents Swift's atomic [Decodable] behavior: a single malformed + // element throws and drops the entire array. This is the root cause + // of issue #611 and the reason decodeSurveys decodes per-element. + #expect(throws: DecodingError.self) { + _ = try PostHogApi.jsonDecoder.decode([PostHogSurvey].self, from: data) + } } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("It's okay")) { - #expect(nextIndex == 4) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + @Test("per-element decoding skips malformed entries and keeps valid ones") + func perElementDecodingSkipsMalformedEntries() throws { + let data = try loadFixture("fixture_surveys_array_with_malformed") + let rawArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + let arrayItems = try #require(rawArray) + + let decoded = PostHogSurveyIntegration().decodeSurveys(from: ["surveys": arrayItems]) + + #expect(decoded.count == 1) + #expect(decoded.first?.id == "valid-survey-id") + } + } + + @Suite("Test survey to display model mapping") + struct TestSurveyToDisplayMapping { + private func makeAppearance( + delay: TimeInterval?, + displayIntroScreen: Bool? = nil, + introScreenHeader: String? = nil, + introScreenDescription: String? = nil, + introScreenDescriptionContentType: PostHogSurveyTextContentType? = nil, + introScreenButtonText: String? = nil + ) -> PostHogSurveyAppearance { + PostHogSurveyAppearance( + position: nil, fontFamily: nil, backgroundColor: nil, + submitButtonColor: nil, submitButtonText: nil, submitButtonTextColor: nil, + textColor: nil, descriptionTextColor: nil, ratingButtonColor: nil, + ratingButtonActiveColor: nil, ratingButtonHoverColor: nil, + inputBackground: nil, inputTextColor: nil, whiteLabel: nil, + autoDisappear: nil, displayThankYouMessage: nil, + thankYouMessageHeader: nil, thankYouMessageDescription: nil, + thankYouMessageDescriptionContentType: nil, + thankYouMessageCloseButtonText: nil, + displayIntroScreen: displayIntroScreen, + introScreenHeader: introScreenHeader, + introScreenDescription: introScreenDescription, + introScreenDescriptionContentType: introScreenDescriptionContentType, + introScreenButtonText: introScreenButtonText, + borderColor: nil, + placeholder: nil, shuffleQuestions: nil, + surveyPopupDelaySeconds: delay, + widgetType: nil, widgetSelector: nil, widgetLabel: nil, widgetColor: nil + ) } - // Test Dissatisfied/Very Dissatisfied path (detractor) - sut.setShownSurvey(survey) - - let detractor = try #require(sut.getNextQuestion(index: 0, response: .singleChoice("Very Dissatisfied"))) - #expect(detractor.0 == 1) - #expect(detractor.1 == false) + @Test("maps surveyPopupDelaySeconds to display survey appearance") + func mapsPopupDelaySecondsToDisplayAppearance() { + let survey = PostHogSurvey.testInstance( + name: "delay-mapped", + appearance: makeAppearance(delay: 7) + ) - let final = try #require(sut.getNextQuestion(index: 1, response: .openEnded("Needs work"))) - #expect(final.0 == 4) - #expect(final.1 == false) + let displaySurvey = survey.toDisplaySurvey() - let completed = try #require(sut.getNextQuestion(index: 4, response: .openEnded("Yes"))) - #expect(completed.0 == 4) - #expect(completed.1 == true) - } + #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == 7) + } - @Test("handles rating response based branching for scale 3") - func handlesRatingResponseBasedBranchingForScale3() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "rating question", - display: .emoji, - scale: .threePoint, - branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + @Test("keeps popup delay nil on display survey appearance when missing") + func keepsPopupDelayNilWhenMissing() { + let survey = PostHogSurvey.testInstance( + name: "delay-missing", + appearance: makeAppearance(delay: nil) + ) - sut.setShownSurvey(survey) + let displaySurvey = survey.toDisplaySurvey() - for rating in 1 ... 3 { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == rating) - #expect(isCompleted == false) + #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == nil) } - } - @Test("handles rating response based branching for scale 5") - func handlesRatingResponseBasedBranchingForScale5() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "rating question", - display: .emoji, - scale: .fivePoint, - branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + @Test("maps intro screen fields to display survey appearance") + func mapsIntroScreenFieldsToDisplayAppearance() { + let survey = PostHogSurvey.testInstance( + name: "intro-mapped", + appearance: makeAppearance( + delay: nil, + displayIntroScreen: true, + introScreenHeader: "Welcome!", + introScreenDescription: "Two quick questions.", + introScreenDescriptionContentType: .text, + introScreenButtonText: "Get started" + ) + ) - for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 2), (4, 3), (5, 3)] { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == expectedIndex) - #expect(isCompleted == false) + let displaySurvey = survey.toDisplaySurvey() + + #expect(displaySurvey.appearance?.displayIntroScreen == true) + #expect(displaySurvey.appearance?.introScreenHeader == "Welcome!") + #expect(displaySurvey.appearance?.introScreenDescription == "Two quick questions.") + #expect(displaySurvey.appearance?.introScreenDescriptionContentType == .text) + #expect(displaySurvey.appearance?.introScreenButtonText == "Get started") } - } - @Test("handles rating response based branching for scale 7") - func handlesRatingResponseBasedBranchingForScale7() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "rating question", - display: .number, - scale: .sevenPoint, - branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + @Test("defaults displayIntroScreen to false on display survey appearance when missing") + func defaultsDisplayIntroScreenToFalseWhenMissing() { + let survey = PostHogSurvey.testInstance( + name: "intro-missing", + appearance: makeAppearance(delay: nil) + ) - sut.setShownSurvey(survey) + let displaySurvey = survey.toDisplaySurvey() - for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3), (7, 3)] { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == expectedIndex) - #expect(isCompleted == false) + #expect(displaySurvey.appearance?.displayIntroScreen == false) + #expect(displaySurvey.appearance?.introScreenHeader == nil) + #expect(displaySurvey.appearance?.introScreenButtonText == nil) } - } - @Test("handles NPS rating response based branching for scale 10") - func handlesNPSRatingResponseBasedBranchingForScale10() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "q1", - display: .number, - scale: .tenPoint, - branching: .responseBased(responseValues: ["detractors": 1, "passives": 2, "promoters": 3]) - )), - .open(.testInstance(question: "question_detractors", branching: .end)), // Detractors path - .open(.testInstance(question: "question_passives", branching: .end)), // Passives path - .open(.testInstance(question: "question_promoters", branching: .end)), // Promoters path + @Test( + "rating display nil-coalesces missing bound labels to empty string", + arguments: [ + (lower: String?.none, upper: String?.none, expectedLower: "", expectedUpper: ""), + (lower: String?.none, upper: "Very likely", expectedLower: "", expectedUpper: "Very likely"), + (lower: "Not likely", upper: String?.none, expectedLower: "Not likely", expectedUpper: ""), ] ) + func ratingDisplayDefaultsMissingBoundLabelsToEmptyString( + lower: String?, + upper: String?, + expectedLower: String, + expectedUpper: String + ) throws { + let ratingQuestion = PostHogRatingSurveyQuestion( + id: "r-1", + question: "How likely?", + description: nil, + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: nil, + display: .number, + scale: .tenPoint, + lowerBoundLabel: lower, + upperBoundLabel: upper + ) + let survey = PostHogSurvey.testInstance( + name: "rating-no-bounds", + questions: [.rating(ratingQuestion)] + ) - sut.setShownSurvey(survey) + let displaySurvey = survey.toDisplaySurvey() + let displayQuestion = try #require(displaySurvey.questions.first as? PostHogDisplayRatingQuestion) - for (rating, expectedIndex) in [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 2), (8, 2), (9, 3), (10, 3)] { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == expectedIndex) - #expect(isCompleted == false) + #expect(displayQuestion.lowerBoundLabel == expectedLower) + #expect(displayQuestion.upperBoundLabel == expectedUpper) } } } - @Suite("Test resilient survey list decoding") - struct TestResilientSurveyListDecoding { - @Test("atomic array decode fails when one survey is malformed") - func atomicArrayDecodeFailsOnMalformedEntry() throws { - let data = try loadFixture("fixture_surveys_array_with_malformed") - - // Documents Swift's atomic [Decodable] behavior: a single malformed - // element throws and drops the entire array. This is the root cause - // of issue #611 and the reason decodeSurveys decodes per-element. - #expect(throws: DecodingError.self) { - _ = try PostHogApi.jsonDecoder.decode([PostHogSurvey].self, from: data) - } - } - - @Test("per-element decoding skips malformed entries and keeps valid ones") - func perElementDecodingSkipsMalformedEntries() throws { - let data = try loadFixture("fixture_surveys_array_with_malformed") - let rawArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] - let arrayItems = try #require(rawArray) - - let decoded = PostHogSurveyIntegration().decodeSurveys(from: ["surveys": arrayItems]) - - #expect(decoded.count == 1) - #expect(decoded.first?.id == "valid-survey-id") - } + func loadFixture(_ name: String) throws -> Data { + let url = Bundle.test.url(forResource: name, withExtension: "json") + return try Data(contentsOf: url!) } - @Suite("Test survey to display model mapping") - struct TestSurveyToDisplayMapping { - private func makeAppearance( - delay: TimeInterval?, - displayIntroScreen: Bool? = nil, - introScreenHeader: String? = nil, - introScreenDescription: String? = nil, - introScreenDescriptionContentType: PostHogSurveyTextContentType? = nil, - introScreenButtonText: String? = nil - ) -> PostHogSurveyAppearance { - PostHogSurveyAppearance( - position: nil, fontFamily: nil, backgroundColor: nil, - submitButtonColor: nil, submitButtonText: nil, submitButtonTextColor: nil, - textColor: nil, descriptionTextColor: nil, ratingButtonColor: nil, - ratingButtonActiveColor: nil, ratingButtonHoverColor: nil, - inputBackground: nil, inputTextColor: nil, whiteLabel: nil, - autoDisappear: nil, displayThankYouMessage: nil, - thankYouMessageHeader: nil, thankYouMessageDescription: nil, - thankYouMessageDescriptionContentType: nil, - thankYouMessageCloseButtonText: nil, - displayIntroScreen: displayIntroScreen, - introScreenHeader: introScreenHeader, - introScreenDescription: introScreenDescription, - introScreenDescriptionContentType: introScreenDescriptionContentType, - introScreenButtonText: introScreenButtonText, - borderColor: nil, - placeholder: nil, shuffleQuestions: nil, - surveyPopupDelaySeconds: delay, - widgetType: nil, widgetSelector: nil, widgetLabel: nil, widgetColor: nil - ) - } - - @Test("maps surveyPopupDelaySeconds to display survey appearance") - func mapsPopupDelaySecondsToDisplayAppearance() { - let survey = PostHogSurvey.testInstance( - name: "delay-mapped", - appearance: makeAppearance(delay: 7) - ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == 7) - } - - @Test("keeps popup delay nil on display survey appearance when missing") - func keepsPopupDelayNilWhenMissing() { - let survey = PostHogSurvey.testInstance( - name: "delay-missing", - appearance: makeAppearance(delay: nil) + private extension PostHogSurvey { + static func testInstance( + id: String = UUID().uuidString, + name: String, + type: PostHogSurveyType = .popover, + questions: [PostHogSurveyQuestion] = [ + .open( + PostHogOpenSurveyQuestion( + id: "", + question: "Some question", + description: "Some description", + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: nil, + branching: nil, + translations: nil + ) + ), + ], + featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]? = nil, + linkedFlagKey: String? = nil, + targetingFlagKey: String? = nil, + internalTargetingFlagKey: String? = nil, + conditions: PostHogSurveyConditions? = nil, + appearance: PostHogSurveyAppearance? = nil, + currentIteration: Int? = nil, + currentIterationStartDate: Date? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + schedule: PostHogSurveySchedule? = nil + ) -> PostHogSurvey { + PostHogSurvey( + id: id, + name: name, + type: type, + questions: questions, + featureFlagKeys: featureFlagKeys, + linkedFlagKey: linkedFlagKey, + targetingFlagKey: targetingFlagKey, + internalTargetingFlagKey: internalTargetingFlagKey, + conditions: conditions, + appearance: appearance, + currentIteration: currentIteration, + currentIterationStartDate: currentIterationStartDate, + startDate: startDate, + endDate: endDate, + schedule: schedule, + translations: nil ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == nil) } + } - @Test("maps intro screen fields to display survey appearance") - func mapsIntroScreenFieldsToDisplayAppearance() { - let survey = PostHogSurvey.testInstance( - name: "intro-mapped", - appearance: makeAppearance( - delay: nil, - displayIntroScreen: true, - introScreenHeader: "Welcome!", - introScreenDescription: "Two quick questions.", - introScreenDescriptionContentType: .text, - introScreenButtonText: "Get started" - ) + private extension PostHogOpenSurveyQuestion { + static func testInstance( + question: String, + branching: PostHogSurveyQuestionBranching? = nil + ) -> PostHogOpenSurveyQuestion { + PostHogOpenSurveyQuestion( + id: "", + question: question, + description: "", + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: nil, + branching: branching, + translations: nil ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.displayIntroScreen == true) - #expect(displaySurvey.appearance?.introScreenHeader == "Welcome!") - #expect(displaySurvey.appearance?.introScreenDescription == "Two quick questions.") - #expect(displaySurvey.appearance?.introScreenDescriptionContentType == .text) - #expect(displaySurvey.appearance?.introScreenButtonText == "Get started") } + } - @Test("defaults displayIntroScreen to false on display survey appearance when missing") - func defaultsDisplayIntroScreenToFalseWhenMissing() { - let survey = PostHogSurvey.testInstance( - name: "intro-missing", - appearance: makeAppearance(delay: nil) + private extension PostHogMultipleSurveyQuestion { + static func testInstance( + question: String, + choices: [String], + branching: PostHogSurveyQuestionBranching? = nil + ) -> PostHogMultipleSurveyQuestion { + PostHogMultipleSurveyQuestion( + id: "", + question: question, + description: "", + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: nil, + branching: branching, + translations: nil, + choices: choices, + hasOpenChoice: false, + shuffleOptions: nil ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.displayIntroScreen == false) - #expect(displaySurvey.appearance?.introScreenHeader == nil) - #expect(displaySurvey.appearance?.introScreenButtonText == nil) } + } - @Test( - "rating display nil-coalesces missing bound labels to empty string", - arguments: [ - (lower: String?.none, upper: String?.none, expectedLower: "", expectedUpper: ""), - (lower: String?.none, upper: "Very likely", expectedLower: "", expectedUpper: "Very likely"), - (lower: "Not likely", upper: String?.none, expectedLower: "Not likely", expectedUpper: ""), - ] - ) - func ratingDisplayDefaultsMissingBoundLabelsToEmptyString( - lower: String?, - upper: String?, - expectedLower: String, - expectedUpper: String - ) throws { - let ratingQuestion = PostHogRatingSurveyQuestion( - id: "r-1", - question: "How likely?", - description: nil, + private extension PostHogRatingSurveyQuestion { + static func testInstance( + question: String, + display: PostHogSurveyRatingDisplayType, + scale: PostHogSurveyRatingScale, + branching: PostHogSurveyQuestionBranching? = nil + ) -> PostHogRatingSurveyQuestion { + PostHogRatingSurveyQuestion( + id: "", + question: question, + description: "", descriptionContentType: nil, optional: nil, buttonText: nil, - originalQuestionIndex: 0, - branching: nil, + originalQuestionIndex: nil, + branching: branching, translations: nil, - display: .number, - scale: .tenPoint, - lowerBoundLabel: lower, - upperBoundLabel: upper - ) - let survey = PostHogSurvey.testInstance( - name: "rating-no-bounds", - questions: [.rating(ratingQuestion)] + display: display, + scale: scale, + lowerBoundLabel: "", + upperBoundLabel: "" ) - - let displaySurvey = survey.toDisplaySurvey() - let displayQuestion = try #require(displaySurvey.questions.first as? PostHogDisplayRatingQuestion) - - #expect(displayQuestion.lowerBoundLabel == expectedLower) - #expect(displayQuestion.upperBoundLabel == expectedUpper) } } -} - -func loadFixture(_ name: String) throws -> Data { - let url = Bundle.test.url(forResource: name, withExtension: "json") - return try Data(contentsOf: url!) -} - -private extension PostHogSurvey { - static func testInstance( - id: String = UUID().uuidString, - name: String, - type: PostHogSurveyType = .popover, - questions: [PostHogSurveyQuestion] = [ - .open( - PostHogOpenSurveyQuestion( - id: "", - question: "Some question", - description: "Some description", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: nil, - translations: nil - ) - ), - ], - featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]? = nil, - linkedFlagKey: String? = nil, - targetingFlagKey: String? = nil, - internalTargetingFlagKey: String? = nil, - conditions: PostHogSurveyConditions? = nil, - appearance: PostHogSurveyAppearance? = nil, - currentIteration: Int? = nil, - currentIterationStartDate: Date? = nil, - startDate: Date? = nil, - endDate: Date? = nil, - schedule: PostHogSurveySchedule? = nil - ) -> PostHogSurvey { - PostHogSurvey( - id: id, - name: name, - type: type, - questions: questions, - featureFlagKeys: featureFlagKeys, - linkedFlagKey: linkedFlagKey, - targetingFlagKey: targetingFlagKey, - internalTargetingFlagKey: internalTargetingFlagKey, - conditions: conditions, - appearance: appearance, - currentIteration: currentIteration, - currentIterationStartDate: currentIterationStartDate, - startDate: startDate, - endDate: endDate, - schedule: schedule, - translations: nil - ) - } -} - -private extension PostHogOpenSurveyQuestion { - static func testInstance( - question: String, - branching: PostHogSurveyQuestionBranching? = nil - ) -> PostHogOpenSurveyQuestion { - PostHogOpenSurveyQuestion( - id: "", - question: question, - description: "", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: branching, - translations: nil - ) - } -} - -private extension PostHogMultipleSurveyQuestion { - static func testInstance( - question: String, - choices: [String], - branching: PostHogSurveyQuestionBranching? = nil - ) -> PostHogMultipleSurveyQuestion { - PostHogMultipleSurveyQuestion( - id: "", - question: question, - description: "", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: branching, - translations: nil, - choices: choices, - hasOpenChoice: false, - shuffleOptions: nil - ) - } -} - -private extension PostHogRatingSurveyQuestion { - static func testInstance( - question: String, - display: PostHogSurveyRatingDisplayType, - scale: PostHogSurveyRatingScale, - branching: PostHogSurveyQuestionBranching? = nil - ) -> PostHogRatingSurveyQuestion { - PostHogRatingSurveyQuestion( - id: "", - question: question, - description: "", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: branching, - translations: nil, - display: display, - scale: scale, - lowerBoundLabel: "", - upperBoundLabel: "" - ) - } -} +#endif diff --git a/PostHogTests/PostHogWebPTest.swift b/PostHogTests/PostHogWebPTest.swift index 96a6b9fe6..d297286d7 100644 --- a/PostHogTests/PostHogWebPTest.swift +++ b/PostHogTests/PostHogWebPTest.swift @@ -5,7 +5,35 @@ // Created by Yiannis Josephides on 09/12/2024. // -#if canImport(UIKit) || targetEnvironment(macCatalyst) +#if os(iOS) + import Foundation + @testable import PostHog + import Testing + import UIKit + + @Suite("Replay image encoding") + struct PostHogReplayImageEncodingTests { + @Test("encodes as WebP when libwebp is linked, JPEG otherwise") + func encodesWithExpectedFormat() throws { + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + #if !SWIFT_PACKAGE || SessionReplay + let mimeType = "webp" + #else + let mimeType = "jpeg" + #endif + + let encoded = try #require(image.toBase64()) + + #expect(encoded.hasPrefix("data:image/\(mimeType);base64,")) + } + } +#endif + +// Mirrors the gate on `UIImage.webpData`, which SwiftPM compiles out when the `SessionReplay` trait is off. +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/StoredSurveyResponseTest.swift b/PostHogTests/StoredSurveyResponseTest.swift index 1b8d64d58..d31728a47 100644 --- a/PostHogTests/StoredSurveyResponseTest.swift +++ b/PostHogTests/StoredSurveyResponseTest.swift @@ -1,18 +1,20 @@ -import Foundation -@testable import PostHog -import Testing +#if !SWIFT_PACKAGE || Surveys + import Foundation + @testable import PostHog + import Testing -struct StoredSurveyResponseTest { - @Test("stored response values survive JSON encoding and decoding", arguments: [ - PostHogSurveyResponse.rating(nil), .rating(5), .openEnded("hello"), .openEnded(nil), - .singleChoice("A"), .multipleChoice(["A", "B"]), .multipleChoice(nil), .link(true), .link(false), - ]) - func storedResponseRoundTrip(response: PostHogSurveyResponse) throws { - let restored = try #require(JSONDecoder().decode(StoredSurveyResponse.self, from: JSONEncoder().encode(StoredSurveyResponse(response))).response) - #expect(restored.type == response.type) - #expect(restored.textValue == response.textValue) - #expect(restored.ratingValue == response.ratingValue) - #expect(restored.selectedOptions == response.selectedOptions) - #expect(restored.linkClicked == response.linkClicked) + struct StoredSurveyResponseTest { + @Test("stored response values survive JSON encoding and decoding", arguments: [ + PostHogSurveyResponse.rating(nil), .rating(5), .openEnded("hello"), .openEnded(nil), + .singleChoice("A"), .multipleChoice(["A", "B"]), .multipleChoice(nil), .link(true), .link(false), + ]) + func storedResponseRoundTrip(response: PostHogSurveyResponse) throws { + let restored = try #require(JSONDecoder().decode(StoredSurveyResponse.self, from: JSONEncoder().encode(StoredSurveyResponse(response))).response) + #expect(restored.type == response.type) + #expect(restored.textValue == response.textValue) + #expect(restored.ratingValue == response.ratingValue) + #expect(restored.selectedOptions == response.selectedOptions) + #expect(restored.linkClicked == response.linkClicked) + } } -} +#endif diff --git a/PostHogTests/UtilsTest.swift b/PostHogTests/UtilsTest.swift index 620842ab0..969def647 100644 --- a/PostHogTests/UtilsTest.swift +++ b/PostHogTests/UtilsTest.swift @@ -96,7 +96,7 @@ struct UtilsTest { } } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) @Suite("Survey color tests") struct SurveyColorTests { @Test(