diff --git a/.changeset/ignored-exception-types-watchos.md b/.changeset/ignored-exception-types-watchos.md new file mode 100644 index 0000000000..168f7fdff3 --- /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 0000000000..c86fc6f027 --- /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 0000000000..f57d1f00a7 --- /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/.changeset/v4-internalize-wrapper-apis.md b/.changeset/v4-internalize-wrapper-apis.md new file mode 100644 index 0000000000..37731cca1c --- /dev/null +++ b/.changeset/v4-internalize-wrapper-apis.md @@ -0,0 +1,23 @@ +--- +'posthog-ios': major +--- + +**Breaking:** hide SDK internals that were public only for PostHog's own wrapper SDKs. + +Moved behind `@_spi(PostHogInternal)`. These are for PostHog's wrapper SDKs and carry no stability guarantees: + +- `postHogSdkName` and `postHogVersion` +- `PostHogSessionManager`: use `PostHogSDK.getSessionId()`, `startSession()` and `endSession()`. +- `PostHogStorageManager` and `PostHogConfig.storageManager`: use the `PostHogSDK` identity methods such as `identify`, `getDistinctId()` and `reset()`. +- `PostHogConfig.snapshotEndpoint` +- `dateToMillis(_:)`: use `Int64(date.timeIntervalSince1970 * 1000)`. +- `imageToBase64(_:_:)`: no public replacement. It returned a WebP `data:` URI, or JPEG when WebP encoding failed. + +No longer public: + +- `toISO8601String(_:)`, `toISO8601Date(_:)`, `sanitizeDictionary(_:)`, `deleteSafely(_:)` and `postHogiOSSdkName` +- `Gzip`, `GzipError` and `CompressionLevel` +- `ReachabilityError` +- `UIColor.init(hex:)` and `UIColor.hexDescription(_:)` + +`PostHogSessionManager.shared`, `PostHogSessionManager.setSessionId(_:)` and `PostHogConfig.snapshotEndpoint` are no longer exposed to Objective-C. diff --git a/.changeset/v4-malformed-payload-nil.md b/.changeset/v4-malformed-payload-nil.md new file mode 100644 index 0000000000..7fb7aea583 --- /dev/null +++ b/.changeset/v4-malformed-payload-nil.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** return `nil` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(_:)` and `getAllFeatureFlags()`. If you read a malformed payload as a `String`, fix the payload JSON in PostHog. String payloads passed to `PostHogBootstrapConfig.featureFlagPayloads` are no longer JSON-decoded a second time, so a bootstrapped `"123"` stays a `String`. diff --git a/.changeset/v4-min-ios-15.md b/.changeset/v4-min-ios-15.md new file mode 100644 index 0000000000..41f5f7ca4e --- /dev/null +++ b/.changeset/v4-min-ios-15.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** raise the minimum iOS deployment target from 13.0 to 15.0. Mac Catalyst follows iOS. diff --git a/.changeset/v4-nwpathmonitor.md b/.changeset/v4-nwpathmonitor.md new file mode 100644 index 0000000000..0b1c1b5a3d --- /dev/null +++ b/.changeset/v4-nwpathmonitor.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +Network reachability now uses `NWPathMonitor` instead of the deprecated `SCNetworkReachability` API. `dataMode = .wifi` still counts any connection that isn't cellular as Wi-Fi, including wired. Some edge cases change. A path that needs a connection now counts as reachable, even when it needs user action (e.g. a VPN waiting for a password). With cellular data turned off for the app, queues now pause instead of sending requests that fail. If no path has arrived in the first 100 ms after setup, `.any` sends and `.wifi` waits for the first path, and events captured in that window leave out `$network_wifi` and `$network_cellular`. The unavailable `ReachabilityChangedNotification` global is removed. diff --git a/.changeset/v4-raise-apple-platform-minimums.md b/.changeset/v4-raise-apple-platform-minimums.md new file mode 100644 index 0000000000..d98daf8c93 --- /dev/null +++ b/.changeset/v4-raise-apple-platform-minimums.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** raise the minimum deployment targets to macOS 11.0 (from 10.15), tvOS 15.0 (from 13.0) and watchOS 10.0 (from 6.0). diff --git a/.changeset/v4-reload-flags-result.md b/.changeset/v4-reload-flags-result.md new file mode 100644 index 0000000000..798bdff41c --- /dev/null +++ b/.changeset/v4-reload-flags-result.md @@ -0,0 +1,7 @@ +--- +'posthog-ios': major +--- + +**Breaking:** `reloadFeatureFlags(_:)` now passes a `PostHogFeatureFlagsLoaded` to its callback, so you can tell whether the reload failed. + +`errorsLoading` is `true` when the request failed or the SDK isn't set up, and `flags` and `variants` then hold the last known values. Update closures from `{ ... }` to `{ result in ... }` (or `{ _ in ... }`). In Objective-C, the block now takes a `PostHogFeatureFlagsLoaded *` argument. diff --git a/.changeset/v4-remove-datamode-cellular.md b/.changeset/v4-remove-datamode-cellular.md new file mode 100644 index 0000000000..eb72435292 --- /dev/null +++ b/.changeset/v4-remove-datamode-cellular.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** remove `PostHogDataMode.cellular`. It always behaved the same as `.any`, so use `.any` instead (`PostHogDataModeAny` in Objective-C). Flushing behavior is unchanged. `.wifi` and `.any` keep their raw values 0 and 2; `PostHogDataMode(rawValue: 1)` now returns `nil`. diff --git a/.changeset/v4-remove-deprecated-apis.md b/.changeset/v4-remove-deprecated-apis.md new file mode 100644 index 0000000000..41faa195da --- /dev/null +++ b/.changeset/v4-remove-deprecated-apis.md @@ -0,0 +1,19 @@ +--- +'posthog-ios': major +--- + +**Breaking:** remove deprecated APIs. + +- `PostHogConfig.apiKey`, `PostHogConfig(apiKey:)` and `PostHogConfig(apiKey:host:)`: use `projectToken`, `PostHogConfig(projectToken:)` and `PostHogConfig(projectToken:host:)`. +- `PostHogConfig.propertiesSanitizer` and the `PostHogPropertiesSanitizer` protocol: use `PostHogConfig.setBeforeSend(_:)`. +- `PostHogConfig.evaluationEnvironments`: use `evaluationContexts`. +- `PostHogConfig.remoteConfig`: delete the assignment. Remote config is always loaded. +- `PostHogSDK.getFeatureFlagPayload(_:)`: use `getFeatureFlagResult(_:)?.payload`. Pass `sendFeatureFlagEvent: false` to keep the old behavior of not capturing `$feature_flag_called`. +- `PostHogSessionReplayConfig.debouncerDelay`: use `throttleDelay`. +- `PostHogSessionReplayConfig.maskPhotoLibraryImages`: delete the assignment. It had no effect. + +In Objective-C: + +- `[[PostHogConfig alloc] apiKey:]` and `apiKey:host:`: use `projectToken:` and `projectToken:host:`. +- `[sdk getFeatureFlagPayload:key]`: use `[sdk getFeatureFlagResultWithKey:key sendFeatureFlagEvent:NO].payload`. +- `config.propertiesSanitizer`: use `[config setBeforeSend:@[...]]` with `BoxedBeforeSendBlock`s. diff --git a/.changeset/v4-remove-legacy-queue-migration.md b/.changeset/v4-remove-legacy-queue-migration.md new file mode 100644 index 0000000000..b517e957e6 --- /dev/null +++ b/.changeset/v4-remove-legacy-queue-migration.md @@ -0,0 +1,9 @@ +--- +'posthog-ios': major +--- + +**Breaking:** remove the migration of the event queue written by SDK 2.x (`posthog.queue.plist`). + +Events still queued on disk by a 2.x SDK are deleted instead of sent when the app updates straight from 2.x to 4.0. Distinct ID, anonymous ID, groups and cached feature flags are still carried over. Events created by a 3.x SDK are not affected. + +Queued events are no longer read in the 2.x format: a top-level `$set` and `message_id` are ignored. This includes 2.x events that 3.x imported and had not sent yet when the app updated to 4.0. diff --git a/.changeset/v4-remove-legacy-version-build.md b/.changeset/v4-remove-legacy-version-build.md new file mode 100644 index 0000000000..d80462b0e0 --- /dev/null +++ b/.changeset/v4-remove-legacy-version-build.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** remove `version` and `build` from `Application Installed`, `Application Updated` and `Application Opened` events. Use `$app_version` and `$app_build` in insights and filters instead. diff --git a/.changeset/v4-remove-replay-wireframe.md b/.changeset/v4-remove-replay-wireframe.md new file mode 100644 index 0000000000..6bee1e552a --- /dev/null +++ b/.changeset/v4-remove-replay-wireframe.md @@ -0,0 +1,12 @@ +--- +'posthog-ios': major +--- + +**Breaking:** remove wireframe session replay capture. Session replay now always records masked screenshots. + +- Apps that didn't set `screenshotMode` now upload screenshots instead of wireframes, which uses more bandwidth and CPU per captured frame. +- Masking covers text, inputs, images and web views. Custom-drawn content (custom `draw(_:)` views, maps, video, charts) is now visible in recordings: wrap anything sensitive in `postHogMask()` or tag it `ph-no-capture`. +- Apps whose window root is a SwiftUI view, which previously recorded nothing, now record sessions. +- `PostHogSessionReplayConfig.screenshotMode` is deprecated and has no effect: delete the assignment. Setting it to `false` no longer prevents screenshots; use the masking options instead. +- The replay screen name now names the visible screen (SwiftUI screens report their view name), and is also sent when `screenshotMode` was enabled. It comes from the view controller's type name only: a plain `UIViewController`'s `title` is no longer sent, since it can contain user data. +- Secure-entry text fields now stay masked inside a `postHogNoMask()` or `ph-no-mask` subtree. diff --git a/.changeset/v4-remove-screenshot-background-capture.md b/.changeset/v4-remove-screenshot-background-capture.md new file mode 100644 index 0000000000..8096e942e1 --- /dev/null +++ b/.changeset/v4-remove-screenshot-background-capture.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** remove the experimental `PostHogSessionReplayConfig.screenshotModeBackgroundCapture` option. Delete the assignment. Screenshots are now always captured on the main thread, where rendering is fast enough that the off-main workaround is no longer needed. diff --git a/.changeset/v4-rename-console-log-types.md b/.changeset/v4-rename-console-log-types.md new file mode 100644 index 0000000000..d87455fa52 --- /dev/null +++ b/.changeset/v4-rename-console-log-types.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** rename `PostHogLogLevel` to `PostHogConsoleLogLevel` and `PostHogLogEntry` to `PostHogConsoleLogEntry`, so the session replay console log types aren't confused with the Logs types `PostHogLogSeverity` and `PostHogLogRecord`. Update custom `logSanitizer` and `minLogLevel` code to the new names (`PostHogConsoleLogLevelInfo`/`Warn`/`Error` in Objective-C). Behavior is unchanged. diff --git a/.changeset/v4-sendable-callbacks.md b/.changeset/v4-sendable-callbacks.md new file mode 100644 index 0000000000..68bb07b2ae --- /dev/null +++ b/.changeset/v4-sendable-callbacks.md @@ -0,0 +1,7 @@ +--- +'posthog-ios': major +--- + +**Breaking:** mark callbacks the SDK can call off the main thread `@Sendable` — `reloadFeatureFlags(_:)`, the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` used by `setBeforeSend(_:)` on `PostHogConfig` and `PostHogLogsConfig`, `PostHogConfig.getAnonymousId`, `PostHogConfig.pushIdentityProvider` and its `completion`, and `sessionReplayConfig.captureLogsConfig.logSanitizer` — and make `PostHogFeatureFlagsLoaded` `Sendable`. This fixes a crash in Swift 6 apps when a `reloadFeatureFlags(_:)` callback is written in main-actor code. + +To update main-actor state from `reloadFeatureFlags(_:)` or `pushIdentityProvider`, hop with `Task { @MainActor in ... }`. The other callbacks return a value immediately, so they must not read main-actor state. Declare closures you store in your own variables as `@Sendable` to avoid new Swift 5 warnings. Use the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` typealiases for any `setBeforeSend(_:)` arrays you build or pass around, including your own helpers: in Swift 5 mode, converting an array between the old function type and these types compiles but crashes at runtime, in either direction. diff --git a/.changeset/v4-swift-tools-6.md b/.changeset/v4-swift-tools-6.md new file mode 100644 index 0000000000..32b8ec3d60 --- /dev/null +++ b/.changeset/v4-swift-tools-6.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** require Xcode 26 or later (Swift 6.2 toolchain, `swift-tools-version` 6.2) to build PostHog, the same Xcode the App Store already requires for uploads; the SDK still compiles in Swift 5 language mode and its API is unchanged. diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 25bcae1a24..daa9995433 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -65,10 +65,10 @@ jobs: target: buildExamplePodsStaticLib runner: macos-15 xcode-version: latest-stable - - name: Static Lib (Xcode 16.2) + - name: Static Lib (Xcode 26.0.1) target: buildExamplePodsStaticLib - runner: macos-14-xlarge - xcode-version: '16.2' + runner: macos-15-xlarge + xcode-version: '26.0.1' - name: Static Framework target: buildExamplePodsStaticFramework runner: macos-15 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe91b43554..303abf7ecd 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 @@ -65,10 +67,10 @@ jobs: - name: Check public API snapshot run: make apiCheck - build-sdk-swift53-compat: + build-sdk-min-toolchain: needs: detect-markdown-only - runs-on: macos-14-xlarge - name: Build SDK (Swift 5 compatibility on Xcode 15.4 with CocoaPods) + runs-on: macos-15-xlarge + name: Build SDK (minimum toolchain, Xcode 26.0.1) steps: - name: Complete markdown-only PR check if: needs.detect-markdown-only.outputs.markdown_only == 'true' @@ -78,12 +80,18 @@ jobs: - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 if: needs.detect-markdown-only.outputs.markdown_only != 'true' with: - xcode-version: '15.4' + xcode-version: '26.0.1' - name: Print toolchain versions if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: | xcodebuild -version xcrun swift --version + - 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/codeql.yml b/.github/workflows/codeql.yml index 2471199f09..fa9f3dff57 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,7 +9,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: macos-14-xlarge + runs-on: macos-15-xlarge permissions: security-events: write # for uploading CodeQL results packages: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73c86fc6b5..003e3115ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -297,7 +297,7 @@ jobs: publish-cocoapods: name: Publish to CocoaPods needs: [create-github-release, notify-approval-needed] - runs-on: macos-14 + runs-on: macos-15 if: always() && needs.create-github-release.result == 'success' permissions: contents: read @@ -312,7 +312,7 @@ jobs: - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '16.2' + xcode-version: '26.0.1' - name: Publish to CocoaPods env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 438d09c70a..6129659e8b 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 099ef01107..6dd32c83ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,11 @@ - SDK entry point: [PostHog/PostHogSDK.swift](PostHog/PostHogSDK.swift); tests: `PostHogTests/`; replay/privacy: `PostHog/Replay/` and `PostHog/Resources/`. ## Repository invariants -- Maintain Swift 5 SDK language compatibility. Tests require Swift 6+ with Swift Testing (Xcode 16+); prefer Swift Testing for new tests. Use `PostHogTests/TestUtils/MockPostHogServer.swift` for HTTP stubbing. -- Preserve deployment minima: iOS/tvOS 13, macOS 10.15, watchOS 6, 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 both `Package.swift` and `Package@swift-5.9.swift`, plus CocoaPods/Xcode packaging/build configuration; visionOS declarations belong in the Swift 5.9 manifest. +- Maintain Swift 5 SDK language mode. The package needs swift-tools-version 6.2 (Xcode 26+); prefer Swift Testing for new tests. Use `PostHogTests/TestUtils/MockPostHogServer.swift` for HTTP stubbing. +- 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 5d6b49375a..f25b9b896b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ If you would like to contribute code to `posthog-ios` you can do so through GitH Use the repository's `make` wrappers rather than invoking `swift` or `xcodebuild` directly; [Makefile](Makefile) owns the commands and options. -For initial setup, install Xcode and, if needed, use `make bootstrap` to install CocoaPods, xcpretty, SwiftLint, SwiftFormat and Periphery. Agents should report missing prerequisites rather than install tools without authorization. Tests import `Testing` and need a Swift 6+ toolchain with Swift Testing (Xcode 16+); use current stable Xcode as CI does. This is separate from the SDK's Swift 5 language compatibility. +For initial setup, install Xcode 26 or later and, if needed, use `make bootstrap` to install CocoaPods, xcpretty, SwiftLint, SwiftFormat and Periphery. Agents should report missing prerequisites rather than install tools without authorization. The package uses swift-tools-version 6.2 and compiles in Swift 5 language mode; use current stable Xcode as CI does. Iterate with focused checks, then run the core CI-aligned checks before submitting **SDK changes**: @@ -37,11 +37,12 @@ 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` | -Use relevant example/package checks for integration or packaging changes, and smoke test changed behavior in an example where applicable. `make buildExamples` runs all example builds; `make build` combines that with `make buildSdk`. These broad targets include CocoaPods installation and XCFramework generation, so they are not the default iteration loop. For package/support changes, also consult the package lint and Swift 5 compatibility lanes in [build CI](.github/workflows/build.yml) and [example CI](.github/workflows/build-examples.yml). +Use relevant example/package checks for integration or packaging changes, and smoke test changed behavior in an example where applicable. `make buildExamples` runs all example builds; `make build` combines that with `make buildSdk`. These broad targets include CocoaPods installation and XCFramework generation, so they are not the default iteration loop. For package/support changes, also consult the package lint and minimum-toolchain lanes in [build CI](.github/workflows/build.yml) and [example CI](.github/workflows/build-examples.yml). ### Simulator, UI and masking prerequisites diff --git a/Makefile b/Makefile index e825b9c932..d9ef61175a 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 +.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 @@ -7,13 +7,31 @@ buildIOS: buildSdk: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=ios | xcpretty #ios - set -o pipefail && xcrun swift build --arch arm64 #macOS + $(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 set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=watchos | xcpretty #watchOS set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=xros | xcpretty #visionOS +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 \ @@ -120,9 +138,11 @@ testPresentationMasks: SWIFT_ACTIVE_COMPILATION_CONDITIONS='$$(inherited) TEST_PRESENTATION_MASKS' \ -only-testing:PostHogTests/PostHogMaskPresentationTest \ -only-testing:PostHogTests/PostHogMaskPresentationPrivacyTest \ + -only-testing:PostHogTests/PostHogReplayLayoutCaptureTest \ 2>&1 | tee presentation-masks.log | xcpretty @grep -q 'Suite "Replay masking behind a cover" passed' presentation-masks.log @grep -q 'Suite "Replay presentation privacy" passed' presentation-masks.log + @grep -q 'Suite "Replay layout-driven capture" passed' presentation-masks.log .PHONY: testCameraReplay @@ -200,6 +220,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 689e01e259..4d9ee752cd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,10 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.2 import PackageDescription let package = Package( name: "PostHog", platforms: [ - // visionOS is supported via Package@swift-5.9.swift for Swift 5.9+ users - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), + .macOS(.v11), .iOS(.v15), .tvOS(.v15), .watchOS(.v10), .visionOS(.v1), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -14,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"), @@ -25,8 +39,8 @@ let package = Package( name: "PostHog", dependencies: [ "PostHogObjCExceptionSupport", - "phlibwebp", - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .tvOS])), + .target(name: "phlibwebp", condition: .when(traits: ["SessionReplay"])), + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS], traits: ["CrashReporting"])), ], path: "PostHog", exclude: [ @@ -96,12 +110,22 @@ 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, .tvOS])), + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS], traits: ["CrashReporting"])), ], path: "PostHogTests", resources: [ .process("Resources"), ] ), - ] + // Compiles in Swift 6 language mode to check the public API the way a Swift 6 app sees it. + .testTarget( + name: "PostHogSwift6Tests", + dependencies: ["PostHog"], + path: "PostHogSwift6Tests", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + ], + swiftLanguageModes: [.v5] ) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index ec757e3b4d..0000000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,106 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "PostHog", - platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1), - ], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "PostHog", - targets: ["PostHog"] - ), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", from: "9.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "PostHog", - dependencies: [ - "PostHogObjCExceptionSupport", - "phlibwebp", - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS])), - ], - path: "PostHog", - exclude: [ - "ObjCExceptionSupport", - ], - resources: [ - .copy("Resources/PrivacyInfo.xcprivacy"), - ] - ), - .target( - name: "PostHogObjCExceptionSupport", - path: "PostHog/ObjCExceptionSupport", - publicHeadersPath: "." - ), - .target( - name: "PostHogTestsObjC", - path: "PostHogTestsObjC", - publicHeadersPath: "." - ), - .target( - name: "phlibwebp", - path: "vendor/libwebp", - publicHeadersPath: ".", - cSettings: [ - .define("PLCR_PRIVATE"), - .headerSearchPath("."), - ] - ), - .target( - name: "PHPLCrashReporter", - path: "vendor/PHPLCrashReporter", - exclude: [ - "Source/dwarf_opstream.hpp", - "Source/dwarf_stack.hpp", - "Source/PLCrashAsyncDwarfCFAState.hpp", - "Source/PLCrashAsyncDwarfCIE.hpp", - "Source/PLCrashAsyncDwarfEncoding.hpp", - "Source/PLCrashAsyncDwarfExpression.hpp", - "Source/PLCrashAsyncDwarfFDE.hpp", - "Source/PLCrashAsyncDwarfPrimitives.hpp", - "Source/PLCrashAsyncLinkedList.hpp", - "Source/PLCrashReport.proto", - "Dependencies/protobuf-c/generate-pb-c.sh", - "LICENSE", - ], - sources: [ - "Source", - ], - resources: [.process("Resources/PrivacyInfo.xcprivacy")], - publicHeadersPath: "include", - cSettings: [ - .define("PLCR_PRIVATE"), - .define("PLCF_RELEASE_BUILD"), - .define("PLCRASHREPORTER_PREFIX", to: "PH"), - .define("SWIFT_PACKAGE"), - .headerSearchPath("Source"), - ], - linkerSettings: [ - .linkedFramework("Foundation"), - ] - ), - .testTarget( - name: "PostHogTests", - dependencies: [ - "PostHog", - "PostHogTestsObjC", - "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])), - ], - path: "PostHogTests", - resources: [ - .process("Resources"), - ] - ), - ] -) diff --git a/PostHog.podspec b/PostHog.podspec index d40401cfb9..575737b917 100644 --- a/PostHog.podspec +++ b/PostHog.podspec @@ -16,10 +16,10 @@ Pod::Spec.new do |s| s.readme = "https://raw.githubusercontent.com/PostHog/posthog-ios/#{s.version.to_s}/README.md" s.changelog = "https://raw.githubusercontent.com/PostHog/posthog-ios/#{s.version.to_s}/CHANGELOG.md" - s.ios.deployment_target = '13.0' - s.tvos.deployment_target = '13.0' - s.osx.deployment_target = "10.15" - s.watchos.deployment_target = "6.0" + s.ios.deployment_target = '15.0' + s.tvos.deployment_target = '15.0' + s.osx.deployment_target = "11.0" + s.watchos.deployment_target = "10.0" s.visionos.deployment_target = "1.0" s.swift_versions = "5.3" diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index c4cd29a8b5..7255112ae9 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ BF5E00000000000000000002 /* PHObjCExceptionCatcher.h in Headers */ = {isa = PBXBuildFile; fileRef = BF5E00000000000000000006 /* PHObjCExceptionCatcher.h */; settings = {ATTRIBUTES = (Private, ); }; }; BF5E00000000000000000003 /* PHObjCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = BF5E00000000000000000007 /* PHObjCExceptionCatcher.m */; }; D0E500012FA0000100000002 /* PostHogEventSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000001 /* PostHogEventSnapshotTests.swift */; }; + D0E500022FA0000100000002 /* PostHogEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E500022FA0000100000001 /* PostHogEventTest.swift */; }; D0E500012FA0000100000021 /* event-shapes-batch.json in Resources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000011 /* event-shapes-batch.json */; }; D0E500012FA0000100000022 /* feature-flags-request.json in Resources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000012 /* feature-flags-request.json */; }; D0E500012FA0000100000023 /* session-replay-request.json in Resources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000013 /* session-replay-request.json */; }; @@ -95,8 +96,6 @@ 6A11C0DE2026092800000011 /* PostHogSurveySheetDetentsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A11C0DE2026092800000012 /* PostHogSurveySheetDetentsTest.swift */; }; 6A11C0DE2026092800000003 /* PostHogHingeStatusTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A11C0DE2026092800000004 /* PostHogHingeStatusTest.swift */; }; 690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0BE2AEFA97F00A0B06B /* FileUtils.swift */; }; - 690FF0C52AEFAE8200A0B06B /* PostHogLegacyQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */; }; - 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0DE2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift */; }; 690FF0E12AEFC59100A0B06B /* PostHogFileBackedQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E02AEFC59100A0B06B /* PostHogFileBackedQueueTest.swift */; }; 690FF0E32AEFD12900A0B06B /* PostHogConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E22AEFD12900A0B06B /* PostHogConfigTest.swift */; }; 6A0C1F2E3B4D5A6B7C8D9E02 /* PostHogOptOutPersistenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */; }; @@ -122,8 +121,6 @@ 69278D412AE6BC7200BB541A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69278D402AE6BC7200BB541A /* Assets.xcassets */; }; 69278D442AE6BC7200BB541A /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 69278D422AE6BC7200BB541A /* LaunchScreen.storyboard */; }; 69278D472AE6BC7200BB541A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 69278D462AE6BC7200BB541A /* main.m */; }; - 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */; }; - 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */; }; 6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6955CB722C517651008EFD8D /* CGSize+Util.swift */; }; 69779BEC2AE68E6900D7A48E /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69779BEB2AE68E6900D7A48E /* UIViewController.swift */; }; 6992AA832AFE51A000087600 /* PostHogExampleTvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6992AA822AFE51A000087600 /* PostHogExampleTvOSApp.swift */; }; @@ -151,20 +148,16 @@ 69F23A782BB30991001194F6 /* NetworkSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A772BB30991001194F6 /* NetworkSample.swift */; }; 69F23A7A2BB309F3001194F6 /* MethodSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A792BB309F3001194F6 /* MethodSwizzler.swift */; }; 69F517E82BAC675800F52C14 /* RRWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517E72BAC675800F52C14 /* RRWireframe.swift */; }; - 69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517E92BAC684F00F52C14 /* RRStyle.swift */; }; - 69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517F22BAC734300F52C14 /* UIColor+Util.swift */; }; 69F517FB2BAC768100F52C14 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517FA2BAC768100F52C14 /* AppDelegate.swift */; }; 69F517FD2BAC768100F52C14 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517FC2BAC768100F52C14 /* SceneDelegate.swift */; }; 69F517FF2BAC768100F52C14 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517FE2BAC768100F52C14 /* ViewController.swift */; }; 69F518022BAC768100F52C14 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 69F518012BAC768100F52C14 /* Base */; }; 69F518042BAC768300F52C14 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69F518032BAC768300F52C14 /* Assets.xcassets */; }; 69F518072BAC768300F52C14 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 69F518062BAC768300F52C14 /* Base */; }; - 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518112BAC783300F52C14 /* CGColor+Util.swift */; }; 69F518142BAC7F4300F52C14 /* Date+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518132BAC7F4300F52C14 /* Date+Util.swift */; }; 69F518162BAC7F9200F52C14 /* UIView+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518152BAC7F9200F52C14 /* UIView+Util.swift */; }; 654EC5A16DFC454F974A30A0 /* PostHogMaskSettleAnalysis.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25D0AE93D314E7FBE8DA8C7 /* PostHogMaskSettleAnalysis.swift */; }; C0FFEE002F8000010000C002 /* PostHogPresentationCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE002F8000010000C001 /* PostHogPresentationCover.swift */; }; - 69F518182BAC80A300F52C14 /* String+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518172BAC80A300F52C14 /* String+Util.swift */; }; 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */; }; 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; 56061CE612D20303BC7F7E0E /* PushNotificationPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C3A4BC40DB1C70B84B25E /* PushNotificationPublisher.swift */; }; @@ -332,6 +325,7 @@ DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; DA578E9C2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */; }; C08700012E00000100000870 /* PostHogReplayCameraTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08700022E00000100000870 /* PostHogReplayCameraTest.swift */; }; + C0870A012E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0870A022E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift */; }; C02440012E00000100000244 /* PostHogViewControllerTraversalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02440022E00000100000244 /* PostHogViewControllerTraversalTest.swift */; }; DA578E9E2D6858BA00B3A56C /* PostHogScreenViewIntegrationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */; }; DA578EA02D6858CE00B3A56C /* MockScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9F2D6858C900B3A56C /* MockScreenViewPublisher.swift */; }; @@ -460,8 +454,8 @@ DA8C9B982EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */; }; DA929C082D0B3A1D0018369C /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA929C092D0B3A1D0018369C /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - DA92C6412DCE09C700C02F3E /* PostHogLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */; }; - DA92C6492DCE09DE00C02F3E /* PostHogLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */; }; + DA92C6412DCE09C700C02F3E /* PostHogConsoleLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6402DCE09C700C02F3E /* PostHogConsoleLogEntry.swift */; }; + DA92C6492DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6482DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift */; }; DA92C64B2DCE1A2600C02F3E /* PostHogSessionReplayConsoleLogConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C64A2DCE1A2600C02F3E /* PostHogSessionReplayConsoleLogConfig.swift */; }; DA92C6632DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6602DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift */; }; DA92C66B2DD208F300C02F3E /* PostHogSessionReplayPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C66A2DD208F300C02F3E /* PostHogSessionReplayPlugin.swift */; }; @@ -864,6 +858,7 @@ BF5E00000000000000000006 /* PHObjCExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PHObjCExceptionCatcher.h; sourceTree = ""; }; BF5E00000000000000000007 /* PHObjCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PHObjCExceptionCatcher.m; sourceTree = ""; }; D0E500012FA0000100000001 /* PostHogEventSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogEventSnapshotTests.swift; sourceTree = ""; }; + D0E500022FA0000100000001 /* PostHogEventTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogEventTest.swift; sourceTree = ""; }; D0E500012FA0000100000011 /* event-shapes-batch.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = "event-shapes-batch.json"; sourceTree = ""; }; D0E500012FA0000100000012 /* feature-flags-request.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = "feature-flags-request.json"; sourceTree = ""; }; D0E500012FA0000100000013 /* session-replay-request.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = "session-replay-request.json"; sourceTree = ""; }; @@ -958,8 +953,6 @@ 6A11C0DE2026092800000012 /* PostHogSurveySheetDetentsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveySheetDetentsTest.swift; sourceTree = ""; }; 6A11C0DE2026092800000004 /* PostHogHingeStatusTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogHingeStatusTest.swift; sourceTree = ""; }; 690FF0BE2AEFA97F00A0B06B /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; - 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLegacyQueue.swift; sourceTree = ""; }; - 690FF0DE2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLegacyQueueTest.swift; sourceTree = ""; }; 690FF0E02AEFC59100A0B06B /* PostHogFileBackedQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogFileBackedQueueTest.swift; sourceTree = ""; }; 690FF0E22AEFD12900A0B06B /* PostHogConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConfigTest.swift; sourceTree = ""; }; 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogOptOutPersistenceTest.swift; sourceTree = ""; }; @@ -988,8 +981,6 @@ 69278D432AE6BC7200BB541A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 69278D452AE6BC7200BB541A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 69278D462AE6BC7200BB541A /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; - 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogPropertiesSanitizer.swift; sourceTree = ""; }; - 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleSanitizer.swift; sourceTree = ""; }; 6955CB722C517651008EFD8D /* CGSize+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Util.swift"; sourceTree = ""; }; 6957A9B66813CC8C7E9B818C /* PLCrashFrameWalker.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashFrameWalker.c; path = vendor/PHPLCrashReporter/Source/PLCrashFrameWalker.c; sourceTree = ""; }; 69779BEB2AE68E6900D7A48E /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; @@ -1023,8 +1014,6 @@ 69F23A772BB30991001194F6 /* NetworkSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSample.swift; sourceTree = ""; }; 69F23A792BB309F3001194F6 /* MethodSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSwizzler.swift; sourceTree = ""; }; 69F517E72BAC675800F52C14 /* RRWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRWireframe.swift; sourceTree = ""; }; - 69F517E92BAC684F00F52C14 /* RRStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRStyle.swift; sourceTree = ""; }; - 69F517F22BAC734300F52C14 /* UIColor+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Util.swift"; sourceTree = ""; }; 69F517F82BAC768100F52C14 /* PostHogExampleStoryboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PostHogExampleStoryboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; 69F517FA2BAC768100F52C14 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 69F517FC2BAC768100F52C14 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -1033,12 +1022,10 @@ 69F518032BAC768300F52C14 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 69F518062BAC768300F52C14 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 69F518082BAC768300F52C14 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 69F518112BAC783300F52C14 /* CGColor+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGColor+Util.swift"; sourceTree = ""; }; 69F518132BAC7F4300F52C14 /* Date+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Util.swift"; sourceTree = ""; }; 69F518152BAC7F9200F52C14 /* UIView+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Util.swift"; sourceTree = ""; }; E25D0AE93D314E7FBE8DA8C7 /* PostHogMaskSettleAnalysis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostHogMaskSettleAnalysis.swift"; sourceTree = ""; }; C0FFEE002F8000010000C001 /* PostHogPresentationCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogPresentationCover.swift; sourceTree = ""; }; - 69F518172BAC80A300F52C14 /* String+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Util.swift"; sourceTree = ""; }; 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInputTraits+Util.swift"; sourceTree = ""; }; 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; 174C3A4BC40DB1C70B84B25E /* PushNotificationPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationPublisher.swift; sourceTree = ""; }; @@ -1185,6 +1172,7 @@ DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationScreenViewPublisher.swift; sourceTree = ""; }; DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockApplicationLifecyclePublisher.swift; sourceTree = ""; }; C08700022E00000100000870 /* PostHogReplayCameraTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayCameraTest.swift; sourceTree = ""; }; + C0870A022E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayLayoutCaptureTest.swift; sourceTree = ""; }; C02440022E00000100000244 /* PostHogViewControllerTraversalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogViewControllerTraversalTest.swift; sourceTree = ""; }; DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegrationTest.swift; sourceTree = ""; }; DA578E9F2D6858C900B3A56C /* MockScreenViewPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockScreenViewPublisher.swift; sourceTree = ""; }; @@ -1372,8 +1360,8 @@ DA879D792F9CC8AD0036A854 /* PostHogGraphicsImageRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogGraphicsImageRenderer.swift; sourceTree = ""; }; DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingConfig.swift; sourceTree = ""; }; DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = ""; }; - DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLogEntry.swift; sourceTree = ""; }; - DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLogLevel.swift; sourceTree = ""; }; + DA92C6402DCE09C700C02F3E /* PostHogConsoleLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConsoleLogEntry.swift; sourceTree = ""; }; + DA92C6482DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConsoleLogLevel.swift; sourceTree = ""; }; DA92C64A2DCE1A2600C02F3E /* PostHogSessionReplayConsoleLogConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayConsoleLogConfig.swift; sourceTree = ""; }; DA92C6602DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayConsoleLogsPlugin.swift; sourceTree = ""; }; DA92C66A2DD208F300C02F3E /* PostHogSessionReplayPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayPlugin.swift; sourceTree = ""; }; @@ -1808,9 +1796,7 @@ 6926DA8D2ADD2876005760D2 /* PostHogContext.swift */, 6A11C0DE2026092800000002 /* PostHogHingeStatusObserver.swift */, 69779BEB2AE68E6900D7A48E /* UIViewController.swift */, - 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */, 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */, - 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */, 69ED1A5B2C7F15F300FE7A91 /* PostHogSessionManager.swift */, B5EB989F28A201A9F44026EE /* PostHogCompression.swift */, 69ED1A9E2C8F451B00FE7A91 /* PostHogPersonProfiles.swift */, @@ -1848,6 +1834,7 @@ B74800000000000000000004 /* PostHogTests-Bridging-Header.h */, B74800000000000000000003 /* PostHogPushNotificationSwizzlingTest.swift */, D0E500012FA0000100000001 /* PostHogEventSnapshotTests.swift */, + D0E500022FA0000100000001 /* PostHogEventTest.swift */, DA27AEC82F56B3F3002A59CE /* PostHogSamplingTest.swift */, DA27AEF12F56B3F3002A59CE /* PostHogGeoIpTest.swift */, DB7100052F80000100000001 /* PostHogTracingHeadersTest.swift */, @@ -1880,7 +1867,6 @@ 6A11C0DE2026092300000002 /* PostHogContextScreenSizeTest.swift */, 6A11C0DE2026092800000012 /* PostHogSurveySheetDetentsTest.swift */, 6A11C0DE2026092800000004 /* PostHogHingeStatusTest.swift */, - 690FF0DE2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift */, DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */, DA4FFB182DA93C78006BAEEA /* PostHogReplayScreenshotDedupTest.swift */, EA5700022DA93C78006BAEEA /* PostHogReplayCaptureTouchesTest.swift */, @@ -1896,6 +1882,7 @@ DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */, DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */, C08700022E00000100000870 /* PostHogReplayCameraTest.swift */, + C0870A022E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift */, C02440022E00000100000244 /* PostHogViewControllerTraversalTest.swift */, DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */, C2A0C3FD5963418C916D3CF6 /* SwiftUITapAutocaptureTests.swift */, @@ -1911,7 +1898,6 @@ DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */, 699C5FEE2C20242A007DB818 /* UUIDTest.swift */, DAF95F602D077C1C001E82BB /* PostHogWebPTest.swift */, - 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */, 69ED1AB52C90711D00FE7A91 /* PostHogSDKPersonProfilesTest.swift */, DA52CAD22F2402E000305F3D /* PostHogMulticastCallbackTest.swift */, DA52CAEE2F24319800305F3D /* PostHogSessionReplayPluginTest.swift */, @@ -2059,14 +2045,10 @@ 69EE82BB2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift */, 69EE82CD2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift */, 69F517E72BAC675800F52C14 /* RRWireframe.swift */, - 69F517E92BAC684F00F52C14 /* RRStyle.swift */, - 69F517F22BAC734300F52C14 /* UIColor+Util.swift */, - 69F518112BAC783300F52C14 /* CGColor+Util.swift */, 69F518132BAC7F4300F52C14 /* Date+Util.swift */, 69F518152BAC7F9200F52C14 /* UIView+Util.swift */, E25D0AE93D314E7FBE8DA8C7 /* PostHogMaskSettleAnalysis.swift */, C0FFEE002F8000010000C001 /* PostHogPresentationCover.swift */, - 69F518172BAC80A300F52C14 /* String+Util.swift */, DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */, 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */, 69F23A772BB30991001194F6 /* NetworkSample.swift */, @@ -2462,8 +2444,8 @@ isa = PBXGroup; children = ( DA92C6602DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift */, - DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */, - DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */, + DA92C6482DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift */, + DA92C6402DCE09C700C02F3E /* PostHogConsoleLogEntry.swift */, DA74644E2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift */, ); path = "Console Logs"; @@ -3426,7 +3408,6 @@ 69261D1F2AD9681300232EC7 /* PostHogConsumerPayload.swift in Sources */, DAB565DF2D14C5660088F720 /* PostHogTagViewModifier.swift in Sources */, 6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */, - 69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */, DA0CA6F12CFF6B6300AF9500 /* UIWindow+.swift in Sources */, DAB9E23C2DE8901D00E62DB9 /* PostHogSessionReplayNetworkPlugin.swift in Sources */, DB7100012F80000100000001 /* PostHogTracingHeadersIntegration.swift in Sources */, @@ -3479,7 +3460,6 @@ 69ED1A5C2C7F15F300FE7A91 /* PostHogSessionManager.swift in Sources */, 69F518142BAC7F4300F52C14 /* Date+Util.swift in Sources */, DA86D0E32E02D78C00CF3065 /* PostHogSurveysDefaultDelegate.swift in Sources */, - 69F518182BAC80A300F52C14 /* String+Util.swift in Sources */, DA9AE9602D8E9960002F1B44 /* EdgeBorder.swift in Sources */, DA9CE32B2D3108AD00DFE652 /* analysis_enc.c in Sources */, DA9CE32C2D3108AD00DFE652 /* lossless_enc_neon.c in Sources */, @@ -3512,7 +3492,7 @@ DA9CE3422D3108AD00DFE652 /* rescaler_sse2.c in Sources */, DA9CE3432D3108AD00DFE652 /* lossless_enc.c in Sources */, DA9CE3442D3108AD00DFE652 /* alpha_processing_sse2.c in Sources */, - DA92C6412DCE09C700C02F3E /* PostHogLogEntry.swift in Sources */, + DA92C6412DCE09C700C02F3E /* PostHogConsoleLogEntry.swift in Sources */, DA9CE3452D3108AD00DFE652 /* vp8l_enc.c in Sources */, DA9CE3462D3108AD00DFE652 /* dec_sse2.c in Sources */, DA9CE3472D3108AD00DFE652 /* sharpyuv.c in Sources */, @@ -3601,15 +3581,13 @@ DA9CE3812D3108AD00DFE652 /* sharpyuv_neon.c in Sources */, 69ED1A882C89B73100FE7A91 /* PostHogSwiftUIViewModifiers.swift in Sources */, 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */, - DA92C6492DCE09DE00C02F3E /* PostHogLogLevel.swift in Sources */, + DA92C6492DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift in Sources */, DAF95F512D072F04001E82BB /* UIImage+WebP.swift in Sources */, 69F517E82BAC675800F52C14 /* RRWireframe.swift in Sources */, - 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */, DA74644F2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift in Sources */, DAFEC4552E03F8EF0064535E /* PostHogNextSurveyQuestion.swift in Sources */, 3AE3FB3D29924E8200AFFC18 /* PostHogSDK.swift in Sources */, DA690C6A2DA54BCC0045FF4E /* PostHogSurveyAppearance.swift in Sources */, - 69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */, 3AE3FB3F29924F4F00AFFC18 /* PostHogConfig.swift in Sources */, 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */, 56061CE612D20303BC7F7E0E /* PushNotificationPublisher.swift in Sources */, @@ -3622,7 +3600,6 @@ DA3BB5052EDF15320097A97A /* PostHogStackFrame.swift in Sources */, DACC08BB2D6E3C6D00115E46 /* PostHogIntegration.swift in Sources */, DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */, - 690FF0C52AEFAE8200A0B06B /* PostHogLegacyQueue.swift in Sources */, 3AE3FB332991388500AFFC18 /* PostHogQueue.swift in Sources */, DA1044882D0B1D2D00C4ACF3 /* View+PostHogLabel.swift in Sources */, DAA1B2F12F8C100000112233 /* View+PostHogNoRageClick.swift in Sources */, @@ -3660,7 +3637,6 @@ 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */, 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, 3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */, - 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */, D44A33B824C72C49F5A38E0E /* PLCrashAsync.c in Sources */, 990B703A6620B474798DD770 /* PLCrashAsyncCompactUnwindEncoding.c in Sources */, ABB09493E02B20855285B547 /* PLCrashAsyncMObject.c in Sources */, @@ -3750,6 +3726,7 @@ BF5E00000000000000000001 /* PHBeforeSendExceptionTestFixture.m in Sources */, B74800000000000000000002 /* PHNotificationDelegateTestFixture.m in Sources */, D0E500012FA0000100000002 /* PostHogEventSnapshotTests.swift in Sources */, + D0E500022FA0000100000002 /* PostHogEventTest.swift in Sources */, DA979D7B2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift in Sources */, DA0C944B2D54CDD000BFD9FB /* PostHogStorageMigrationTest.swift in Sources */, 690FF0F52AF0F06100A0B06B /* PostHogSDKTest.swift in Sources */, @@ -3777,6 +3754,7 @@ 69ED1AB62C90711D00FE7A91 /* PostHogSDKPersonProfilesTest.swift in Sources */, DA578E9E2D6858BA00B3A56C /* PostHogScreenViewIntegrationTest.swift in Sources */, C08700012E00000100000870 /* PostHogReplayCameraTest.swift in Sources */, + C0870A012E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift in Sources */, C02440012E00000100000244 /* PostHogViewControllerTraversalTest.swift in Sources */, 3A62646A29C9E385007E8C07 /* MockPostHogServer.swift in Sources */, 690FF0BB2AEF8B8200A0B06B /* PostHogContextTest.swift in Sources */, @@ -3795,7 +3773,6 @@ AAC0FFEE2E20000100000002 /* PostHogMaskingCharacterizationTest.swift in Sources */, 3A62647129CAF67B007E8C07 /* PostHogStorageManagerTest.swift in Sources */, DA5E52EB2E0996C400159D20 /* PostHogSurveyEnumsTest.swift in Sources */, - 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */, 1F5558262DFC4802007643C0 /* PostHogStorageMergeTest.swift in Sources */, A1A1A1A1A1A1A1A1A1A10002 /* PostHogMaskScenarioTest.swift in Sources */, C0FFEE002F8000010000C004 /* PostHogMaskPresentationTest.swift in Sources */, @@ -3809,7 +3786,6 @@ DADE7DFE2F7EA90D00C4650C /* PostHogDeepLinkIntegrationTests.swift in Sources */, DA52CAEF2F24319800305F3D /* PostHogSessionReplayPluginTest.swift in Sources */, DA52CAEF2F2431980030AA01 /* PostHogConsoleLogInterceptorTest.swift in Sources */, - 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */, DA6F24822D4A6CA100CA2777 /* PostHogApiTest.swift in Sources */, AE3C6A5B6DAB01EF554A0270 /* PostHogPushNotificationTest.swift in Sources */, D36F92424A13D7CE61B6E348 /* PostHogSwizzlerTest.swift in Sources */, @@ -4013,7 +3989,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4028,7 +4004,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -4063,7 +4039,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4079,7 +4055,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "PostHogExample/PostHogExample-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; @@ -4137,18 +4113,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -4199,18 +4175,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; @@ -4257,13 +4233,13 @@ ); INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MODULEMAP_FILE = PostHog/PostHog.modulemap; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; @@ -4280,8 +4256,8 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -4329,13 +4305,13 @@ ); INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MODULEMAP_FILE = PostHog/PostHog.modulemap; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; @@ -4352,8 +4328,8 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; @@ -4367,13 +4343,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PNC2XCH2XP; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4387,8 +4363,8 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -4402,13 +4378,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PNC2XCH2XP; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4420,8 +4396,8 @@ SWIFT_OBJC_BRIDGING_HEADER = "PostHogTests/PostHogTests-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; @@ -4651,7 +4627,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4688,7 +4664,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4760,18 +4736,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Testing; }; @@ -4818,13 +4794,13 @@ ); INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MODULEMAP_FILE = PostHog/PostHog.modulemap; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; @@ -4842,8 +4818,8 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Testing; @@ -4859,13 +4835,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PNC2XCH2XP; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; OTHER_CFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogTests; @@ -4880,8 +4856,8 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Testing; @@ -4914,7 +4890,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4929,7 +4905,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Testing; @@ -5056,7 +5032,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift b/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift index 6466f3ca01..2780e8cd39 100644 --- a/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift +++ b/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift @@ -109,17 +109,10 @@ final class ApplicationLifecyclePublisher: AppLifecyclePublishing { name: NSApplication.didBecomeActiveNotification, object: nil) #elseif os(watchOS) - if #available(watchOS 7.0, *) { - NotificationCenter.default.addObserver(self, - selector: #selector(appDidBecomeActive), - name: WKApplication.didBecomeActiveNotification, - object: nil) - } else { - NotificationCenter.default.addObserver(self, - selector: #selector(appDidBecomeActive), - name: .init("UIApplicationDidBecomeActiveNotification"), - object: nil) - } + NotificationCenter.default.addObserver(self, + selector: #selector(appDidBecomeActive), + name: WKApplication.didBecomeActiveNotification, + object: nil) #endif } diff --git a/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift b/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift index 61b5b12dd1..92c562b154 100644 --- a/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift +++ b/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift @@ -153,12 +153,6 @@ final class PostHogAppLifeCycleIntegration: PostHogIntegration { } } - if let versionName { - props["version"] = versionName - } - if let versionCode { - props["build"] = parseBundleVersion(versionCode) - } // Toolchain only changes with a new build, so it rides on install/update instead of every event. props.merge(appBuildToolchainProperties(bundle.infoDictionary)) { current, _ in current } @@ -181,24 +175,8 @@ final class PostHogAppLifeCycleIntegration: PostHogIntegration { return } - var props: [String: Any] = [:] - props["from_background"] = !isFreshAppLaunch - - if isFreshAppLaunch { - let bundle = Bundle.main - - let versionName = appVersionString() - let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String - - if versionName != nil { - props["version"] = versionName - } - if let versionCode { - props["build"] = parseBundleVersion(versionCode) - } - - isFreshAppLaunch = false - } + let props: [String: Any] = ["from_background": !isFreshAppLaunch] + isFreshAppLaunch = false postHog.capture("Application Opened", properties: props) } diff --git a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift index 432c1479cd..2a45e55e2d 100644 --- a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift +++ b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift @@ -315,7 +315,7 @@ extension UIControl { func event(for action: Selector, to target: Any?) -> UIControl.Event? { - var events: [UIControl.Event] = [ + let events: [UIControl.Event] = [ .valueChanged, .touchDown, .touchDownRepeat, @@ -331,12 +331,9 @@ .editingDidEnd, .editingDidEndOnExit, .primaryActionTriggered, + .menuActionTriggered, ] - if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) { - events.append(.menuActionTriggered) - } - // latest event for action return events.first { event in self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false @@ -376,7 +373,7 @@ return EventType.kToggle } return EventType.kValueChange - } else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered { + } else if self == .menuActionTriggered { return EventType.kMenuAction } @@ -475,9 +472,7 @@ } extension UIToolbar { - override var ph_autocaptureEvents: UIControl.Event { - if #available(iOS 14.0, *) { .menuActionTriggered } else { .primaryActionTriggered } - } + override var ph_autocaptureEvents: UIControl.Event { .menuActionTriggered } } extension UITextField { diff --git a/PostHog/Autocapture/SwiftUITapAutocapture.swift b/PostHog/Autocapture/SwiftUITapAutocapture.swift index f7e00a43f3..8de862ac88 100644 --- a/PostHog/Autocapture/SwiftUITapAutocapture.swift +++ b/PostHog/Autocapture/SwiftUITapAutocapture.swift @@ -262,16 +262,14 @@ } } } - #if compiler(>=6.0) - if #available(iOS 18.0, *), - let element = root.accessibilityHitTest(point, event: nil) as? NSObject, - element !== root - { - visit(element, depth: 0, path: "") - // Indexed traversal must still assign a path to an unlabeled hit-test leaf. - visited.removeAll(keepingCapacity: true) - } - #endif + if #available(iOS 18.0, *), + let element = root.accessibilityHitTest(point, event: nil) as? NSObject, + element !== root + { + visit(element, depth: 0, path: "") + // Indexed traversal must still assign a path to an unlabeled hit-test leaf. + visited.removeAll(keepingCapacity: true) + } visit(root, depth: 0, path: "") return AccessibilityTarget(identifier: best ?? fallback, excluded: excluded, traits: traits, developerIdentifier: best) } diff --git a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift index 22517ac9a4..1f2bd0de4b 100644 --- a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -7,13 +7,8 @@ import Foundation -#if os(iOS) || os(macOS) || os(tvOS) - #if compiler(>=6.0) - internal import PHPLCrashReporter - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import PHPLCrashReporter - #endif +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) + internal import PHPLCrashReporter enum PostHogCrashReportProcessor { /// Process a PHPLCrashReport and convert it to PostHog $exception event properties diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 165e6ff351..7cc0210736 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -7,13 +7,8 @@ import Foundation -#if os(iOS) || os(macOS) || os(tvOS) - #if compiler(>=6.0) - internal import PHPLCrashReporter - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import PHPLCrashReporter - #endif +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) + internal import PHPLCrashReporter class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { private static let integrationInstallState = PostHogIntegrationInstallState() @@ -299,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 a0c4d18476..0d58b8f7c8 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/Logs/PostHogLogsConfig.swift b/PostHog/Logs/PostHogLogsConfig.swift index 2a5d11c8b3..99b4fc2723 100644 --- a/PostHog/Logs/PostHogLogsConfig.swift +++ b/PostHog/Logs/PostHogLogsConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter record: The log record about to be queued. /// - Returns: The record to queue, or `nil` to drop it. -public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRecord? +public typealias PostHogBeforeSendLogBlock = @Sendable (PostHogLogRecord) -> PostHogLogRecord? /// Configuration for the PostHog logs subsystem. Mutate fields on `config.logs` /// before calling `PostHogSDK.setup(_:)`. @@ -105,7 +105,7 @@ public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRec @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) { setBeforeSend(blocks.map { box in - { record in box.invokeSafely(with: record) } + { @Sendable record in box.invokeSafely(with: record) } }) } diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index 6418b3c60e..c200a8f587 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -29,20 +29,19 @@ import Foundation /// Unique event identifier generated by the SDK. @objc public private(set) var uuid: UUID // Only used for Replay. - // Publicly this remains named apiKey for BeforeSendBlock compatibility, - // but it carries the PostHog project token and is serialized as api_key on the wire. - var apiKey: String? + // Serialized as api_key on the wire. + var projectToken: String? /// Set by the SDK when this event took the throttled replay debug bundle, so it survives `beforeSend` renames or replacement. var carriesReplayDebugBundle = false - init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), apiKey: String? = nil) { + init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), projectToken: String? = nil) { self.event = event self.distinctId = distinctId self.properties = properties ?? [:] self.timestamp = timestamp self.uuid = uuid - self.apiKey = apiKey + self.projectToken = projectToken } // NOTE: Ideally we would use the NSCoding behaviour but it gets needlessly complex @@ -62,22 +61,15 @@ import Foundation let timestampDate = toISO8601Date(timestamp) ?? Date() - var properties = (json["properties"] as? [String: Any]) ?? [:] - - // back compatibility with v2 - let setProps = json["$set"] as? [String: Any] - if setProps != nil { - properties["$set"] = setProps - } + let properties = (json["properties"] as? [String: Any]) ?? [:] guard let distinctId = (json["distinct_id"] as? String) ?? (properties["distinct_id"] as? String) else { return nil } - // `message_id` is deprecated and only accepted for backwards compatibility with legacy persisted events. - let uuid = ((json["uuid"] as? String) ?? (json["message_id"] as? String)) ?? UUID.v7String() + let uuid = (json["uuid"] as? String) ?? UUID.v7String() let uuidObj = UUID(uuidString: uuid) ?? UUID.v7() // Wire field name remains api_key, but it carries the PostHog project token. - let projectKey = json["api_key"] as? String + let projectToken = json["api_key"] as? String return PostHogEvent( event: event, @@ -85,7 +77,7 @@ import Foundation properties: properties, timestamp: timestampDate, uuid: uuidObj, - apiKey: projectKey + projectToken: projectToken ) } @@ -98,7 +90,7 @@ import Foundation "uuid": uuid.postHogUuidString, ] - if let projectToken = apiKey { + if let projectToken { // Wire field name remains api_key, but it carries the PostHog project token. json["api_key"] = projectToken } diff --git a/PostHog/Models/PostHogFeatureFlagsLoaded.swift b/PostHog/Models/PostHogFeatureFlagsLoaded.swift index 4ce21c8350..1782054cb0 100644 --- a/PostHog/Models/PostHogFeatureFlagsLoaded.swift +++ b/PostHog/Models/PostHogFeatureFlagsLoaded.swift @@ -22,7 +22,9 @@ import Foundation /// subscription.unsubscribe() /// ``` @objc(PostHogFeatureFlagsLoaded) -public final class PostHogFeatureFlagsLoaded: NSObject { +public final class PostHogFeatureFlagsLoaded: NSObject, @unchecked Sendable { + // @unchecked: every property is a `let`, and `variants` only holds Swift `Bool` and `String` values (see init). + /// The keys of the enabled feature flags. @objc public let flags: [String] @@ -38,11 +40,16 @@ public final class PostHogFeatureFlagsLoaded: NSObject { @objc public let errorsLoading: Bool init(featureFlags: [String: Any], errorsLoading: Bool) { - let enabled = featureFlags.filter { _, value in + // Store the bridged values, not the originals: a bootstrap `NSMutableString` would stay mutable. + var enabled: [String: Any] = [:] + for (key, value) in featureFlags { if let bool = value as? Bool { - return bool + if bool { + enabled[key] = bool + } + } else if let string = value as? String { + enabled[key] = string } - return value is String } flags = Array(enabled.keys) variants = enabled diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index c6fdf758d2..4a1ef47ff2 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -207,7 +207,7 @@ class PostHogApi { } for event in events { - event.apiKey = config.projectToken + event.projectToken = config.projectToken } let toSend = events.map { $0.toJSON() } diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 543974feec..928185ce9f 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter event: The event about to be queued. /// - Returns: The event to queue, or `nil` to drop it. -public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? +public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Runtime configuration for a `PostHogSDK` instance. /// @@ -39,12 +39,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Network connectivity mode required before queued data may be flushed. @frozen @objc(PostHogDataMode) public enum PostHogDataMode: Int { /// Flush only while the device is connected to Wi-Fi. - case wifi - /// Behaves the same as `.any`. Use `.any` instead. - @available(*, deprecated, message: "Behaves the same as .any. Use .any instead. This will be removed in the next major version.") - case cellular + case wifi = 0 /// Flush while any network connection is available. - case any + case any = 2 } /// PostHog ingestion host used for all SDK network requests. @@ -60,12 +57,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// This field was formerly named `apiKey`. @objc public let projectToken: String - /// Obsolete alias for `projectToken`. - @available(*, deprecated, message: "Use projectToken instead. This will be removed in the next major version.") - @objc public var apiKey: String { - hedgeLog("apiKey is deprecated and will be removed in the next major version. Use projectToken instead.") - return projectToken - } /// Number of queued events that triggers an automatic flush. /// /// Lower values send data sooner but can increase battery and network usage. @@ -124,21 +115,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: `true`. @objc public var preloadFeatureFlags: Bool = true - /// Deprecated no-op for remote config loading. - /// - /// Remote config is now always loaded; setting this property has no effect. - /// - /// - Deprecated: Remote config is always loaded. This option will be removed in a future version. - @available(*, deprecated, message: "Remote config is now always loaded. This option is a no-op and will be removed in a future version.") - @objc public var remoteConfig: Bool { - get { true } - set { - if !newValue { - hedgeLog("remoteConfig is deprecated and is now always enabled. Setting it to false has no effect.") - } - } - } - /// Whether the SDK automatically captures application lifecycle events. /// /// When enabled, the SDK records events such as `Application Installed`, @@ -229,7 +205,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// here stalls the SDK's push retry/offline-resume flow (though not your app's main thread). /// /// Default: `nil` (requests carry no identity token). - @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? + @objc public var pushIdentityProvider: (@Sendable (_ distinctId: String, _ appId: String, _ completion: @escaping @Sendable (String?) -> Void) -> Void)? #if os(iOS) || targetEnvironment(macCatalyst) /// Enables UIKit element interaction autocapture on iOS and Mac Catalyst. @@ -306,11 +282,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Hook used to customize newly generated anonymous IDs. /// /// The SDK passes its generated UUID v7 and stores the UUID returned by this closure. - /// Existing stored anonymous IDs are not regenerated. + /// Existing stored anonymous IDs are not regenerated. The closure can be called on any thread, + /// so it must not read main-actor state. /// /// - Parameter uuid: The SDK-generated anonymous UUID. /// - Returns: The UUID to persist as the anonymous ID. - @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } + @objc public var getAnonymousId: (@Sendable (UUID) -> UUID) = { uuid in uuid } /// Pre-seeded identity and feature-flag state applied during setup, before any /// network request completes. @@ -347,18 +324,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Defaults to false. @objc public var reuseAnonymousId: Bool = false - private var _propertiesSanitizer: PostHogPropertiesSanitizer? - var legacyPropertiesSanitizer: PostHogPropertiesSanitizer? { - _propertiesSanitizer - } - - /// Hook that allows to sanitize the event properties - /// The hook is called before the event is cached or sent over the wire - @available(*, deprecated, message: "Use beforeSend instead") - @objc public var propertiesSanitizer: PostHogPropertiesSanitizer? { - get { _propertiesSanitizer } - set { _propertiesSanitizer = newValue } - } /// Determines the behavior for processing user profiles. @objc public var personProfiles: PostHogPersonProfiles = .identifiedOnly @@ -404,20 +369,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: nil (all flags are evaluated) @objc public var evaluationContexts: [String]? - /// Deprecated alias for `evaluationContexts`. - /// - /// - Deprecated: Use `evaluationContexts` instead. This property will be removed in a future version. - @available(*, deprecated, message: "Use evaluationContexts instead. This property will continue to work but will be removed in a future version.") - @objc public var evaluationEnvironments: [String]? { - get { evaluationContexts } - set { - if newValue != nil { - hedgeLog("evaluationEnvironments is deprecated. Use evaluationContexts instead.") - } - evaluationContexts = newValue - } - } - /// The identifier of the App Group that should be used to store shared analytics data. /// PostHog will try to get the physical location of the App Group’s shared container, otherwise fallback to the default location /// Default: nil @@ -427,7 +378,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// /// - Warning: This value is managed by the SDK from remote configuration and should not /// be changed by application code. - @objc public var snapshotEndpoint: String = "/s/" + @_spi(PostHogInternal) public var snapshotEndpoint: String = "/s/" /// Default PostHog ingestion host for US Cloud projects. /// @@ -451,12 +402,16 @@ public typealias BeforeSendBlock = (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 @@ -471,32 +426,32 @@ public typealias BeforeSendBlock = (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. /// In case of multiple instances, please make sure you are capturing events on the instance that has config.surveys = true - @available(iOS 15.0, *) - @available(watchOS, unavailable, message: "Surveys are only available on iOS 15+") - @available(macOS, unavailable, message: "Surveys are only available on iOS 15+") - @available(tvOS, unavailable, message: "Surveys are only available on iOS 15+") - @available(visionOS, unavailable, message: "Surveys are only available on iOS 15+") + @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") + @available(visionOS, unavailable, message: "Surveys are only available on iOS") @objc public var surveys: Bool { get { _surveys } - set { setSurveys(newValue) } + set { _surveys = newValue } } /// Configuration for mobile survey presentation and localization. /// /// Mutate fields on `config.surveysConfig` or replace this object before calling setup. - /// Available on iOS 15 and later. - @available(iOS 15.0, *) - @available(watchOS, unavailable, message: "Surveys are only available on iOS 15+") - @available(macOS, unavailable, message: "Surveys are only available on iOS 15+") - @available(tvOS, unavailable, message: "Surveys are only available on iOS 15+") - @available(visionOS, unavailable, message: "Surveys are only available on iOS 15+") + /// 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") + @available(visionOS, unavailable, message: "Surveys are only available on iOS") @objc public var surveysConfig: PostHogSurveysConfig { get { _surveysConfig } - set { setSurveysConfig(newValue) } + set { _surveysConfig = newValue } } /// Optional custom URLSessionConfiguration for network requests @@ -526,7 +481,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// /// - Warning: This is an SDK extension point used internally to share identity storage /// with SDK integrations and tests. Application code should not normally replace it. - public var storageManager: PostHogStorageManager? + @_spi(PostHogInternal) public var storageManager: PostHogStorageManager? private static func normalizeProjectToken(_ projectToken: String) -> String { projectToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -560,35 +515,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? self.host = URL(string: normalizedHost.isEmpty ? PostHogConfig.defaultHost : normalizedHost) ?? URL(string: PostHogConfig.defaultHost)! } - /// Creates a configuration using the deprecated `apiKey` name. - /// - /// - Parameter apiKey: Your PostHog project token. - /// - Deprecated: Use `init(projectToken:)` instead. - @available(*, deprecated, message: "Use init(projectToken:) instead. This will be removed in the next major version.") - @objc(apiKey:) - public convenience init( - apiKey: String - ) { - hedgeLog("apiKey is deprecated and will be removed in the next major version. Use projectToken instead.") - self.init(projectToken: apiKey) - } - - /// Creates a configuration using the deprecated `apiKey` name and an explicit host. - /// - /// - Parameters: - /// - apiKey: Your PostHog project token. - /// - host: PostHog ingestion host. Empty or invalid values fall back to `defaultHost`. - /// - Deprecated: Use `init(projectToken:host:)` instead. - @available(*, deprecated, message: "Use init(projectToken:host:) instead. This will be removed in the next major version.") - @objc(apiKey:host:) - public convenience init( - apiKey: String, - host: String = defaultHost - ) { - hedgeLog("apiKey is deprecated and will be removed in the next major version. Use projectToken instead.") - self.init(projectToken: apiKey, host: host) - } - /// Returns an array of integrations to be installed based on current configuration func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] @@ -631,17 +557,15 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? #endif #if os(iOS) || os(macOS) - if #available(iOS 14.0, macOS 11.0, *) { - // Token registration is iOS-only in v1 (the backend rejects `macos`); opened-capture - // works on both platforms. - #if os(iOS) - if capturePushNotificationSubscriptions { - integrations.append(PostHogPushNotificationSubscriptionIntegration()) - } - #endif - if installsPushNotificationOpenIntegration { - integrations.append(PostHogPushNotificationOpenIntegration()) + // Token registration is iOS-only in v1 (the backend rejects `macos`); opened-capture + // works on both platforms. + #if os(iOS) + if capturePushNotificationSubscriptions { + integrations.append(PostHogPushNotificationSubscriptionIntegration()) } + #endif + if installsPushNotificationOpenIntegration { + integrations.append(PostHogPushNotificationOpenIntegration()) } #endif @@ -658,22 +582,8 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? #endif var _surveys: Bool = true // swiftlint:disable:this identifier_name - private func setSurveys(_ value: Bool) { - // protection against objc API availability warning instead of error - // Unlike swift, which enforces stricter safety rules, objc just displays a warning - if #available(iOS 15.0, *) { - _surveys = value - } - } var _surveysConfig: PostHogSurveysConfig = .init() // swiftlint:disable:this identifier_name - private func setSurveysConfig(_ value: PostHogSurveysConfig) { - // protection against objc API availability warning instead of error - // Unlike swift, which enforces stricter safety rules, objc just displays a warning - if #available(iOS 15.0, *) { - _surveysConfig = value - } - } /// Hook that allows to sanitize the event /// The hook is called before the event is cached or sent over the wire @@ -702,7 +612,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) { setBeforeSend(blocks.map { box in - { event in box.invokeSafely(with: event) } + { @Sendable event in box.invokeSafely(with: event) } }) } diff --git a/PostHog/PostHogContext.swift b/PostHog/PostHogContext.swift index d400b18cbf..e8ffef6582 100644 --- a/PostHog/PostHogContext.swift +++ b/PostHog/PostHogContext.swift @@ -138,22 +138,12 @@ class PostHogContext { } #else init() { - if #available(watchOS 7.0, *) { - registerNotifications() - } else { - onShouldUpdateScreenSize() - } + registerNotifications() } #endif deinit { - #if !os(watchOS) - unregisterNotifications() - #else - if #available(watchOS 7.0, *) { - unregisterNotifications() - } - #endif + unregisterNotifications() } private lazy var theSdkInfo: [String: Any] = { @@ -215,10 +205,8 @@ class PostHogContext { #if targetEnvironment(macCatalyst) sysctlName = "hw.model" #elseif os(iOS) || os(visionOS) - if #available(iOS 14.0, *) { - if ProcessInfo.processInfo.isiOSAppOnMac { - sysctlName = "hw.model" - } + if ProcessInfo.processInfo.isiOSAppOnMac { + sysctlName = "hw.model" } #endif @@ -307,9 +295,9 @@ class PostHogContext { properties["$timezone"] = TimeZone.current.identifier #if !os(watchOS) - if reachability != nil { - properties["$network_wifi"] = reachability?.connection == .wifi - properties["$network_cellular"] = reachability?.connection == .cellular + if let connection = reachability?.connection { + properties["$network_wifi"] = connection == .wifi + properties["$network_cellular"] = connection == .cellular } #endif @@ -348,12 +336,10 @@ class PostHogContext { name: NSApplication.didBecomeActiveNotification, object: nil) #elseif os(watchOS) - if #available(watchOS 7.0, *) { - NotificationCenter.default.addObserver(self, - selector: #selector(onShouldUpdateScreenSize), - name: WKApplication.didBecomeActiveNotification, - object: nil) - } + NotificationCenter.default.addObserver(self, + selector: #selector(onShouldUpdateScreenSize), + name: WKApplication.didBecomeActiveNotification, + object: nil) #endif } @@ -379,11 +365,9 @@ class PostHogContext { name: NSApplication.didBecomeActiveNotification, object: nil) #elseif os(watchOS) - if #available(watchOS 7.0, *) { - NotificationCenter.default.removeObserver(self, - name: WKApplication.didBecomeActiveNotification, - object: nil) - } + NotificationCenter.default.removeObserver(self, + name: WKApplication.didBecomeActiveNotification, + object: nil) #endif } @@ -494,12 +478,7 @@ class PostHogContext { #endif }() - static let isIOSAppOnMac: Bool = { - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { - return ProcessInfo.processInfo.isiOSAppOnMac - } - return false - }() + static let isIOSAppOnMac: Bool = ProcessInfo.processInfo.isiOSAppOnMac static let isMacCatalystApp: Bool = { #if targetEnvironment(macCatalyst) diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 5ea44b3afc..9206ca0a00 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -43,7 +43,7 @@ class PostHogFileBackedQueue { migrateOldQueueFolder(queue: queue, oldQueueFolder: oldQueue) } else { // old plist file - migrateOldQueue(queue: queue, oldQueue: oldQueue) + deleteSafely(oldQueue) } } } diff --git a/PostHog/PostHogIntegration.swift b/PostHog/PostHogIntegration.swift index 82902b502e..1f23bfa770 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/PostHogLegacyQueue.swift b/PostHog/PostHogLegacyQueue.swift deleted file mode 100644 index 8b7285259c..0000000000 --- a/PostHog/PostHogLegacyQueue.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// PostHogLegacyQueue.swift -// PostHog -// -// Created by Manoel Aranda Neto on 30.10.23. -// - -import Foundation - -// Migrates the Old Queue (v2) to the new Queue (v3) -func migrateOldQueue(queue: URL, oldQueue: URL) { - if !FileManager.default.fileExists(atPath: oldQueue.path) { - return - } - - defer { - deleteSafely(oldQueue) - } - - do { - let data = try Data(contentsOf: oldQueue) - guard let array = try JSONSerialization.jsonObject(with: data) as? [Any] else { - hedgeLog("Failed to migrate queue: invalid data format") - return - } - - for item in array { - guard let event = item as? [String: Any] else { - continue - } - let timestamp = event["timestamp"] as? String ?? toISO8601String(Date()) - - let timestampDate = toISO8601Date(timestamp) ?? Date() - - let filename = "\(timestampDate.timeIntervalSince1970)" - - let contents = try JSONSerialization.data(withJSONObject: event) - - try contents.write(to: queue.appendingPathComponent(filename)) - } - } catch { - hedgeLog("Failed to migrate queue \(error)") - } -} diff --git a/PostHog/PostHogPropertiesSanitizer.swift b/PostHog/PostHogPropertiesSanitizer.swift deleted file mode 100644 index 9882f32087..0000000000 --- a/PostHog/PostHogPropertiesSanitizer.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// PostHogPropertiesSanitizer.swift -// PostHog -// -// Created by Manoel Aranda Neto on 06.08.24. -// - -import Foundation - -/// Protocol to sanitize the event properties -@objc(PostHogPropertiesSanitizer) public protocol PostHogPropertiesSanitizer { - /// Sanitizes the event properties - /// - Parameter properties: the event properties to sanitize - /// - Returns: the sanitized properties - /// - /// - Note: `inout` cannot be used in Swift protocols, so clone the properties before mutating them. - /// - /// ```swift - /// private class ExampleSanitizer: PostHogPropertiesSanitizer { - /// public func sanitize(_ properties: [String: Any]) -> [String: Any] { - /// var sanitizedProperties = properties - /// // Perform sanitization - /// // For example, removing keys with empty values - /// for (key, value) in properties { - /// if let stringValue = value as? String, stringValue.isEmpty { - /// sanitizedProperties.removeValue(forKey: key) - /// } - /// } - /// return sanitizedProperties - /// } - /// } - /// ``` - @objc func sanitize(_ properties: [String: Any]) -> [String: Any] -} diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 711084e8f0..9d29667233 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -237,15 +237,7 @@ class PostHogQueue { _ = updatePauseState(for: reachability) } - do { - try reachability?.startNotifier() - } catch { - // Without a running notifier no callback can ever clear a - // reachability pause, so it must not outlive the failure. - // URLSession errors and the retry backoff cover connectivity. - hedgeLog("Error: Unable to monitor network reachability: \(error)") - stateLock.withLock { paused = false } - } + reachability?.startNotifier() #endif } diff --git a/PostHog/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index 0281379fc6..8f3242b94f 100644 --- a/PostHog/PostHogRemoteConfig.swift +++ b/PostHog/PostHogRemoteConfig.swift @@ -156,7 +156,7 @@ class PostHogRemoteConfig { preloadErrorTrackingConfig() preloadPushConfig() - // Remote config is always loaded (config.remoteConfig is now a no-op) + // Remote config is always loaded preloadRemoteConfig() } @@ -224,7 +224,7 @@ class PostHogRemoteConfig { func reloadRemoteConfig( callback: (([String: Any]?) -> Void)? = nil ) { - // Remote config is always loaded (config.remoteConfig is now a no-op) + // Remote config is always loaded // Note: this guard has the same withLock closure-return bug as loadFeatureFlags // had, but for remote config duplicate concurrent requests are harmless (no // identity-sensitive params). Fixing it properly requires a pending callback @@ -280,7 +280,7 @@ class PostHogRemoteConfig { } func reloadFeatureFlags( - callback: (([String: Any]?) -> Void)? = nil + callback: ((FeatureFlagsLoadResult) -> Void)? = nil ) { reloadFeatureFlags(callback: callback, surveyCompletion: nil) } @@ -290,7 +290,7 @@ class PostHogRemoteConfig { } private func reloadFeatureFlags( - callback: (([String: Any]?) -> Void)?, + callback: ((FeatureFlagsLoadResult) -> Void)?, surveyCompletion: (([String: Any]?) -> Void)? ) { guard canReloadFlagsForTesting else { @@ -300,7 +300,7 @@ class PostHogRemoteConfig { guard let storageManager = config.storageManager else { hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags") - callback?(nil) + callback?(failedLoadResult()) surveyCompletion?(nil) return } @@ -422,7 +422,7 @@ class PostHogRemoteConfig { anonymousId: String?, deviceId: String? = nil, groups: [String: String], - callback: (([String: Any]?) -> Void)? = nil, + callback: ((FeatureFlagsLoadResult) -> Void)? = nil, surveyCompletion: (([String: Any]?) -> Void)? = nil, coalesceWithCurrentRequest: Bool = false ) { @@ -492,15 +492,16 @@ class PostHogRemoteConfig { self.processErrorTrackingConfig(nil) self.notifyFeatureFlagsAndRelease(cachedFeatureFlags) - callback?(cachedFeatureFlags) + callback?(FeatureFlagsLoadResult(featureFlags: cachedFeatureFlags, lastKnownFeatureFlags: cachedFeatureFlags)) return } // Safely handle optional data guard var data = data else { hedgeLog("Error: Flags response data is nil") + let result = self.failedLoadResult() self.notifyFeatureFlagsAndRelease(nil) - callback?(nil) + callback?(result) return } @@ -512,8 +513,9 @@ class PostHogRemoteConfig { let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any] else { hedgeLog("Error: Flags response missing correct featureFlags format") + let result = self.failedLoadResult() self.notifyFeatureFlagsAndRelease(nil) - callback?(nil) + callback?(result) return } @@ -576,7 +578,7 @@ class PostHogRemoteConfig { } self.notifyFeatureFlagsAndRelease(loadedFeatureFlags) - callback?(loadedFeatureFlags) + callback?(FeatureFlagsLoadResult(featureFlags: loadedFeatureFlags, lastKnownFeatureFlags: loadedFeatureFlags ?? [:])) } } } @@ -794,6 +796,12 @@ class PostHogRemoteConfig { } } + /// Snapshots the cached flags for a failed load. Taken before listeners and callbacks run. + private func failedLoadResult() -> FeatureFlagsLoadResult { + let cached = featureFlagsLock.withLock { getCachedFeatureFlags() ?? [:] } + return FeatureFlagsLoadResult(featureFlags: nil, lastKnownFeatureFlags: cached) + } + private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) { let (pending, surveyWaiters): (PendingFeatureFlagsRequest?, [([String: Any]?) -> Void]) = loadingFeatureFlagsLock.withLock { self.loadingFeatureFlags = false @@ -812,7 +820,7 @@ class PostHogRemoteConfig { anonymousId: pending.anonymousId, deviceId: pending.deviceId, groups: pending.groups, - callback: { flags in pending.callbacks.forEach { $0(flags) } } + callback: { result in pending.callbacks.forEach { $0(result) } } ) } else { for waiter in surveyWaiters { @@ -878,7 +886,12 @@ class PostHogRemoteConfig { featureFlagsLock.withLock { guard !bootstrappedFlags.isEmpty else { return false } setCachedFeatureFlags(bootstrappedFlags) - setCachedFeatureFlagPayload(bootstrappedPayloads) + // Bootstrap payloads are already decoded; serialize them like /flags payloads so reads + // decode every cached payload exactly once (a bootstrapped "123" stays a String). + setCachedFeatureFlagPayload(bootstrappedPayloads.compactMapValues { payload in + (try? JSONSerialization.data(withJSONObject: payload, options: .fragmentsAllowed)) + .flatMap { String(data: $0, encoding: .utf8) } + }) return true } } @@ -1055,22 +1068,19 @@ class PostHogRemoteConfig { flags = getCachedFeatureFlagPayload() } - let value = flags?[key] - - guard let stringValue = value as? String else { - return value - } + return Self.decodePayload(flags?[key], key: key) + } + /// Decodes a cached payload, which is stored as serialized JSON (mimicking posthog-js's `JSON.parse`). + /// Malformed, empty, or whitespace-only input returns `nil`, never the raw string. + private static func decodePayload(_ value: Any?, key: String) -> Any? { + guard let serialized = value as? String else { return value } do { - // The payload value is stored as a string and is not pre-parsed... - // We need to mimic the JSON.parse of JS which is what posthog-js uses - return try JSONSerialization.jsonObject(with: stringValue.data(using: .utf8)!, options: .fragmentsAllowed) + return try JSONSerialization.jsonObject(with: Data(serialized.utf8), options: .fragmentsAllowed) } catch { - hedgeLog("Error parsing the object \(String(describing: value)): \(error)") + hedgeLog("Ignoring malformed payload for feature flag \(key): \(error)") + return nil } - - // fallback to original value if not possible to serialize - return value } func getFeatureFlagResult(_ key: String) -> PostHogFeatureFlagResult? { @@ -1104,17 +1114,7 @@ class PostHogRemoteConfig { } private func makeFeatureFlagResult(key: String, flagValue: Any?, payloadValue: Any?) -> PostHogFeatureFlagResult { - let payload: Any? - if let stringValue = payloadValue as? String { - do { - payload = try JSONSerialization.jsonObject(with: stringValue.data(using: .utf8)!, options: .fragmentsAllowed) - } catch { - hedgeLog("Error parsing the object \(String(describing: payloadValue)): \(error)") - payload = payloadValue - } - } else { - payload = payloadValue - } + let payload = Self.decodePayload(payloadValue, key: key) let isEnabled: Bool let variant: String? @@ -1324,7 +1324,16 @@ private struct PendingFeatureFlagsRequest { let anonymousId: String? let deviceId: String? let groups: [String: String] - let callbacks: [([String: Any]?) -> Void] + let callbacks: [(FeatureFlagsLoadResult) -> Void] +} + +/// The outcome of one feature flags load, captured once when it completes so every coalesced +/// callback sees the same values even if the cache changes while they run (for example a `reset()`). +struct FeatureFlagsLoadResult { + /// The loaded flags, or `nil` if the load failed. + let featureFlags: [String: Any]? + /// The loaded flags, or the cached flags when the load failed. + let lastKnownFeatureFlags: [String: Any] } #if TESTING diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 8f627fcf8d..840f35fd90 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -133,18 +133,10 @@ let maxRetryDelay = 30.0 private weak var surveysIntegration: PostHogSurveyIntegration? #endif - // nonisolated(unsafe) is introduced in Swift 5.10 - #if swift(>=5.10) - /// Shared singleton SDK instance used by most applications. - /// - /// Call `setup(_:)` once with a `PostHogConfig` before using capture APIs. - @objc public nonisolated(unsafe) static let shared: PostHogSDK = .init(PostHogConfig(projectToken: "")) - #else - /// Shared singleton SDK instance used by most applications. - /// - /// Call `setup(_:)` once with a `PostHogConfig` before using capture APIs. - @objc public static let shared: PostHogSDK = .init(PostHogConfig(projectToken: "")) - #endif + /// Shared singleton SDK instance used by most applications. + /// + /// Call `setup(_:)` once with a `PostHogConfig` before using capture APIs. + @objc public nonisolated(unsafe) static let shared: PostHogSDK = .init(PostHogConfig(projectToken: "")) deinit { #if !os(watchOS) @@ -181,7 +173,7 @@ let maxRetryDelay = 30.0 } if config.projectToken.isEmpty { - hedgeLog("PostHog SDK will be disabled because projectToken or apiKey is empty.") + hedgeLog("PostHog SDK will be disabled because projectToken is empty.") return } @@ -210,11 +202,7 @@ let maxRetryDelay = 30.0 }) #if !os(watchOS) - do { - reachability = try Reachability() - } catch { - // ignored - } + reachability = Reachability() context = PostHogContext(reachability) #else context = PostHogContext() @@ -339,10 +327,8 @@ let maxRetryDelay = 30.0 #if os(iOS) || os(macOS) // Releases a prewarm this setup turns out not to want — including while opted out, // where the integrations above were never installed and so could never release it. - if #available(iOS 14.0, macOS 11.0, *) { - if !config.installsPushNotificationOpenIntegration { - DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() - } + if !config.installsPushNotificationOpenIntegration { + DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() } #endif @@ -554,7 +540,6 @@ let maxRetryDelay = 30.0 /// Only the first URL context is captured. /// /// - Parameter openURLContexts: The set of URL contexts from the scene delegate. - @available(iOS 13.0, tvOS 13.0, *) @objc public func captureDeepLink(openURLContexts: Set) { if let context = openURLContexts.first { captureDeepLink(url: context.url, referrer: context.options.sourceApplication) @@ -744,10 +729,10 @@ 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), + "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode, ] #else var replayDebugProperties: [String: Any] = ["$recording_status": "disabled"] @@ -1233,10 +1218,7 @@ let maxRetryDelay = 30.0 return true } guard let reachability else { return true } - if case .unavailable = reachability.connection { - return false - } - return true + return reachability.connection != .unavailable #else return true #endif @@ -1618,9 +1600,9 @@ let maxRetryDelay = 30.0 } // Filtering after the full build stays robust as new context properties are added later: - // anything not explicitly allowlisted is stripped. beforeSend hooks and the legacy - // propertiesSanitizer run later (in buildEvent) and may re-add keys — an accepted - // escape hatch, codified in the minimal-event contract. + // anything not explicitly allowlisted is stripped. beforeSend hooks run later (in + // buildEvent) and may re-add keys — an accepted escape hatch, codified in the + // minimal-event contract. if let propertyAllowlist { finalProperties = finalProperties.filter { propertyAllowlist.contains($0.key) || $0.key == Self.replayDebugClaimMarkerKey @@ -1814,13 +1796,6 @@ let maxRetryDelay = 30.0 queueEvent(event, queue: queue) } - private func sanitizeProperties(_ properties: [String: Any]) -> [String: Any] { - if let sanitizer = config.legacyPropertiesSanitizer { - return sanitizer.sanitize(properties) - } - return properties - } - /// Assigns an additional distinct ID to the current user. /// /// Use alias when a user should be connected to another identifier that was previously used @@ -1933,12 +1908,11 @@ let maxRetryDelay = 30.0 func buildEvent(event eventName: String, distinctId: String, properties: [String: Any], timestamp: Date = Date()) -> PostHogEvent? { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil - let sanitizedProperties = sanitizeProperties(properties) let event = PostHogEvent( event: eventName, distinctId: distinctId, - properties: sanitizedProperties, + properties: properties, timestamp: timestamp ) @@ -2117,7 +2091,7 @@ let maxRetryDelay = 30.0 /// /// ```swift /// PostHogSDK.shared.setPersonPropertiesForFlags(["plan": "premium"], reloadFeatureFlags: false) - /// PostHogSDK.shared.reloadFeatureFlags { + /// PostHogSDK.shared.reloadFeatureFlags { _ in /// let flagValue = PostHogSDK.shared.isFeatureEnabled("new_feature") /// } /// ``` @@ -2126,9 +2100,10 @@ let maxRetryDelay = 30.0 /// leaves your app in control of when flags load. /// /// - Note: `reset()` clears person properties set here, so they must be set again afterwards. - /// - Note: `reloadFeatureFlags(_:)` reports that the reload finished, not that it succeeded. If the - /// request fails, or the project is over its feature flag quota, the handler still runs and the - /// flags you read are the previously cached ones. + /// - Note: The `reloadFeatureFlags(_:)` handler runs whether or not the reload succeeded. If the + /// request fails, `errorsLoading` is `true` and the flags you read are the previously cached ones. + /// If the project is over its feature flag quota, `errorsLoading` is `false` but the flags are + /// still the previously cached ones. /// /// - Parameters: /// - properties: Dictionary of person properties to include in flag evaluation @@ -2322,29 +2297,43 @@ let maxRetryDelay = 30.0 /// Reloads feature flags for the current user and group context. @objc public func reloadFeatureFlags() { - reloadFeatureFlags { + reloadFeatureFlags { _ in // No use case } } - /// Reloads feature flags and invokes a callback when finished. + /// Reloads feature flags and invokes a callback with the result when finished. /// - /// - Parameter callback: Invoked when the reload finishes, or immediately if the reload - /// is skipped (SDK disabled/opted-out, or no remote config available). + /// ```swift + /// PostHogSDK.shared.reloadFeatureFlags { result in + /// if result.errorsLoading { + /// // The reload failed. result.variants holds the last known flags. + /// } + /// } + /// ``` + /// + /// - Parameter callback: Invoked when the reload finishes, possibly on a background thread. + /// If the request fails, ``PostHogFeatureFlagsLoaded/errorsLoading`` is `true` and the flags are + /// the last known ones. If the reload is skipped because the SDK isn't set up, it's invoked + /// right away with no flags and `errorsLoading` set to `true`. To update UI or other main-actor + /// state, hop to the main actor, for example with `Task { @MainActor in ... }`. @objc(reloadFeatureFlagsWithCallback:) - public func reloadFeatureFlags(_ callback: @escaping () -> Void) { + public func reloadFeatureFlags(_ callback: @escaping @Sendable (PostHogFeatureFlagsLoaded) -> Void) { if !isEnabled() { - callback() + callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return } guard let remoteConfig else { - callback() + callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return } - remoteConfig.reloadFeatureFlags { _ in - callback() + remoteConfig.reloadFeatureFlags { result in + callback(PostHogFeatureFlagsLoaded( + featureFlags: result.lastKnownFeatureFlags, + errorsLoading: result.featureFlags == nil + )) } } @@ -2582,19 +2571,6 @@ let maxRetryDelay = 30.0 return remoteConfig?.getAllFeatureFlagResults() } - /// Returns the payload for a feature flag. - /// - /// - Parameter key: The feature flag key. - /// - Returns: The flag payload, or `nil` if the flag or payload is unavailable. - /// - Warning: This method does not send the `$feature_flag_called` event. - /// Use `getFeatureFlagResult(_:)` instead for proper analytics tracking. - @available(*, deprecated, message: "Use getFeatureFlagResult(_:) instead which properly tracks feature flag usage") - @objc public func getFeatureFlagPayload(_ key: String) -> Any? { - // Don't send event to maintain backwards compatibility - let result = getFeatureFlagResult(key, sendEvent: false) - return result?.payload - } - private func flagValuesEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { switch (lhs, rhs) { case (nil, nil): @@ -2765,7 +2741,7 @@ let maxRetryDelay = 30.0 // Gate on the same conditions that install the subscription integration: auto-capture and // swizzling. Without swizzling the integration is skipped, so refetching would fire the host's // APNs lifecycle with no observer to forward the token. - if #available(iOS 14.0, *), config.capturePushNotificationSubscriptions, config.enableSwizzling { + if config.capturePushNotificationSubscriptions, config.enableSwizzling { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh() } #endif @@ -3485,7 +3461,6 @@ let maxRetryDelay = 30.0 /// (`close()`), or at `setup()` when the config disables push-open capture or the app is /// opted out. If `setup()` is never called they stay for the process lifetime. The per-class /// delegate wrapper, as elsewhere in this SDK, stays for the process lifetime regardless. - @available(iOS 14.0, macOS 11.0, *) @objc public static func prewarmPushNotificationOpenCapture() { DI.main.pushNotificationPublisher.prewarmNotificationResponseCapture() } @@ -3514,7 +3489,6 @@ let maxRetryDelay = 30.0 /// `notification.request.identifier` differ are two taps, not one reported twice. /// /// - Parameter response: The `UNNotificationResponse` received from the system. - @available(iOS 14.0, macOS 11.0, *) @objc public func capturePushNotificationOpened(response: UNNotificationResponse) { let content = response.notification.request.content // Free-text content is captured only for PostHog-attributed pushes: forwarding the @@ -3728,7 +3702,6 @@ let maxRetryDelay = 30.0 #endif #if os(iOS) || os(macOS) - @available(iOS 14.0, macOS 11.0, *) func getPushNotificationIntegration() -> PostHogPushNotificationOpenIntegration? { getIntegration() } @@ -3736,7 +3709,6 @@ let maxRetryDelay = 30.0 #endif #if os(iOS) - @available(iOS 14.0, *) func getPushNotificationSubscriptionIntegration() -> PostHogPushNotificationSubscriptionIntegration? { getIntegration() } diff --git a/PostHog/PostHogSessionManager.swift b/PostHog/PostHogSessionManager.swift index d239902f61..7220dd2245 100644 --- a/PostHog/PostHogSessionManager.swift +++ b/PostHog/PostHogSessionManager.swift @@ -9,10 +9,10 @@ import Foundation /// Manages the active PostHog session ID and session rotation state. /// -/// - Warning: This class is public for backwards compatibility, but is intended for -/// SDK-internal use only. Application code should use `PostHogSDK.getSessionId()`, +/// - Warning: For PostHog's wrapper SDKs only, which import it with +/// `@_spi(PostHogInternal)`. Application code should use `PostHogSDK.getSessionId()`, /// `startSession()`, and `endSession()` instead of interacting with this manager directly. -@objc public class PostHogSessionManager: NSObject { +@_spi(PostHogInternal) @objc public class PostHogSessionManager: NSObject { enum SessionIDChangeReason: String { case sessionIdEmpty = "Session id was empty" case sessionStart = "Session started" @@ -24,8 +24,7 @@ import Foundation } /// Session manager used by `PostHogSDK.shared`. - @available(*, deprecated, message: "PostHogSessionManager becomes SDK-internal in PostHog 4.0. Use PostHogSDK.shared.getSessionId(), startSession() and endSession() instead. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") - @objc public static var shared: PostHogSessionManager { + public static var shared: PostHogSessionManager { PostHogSDK.shared.sessionManager } @@ -75,8 +74,7 @@ import Foundation /// Use with care: changing the session ID affects analytics session attribution and session replay. /// /// - Parameter sessionId: Session ID to use for subsequent events. - @available(*, deprecated, message: "PostHogSessionManager becomes SDK-internal in PostHog 4.0. Use PostHogSDK.shared.getSessionId(), startSession() and endSession() instead. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") - @objc public func setSessionId(_ sessionId: String) { + public func setSessionId(_ sessionId: String) { setSessionIdInternal(sessionId, at: now(), reason: .customSessionId) } diff --git a/PostHog/PostHogStorageManager.swift b/PostHog/PostHogStorageManager.swift index a18393a6bf..25571844e4 100644 --- a/PostHog/PostHogStorageManager.swift +++ b/PostHog/PostHogStorageManager.swift @@ -9,10 +9,10 @@ import Foundation /// Manages persisted identity metadata for a PostHog SDK instance. /// -/// - Warning: This class is public for backwards compatibility, but is intended for -/// SDK-internal use only. Application code should use `PostHogSDK` identity APIs +/// - Warning: For PostHog's wrapper SDKs only, which import it with +/// `@_spi(PostHogInternal)`. Application code should use `PostHogSDK` identity APIs /// instead of interacting with storage directly. -public class PostHogStorageManager { +@_spi(PostHogInternal) public class PostHogStorageManager { private let storage: PostHogStorage! private let anonLock = NSLock() diff --git a/PostHog/PostHogVersion.swift b/PostHog/PostHogVersion.swift index eb01bb7921..8c85afcccf 100644 --- a/PostHog/PostHogVersion.swift +++ b/PostHog/PostHogVersion.swift @@ -11,12 +11,12 @@ import Foundation /// Current SDK version string. /// /// - Warning: This is intended for SDK internals and integrations. Application code should not mutate it. -public var postHogVersion = "3.91.0" +@_spi(PostHogInternal) public var postHogVersion = "3.91.0" /// Default SDK name reported by the native iOS SDK. -public let postHogiOSSdkName = "posthog-ios" +let postHogiOSSdkName = "posthog-ios" /// SDK name included in captured event context. /// /// - Warning: This is intended for SDK internals and wrapper SDKs. Application code should not mutate it. -public var postHogSdkName = postHogiOSSdkName +@_spi(PostHogInternal) public var postHogSdkName = postHogiOSSdkName diff --git a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift index 855d938727..c079e942aa 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift @@ -7,7 +7,6 @@ /// /// Swizzle installation and teardown are driven entirely by the publisher (via the subscriber-count /// callback), so this integration only owns the subscription token. - @available(iOS 14.0, macOS 11.0, *) final class PostHogPushNotificationOpenIntegration: PostHogIntegration { var requiresSwizzling: Bool { true } @@ -99,7 +98,6 @@ } #if TESTING - @available(iOS 14.0, macOS 11.0, *) extension PostHogPushNotificationOpenIntegration { static func clearInstalls() { integrationInstallState.clear() diff --git a/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift index ed70212c87..f1904c3576 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift @@ -10,7 +10,6 @@ /// /// Swizzle installation and teardown are driven entirely by the publisher (via the subscriber-count /// callback), so this integration only owns the subscription token. - @available(iOS 14.0, *) final class PostHogPushNotificationSubscriptionIntegration: PostHogIntegration { var requiresSwizzling: Bool { true } @@ -65,7 +64,6 @@ } #if TESTING - @available(iOS 14.0, *) extension PostHogPushNotificationSubscriptionIntegration { static func clearInstalls() { integrationInstallState.clear() diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 198c96bb51..e0bb2748b4 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -697,7 +697,8 @@ final class PostHogPushSubscriptionHandler { return completion(cached) } - var completed = false + // Guarded by stateLock. + nonisolated(unsafe) var completed = false // A provider that never calls its completion would hold isSending for the whole process and // wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a // token-less send. A late real completion doesn't deliver (via `completed`) but still diff --git a/PostHog/Replay/CGColor+Util.swift b/PostHog/Replay/CGColor+Util.swift deleted file mode 100644 index 9fee8b564b..0000000000 --- a/PostHog/Replay/CGColor+Util.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// CGColor+Util.swift -// PostHog -// -// Created by Manoel Aranda Neto on 21.03.24. -// - -#if os(iOS) - - import Foundation - import UIKit - - extension CGColor { - func toRGBString() -> String? { - // see dicussion: https://github.com/PostHog/posthog-ios/issues/226 - // Allow only CGColors with an intiialized value of `numberOfComponents` with a value in 3...4 range - // Loading dynamic colors from storyboard sometimes leads to some random values for numberOfComponents like `105553118884896` which crashes the app - guard - 3 ... 4 ~= numberOfComponents, // check range - let components = components, // we now assume it's safe to access `components` - components.count >= 3 - else { - return nil - } - - let red = Int(components[0] * 255) - let green = Int(components[1] * 255) - let blue = Int(components[2] * 255) - - return String(format: "#%02X%02X%02X", red, green, blue) - } - } -#endif diff --git a/PostHog/Replay/Date+Util.swift b/PostHog/Replay/Date+Util.swift index 842425d4c0..1492da5273 100644 --- a/PostHog/Replay/Date+Util.swift +++ b/PostHog/Replay/Date+Util.swift @@ -17,7 +17,6 @@ extension Date { /// /// - Parameter date: Date to convert. /// - Returns: Milliseconds since 1970-01-01 00:00:00 UTC. -@available(*, deprecated, message: "dateToMillis(_:) becomes SDK-internal in PostHog 4.0. Use Int64(date.timeIntervalSince1970 * 1000) instead. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") -public func dateToMillis(_ date: Date) -> Int64 { +@_spi(PostHogInternal) public func dateToMillis(_ date: Date) -> Int64 { date.toMillis() } diff --git a/PostHog/Replay/NetworkSample.swift b/PostHog/Replay/NetworkSample.swift index 5c6bf4d358..2f561afc11 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/PostHogLogEntry.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogEntry.swift similarity index 82% rename from PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift rename to PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogEntry.swift index a0b0662ec1..f2f01e4563 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogEntry.swift @@ -1,5 +1,5 @@ // -// PostHogLogEntry.swift +// PostHogConsoleLogEntry.swift // PostHog // // Created by Ioannis Josephides on 09/05/2025. @@ -14,11 +14,11 @@ Describes a single console log entry after it has been processed by `PostHogSessionReplayConsoleLogConfig.logSanitizer`. Each instance contains the log message content and its determined severity level. */ - @objc public class PostHogLogEntry: NSObject { + @objc public class PostHogConsoleLogEntry: NSObject { /// The severity level of the log entry. /// This determines how the log will be displayed in the session replay and /// whether it will be captured based on `minLogLevel` setting. - @objc public let level: PostHogLogLevel + @objc public let level: PostHogConsoleLogLevel /// The actual content of the log message. /// This is the processed and sanitized log message @@ -28,7 +28,7 @@ /// - Parameters: /// - level: The severity level of the log entry /// - message: The processed log message content - @objc public init(level: PostHogLogLevel, message: String) { + @objc public init(level: PostHogConsoleLogLevel, message: String) { self.level = level self.message = message super.init() diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift index f08f01f56c..383041f104 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 { @@ -14,7 +14,7 @@ struct ConsoleOutput { let timestamp: Date let text: String - let level: PostHogLogLevel + let level: PostHogConsoleLogLevel } private enum Stream { @@ -161,7 +161,7 @@ } /// Determines if the log message should be captured, based on config - private func shouldCaptureLog(entry: PostHogLogEntry, config: PostHogConfig) -> Bool { + private func shouldCaptureLog(entry: PostHogConsoleLogEntry, config: PostHogConfig) -> Bool { entry.level.rawValue >= config.sessionReplayConfig.captureLogsConfig.minLogLevel.rawValue } diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogLevel.swift similarity index 85% rename from PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift rename to PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogLevel.swift index ff3f23d70d..c00fd52170 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogLevel.swift @@ -1,5 +1,5 @@ // -// PostHogLogLevel.swift +// PostHogConsoleLogLevel.swift // PostHog // // Created by Ioannis Josephides on 09/05/2025. @@ -11,7 +11,7 @@ /// The severity level of a console log entry. /// /// Used to categorize logs by their severity in session replay. - @objc public enum PostHogLogLevel: Int { + @objc public enum PostHogConsoleLogLevel: Int { /// Informational messages, debugging output, and general logs case info /// Warning messages indicating potential issues or deprecation notices diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift b/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift index 04858437a4..b89ab4f640 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 { @@ -64,7 +64,7 @@ return } - // `PostHogLogLevel`` needs to be an Int enum for objc interop + // `PostHogConsoleLogLevel`` needs to be an Int enum for objc interop // So we need to convert this to a String before sending upstream let level = switch output.level { case .error: "error" diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 38ed755635..3934c29f90 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 40016a4d40..e7e0f0691c 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionExtension.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionExtension.swift @@ -116,7 +116,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.data(for:delegate:)`. - @available(iOS 15.0, *) func postHogData( for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, @@ -134,7 +133,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.data(from:delegate:)`. - @available(iOS 15.0, *) func postHogData( from url: URL, delegate: (any URLSessionTaskDelegate)? = nil, @@ -153,7 +151,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.upload(for:fromFile:delegate:)`. - @available(iOS 15.0, *) func postHogUpload( for request: URLRequest, fromFile fileURL: URL, @@ -173,7 +170,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.upload(for:from:delegate:)`. - @available(iOS 15.0, *) func postHogUpload( for request: URLRequest, from bodyData: Data, @@ -192,7 +188,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The downloaded file URL and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.download(for:delegate:)`. - @available(iOS 15.0, *) func postHogDownload( for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, @@ -210,7 +205,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The downloaded file URL and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.download(from:delegate:)`. - @available(iOS 15.0, *) func postHogDownload( from url: URL, delegate: (any URLSessionTaskDelegate)? = nil, @@ -228,7 +222,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The downloaded file URL and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.download(resumeFrom:delegate:)`. - @available(iOS 15.0, *) func postHogDownload( resumeFrom resumeData: Data, delegate: (any URLSessionTaskDelegate)? = nil, @@ -248,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 ab63ab1a0b..c17bafed29 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/Network/URLSessionSwizzler.swift b/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift index ca446b8cab..8c68b4d488 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift @@ -9,12 +9,7 @@ #if os(iOS) import Foundation - #if compiler(>=6.0) - internal import PostHogObjCExceptionSupport - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import PostHogObjCExceptionSupport - #endif + internal import PostHogObjCExceptionSupport final class URLSessionInstrumentation { typealias RequestModifier = (URLRequest) -> URLRequest @@ -168,7 +163,7 @@ private let downloadTaskWithURLAndCompletion: DownloadTaskWithURLAndCompletion private let downloadTaskWithRequest: DownloadTaskWithRequest private let downloadTaskWithURL: DownloadTaskWithURL - private let taskResume: TaskResume? + private let taskResume: TaskResume private var hasSwizzled = false @@ -224,13 +219,7 @@ ) downloadTaskWithURL = try DownloadTaskWithURL.build(modifyRequest: modifyRequest) - // Async/await URLSession convenience APIs are iOS 15+, so only install the - // task-level resume fallback on those OS versions. - if #available(iOS 15, *) { - taskResume = try TaskResume.build(modifyRequest: modifyRequest) - } else { - taskResume = nil - } + taskResume = try TaskResume.build(modifyRequest: modifyRequest) } func swizzle() { @@ -249,7 +238,7 @@ downloadTaskWithURLAndCompletion.swizzle() downloadTaskWithRequest.swizzle() downloadTaskWithURL.swizzle() - taskResume?.swizzle() + taskResume.swizzle() hasSwizzled = true } @@ -274,7 +263,7 @@ downloadTaskWithURLAndCompletion.unswizzle() downloadTaskWithRequest.unswizzle() downloadTaskWithURL.unswizzle() - taskResume?.unswizzle() + taskResume.unswizzle() hasSwizzled = false } diff --git a/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift b/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift index 2350d513a9..f5ac2578ad 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/PostHogMaskSettleAnalysis.swift b/PostHog/Replay/PostHogMaskSettleAnalysis.swift index 227685250c..24bbbb6cef 100644 --- a/PostHog/Replay/PostHogMaskSettleAnalysis.swift +++ b/PostHog/Replay/PostHogMaskSettleAnalysis.swift @@ -110,37 +110,6 @@ return oldRects } - /// Per-owner union of two samples taken either side of a render: covers wherever the - /// content sat while the render ran. An owner present in only one sample was still on - /// screen for part of that window, so its rect is emitted as collected rather than - /// discarding the frame — a scroll that recycles cells changes the owner set constantly, - /// and dropping every such frame freezes the recording across the whole interaction. - /// Unlike `pairedOldRects` the result is a mask list, not positionally tied to `after`. - /// nil only for a repeated owner within one sample, where the pairing is genuinely - /// ambiguous and there is no safe rect to emit. - static func sweptRects(before: [MaskedRegion]?, after: [MaskedRegion]?) -> [CGRect]? { - guard let before, let after, let beforeByOwner = rectsByOwner(before) else { - return nil - } - - var rects: [CGRect] = [] - rects.reserveCapacity(max(before.count, after.count)) - var seenAfterOwners: Set = [] - seenAfterOwners.reserveCapacity(after.count) - for region in after { - guard seenAfterOwners.insert(region.owner).inserted else { - return nil - } - // No counterpart means the owner appeared mid-render; cover where it landed. - rects.append(beforeByOwner[region.owner].map { $0.union(region.rect) } ?? region.rect) - } - // Owners that went away mid-render were still displayed for part of it. - for region in before where !seenAfterOwners.contains(region.owner) { - rects.append(region.rect) - } - return rects - } - /// Banded by measured drift: /// - still (≤ tolerance): rects as collected. /// - drift (≤ budget): each mask inflated to the swept region (both sampled positions diff --git a/PostHog/Replay/PostHogPresentationCover.swift b/PostHog/Replay/PostHogPresentationCover.swift index 9f94f4226f..03e9b566cf 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 457838055c..a20cb18f81 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -6,10 +6,9 @@ // // Created by Manoel Aranda Neto on 19.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import PhotosUI - import SwiftUI import UIKit import WebKit @@ -762,25 +761,6 @@ } } - private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil, postHog: PostHogSDK, timestampDate: Date) { - guard - let wireframe = autoreleasepool(invoking: { - toWireframe(window) - }) - else { - return - } - - captureSnapshot( - wireframe, - window: window, - windowSize: window.bounds.size, - screenName: screenName, - postHog: postHog, - timestampDate: timestampDate - ) - } - func captureSnapshot( _ wireframe: RRWireframe, window: UIWindow, @@ -877,24 +857,6 @@ return imageHash == lastImageHash } - private func setAlignment(_ alignment: NSTextAlignment, _ style: RRStyle) { - if alignment == .center { - style.verticalAlign = "center" - style.horizontalAlign = "center" - } else if alignment == .right { - style.horizontalAlign = "right" - } else if alignment == .left { - style.horizontalAlign = "left" - } - } - - private func setPadding(_ insets: UIEdgeInsets, _ style: RRStyle) { - style.paddingTop = insets.top.toInt() - style.paddingRight = insets.right.toInt() - style.paddingBottom = insets.bottom.toInt() - style.paddingLeft = insets.left.toInt() - } - private func createBasicWireframe(_ view: UIView) -> RRWireframe { let wireframe = RRWireframe() @@ -941,6 +903,8 @@ // Checked first so an explicit unmask wins over the sensitive-type early-returns // below, matching the modifier's precedence. if view.isNoMask() { + // Secure-entry fields stay masked even under an explicit unmask. + findSecureTextEntries(view, window, &maskableWidgets) return } @@ -1102,8 +1066,9 @@ let viewRect = view.toAbsoluteRect(window) let windowRect = window.frame - // Check if the rectangles do not match - if !viewRect.equalTo(windowRect) { + // Check if the rectangles do not match. A full-window leaf has no descendants + // to carry the mask, so it is masked itself. + if !viewRect.equalTo(windowRect) || view.subviews.isEmpty { maskableWidgets.append(.init(view, in: window)) } else { maskDescendants = true @@ -1121,6 +1086,17 @@ } } + private func findSecureTextEntries(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { + if (view as? UITextInputTraits)?.isSecureTextEntry == true { + maskableWidgets.append(.init(view, in: window)) + return + } + + for child in view.subviews where child.isVisibleForMasking() { + findSecureTextEntries(child, window, &maskableWidgets) + } + } + /// Recursively iterate through layer hierarchy to find maskable layers (iOS 26+) /// /// On iOS 26, SwiftUI primitives (Text, Image, Button) are rendered as CALayer sublayers @@ -1187,9 +1163,6 @@ /// All regions to redact in `window`: heuristic widgets from the hierarchy walk plus the /// live regions of `postHogMask()` reporters. Returns nil when a reporter hasn't laid out /// yet — the caller must skip the frame rather than capture it under-masked. - /// Pre-existing limitation with `screenshotModeBackgroundCapture` (off by default): pixels - /// render after this collection, so any rect source can go stale for content committed in - /// between. private func collectMaskedRegions(in window: UIWindow) -> [MaskedRegion]? { guard !window.hasCameraForReplay() else { return nil @@ -1236,8 +1209,8 @@ } // The settled path renders here so the pixels come from the same main-thread tick that - // measured the mask rects — any later and the presentation tree has moved on. Callers - // that render themselves (background capture, the bridge's first frame) pass false. + // measured the mask rects — any later and the presentation tree has moved on. The + // bridge's first frame renders itself and passes false. let image = renderImage ? window.toImage(preferFidelityRenderer: preferFidelityRenderer) : nil return ScreenshotCapture(wireframe: wireframe, windowSize: window.bounds.size, timestampDate: Date(), image: image) @@ -1376,12 +1349,7 @@ } private func isSwitchSensitive(_ view: UISwitch) -> Bool { - var containsText = true - if #available(iOS 14.0, *) { - containsText = hasText(view.title) - } - - return isTextInputSensitive(view) && containsText + isTextInputSensitive(view) && hasText(view.title) } private func isTextFieldSensitive(_ view: UITextField) -> Bool { @@ -1429,143 +1397,6 @@ return config?.sessionReplayConfig.maskAllImages == true } - private func toWireframe(_ view: UIView) -> RRWireframe? { - if !view.isVisible() { - return nil - } - - let wireframe = createBasicWireframe(view) - - let style = RRStyle() - - if let textView = view as? UITextView { - wireframe.type = "text" - wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text - wireframe.disabled = !textView.isEditable - style.color = textView.textColor?.toRGBString() - style.fontFamily = textView.font?.familyName - if let fontSize = textView.font?.pointSize.toInt() { - style.fontSize = fontSize - } - setAlignment(textView.textAlignment, style) - setPadding(textView.textContainerInset, style) - } - - if let textField = view as? UITextField { - wireframe.type = "input" - wireframe.inputType = "text_area" - let isSensitive = isTextFieldSensitive(textField) - if let text = textField.text { - wireframe.value = isSensitive ? text.mask() : text - } else { - if let text = textField.placeholder { - wireframe.value = isSensitive ? text.mask() : text - } - } - wireframe.disabled = !textField.isEnabled - style.color = textField.textColor?.toRGBString() - style.fontFamily = textField.font?.familyName - if let fontSize = textField.font?.pointSize.toInt() { - style.fontSize = fontSize - } - setAlignment(textField.textAlignment, style) - } - - if view is UIPickerView { - wireframe.type = "input" - wireframe.inputType = "select" - // set wireframe.value from selected row - } - - if let theSwitch = view as? UISwitch { - wireframe.type = "input" - wireframe.inputType = "toggle" - wireframe.checked = theSwitch.isOn - if #available(iOS 14.0, *) { - if let text = theSwitch.title { - wireframe.label = isSwitchSensitive(theSwitch) ? text.mask() : text - } - } - } - - if let imageView = view as? UIImageView { - wireframe.type = "image" - if let image = imageView.image { - if !isImageViewSensitive(imageView) { - wireframe.image = image - } - } - } - - if let button = view as? UIButton { - wireframe.type = "input" - wireframe.inputType = "button" - wireframe.disabled = !button.isEnabled - - if let text = button.titleLabel?.text { - // NOTE: this will create a ghosting effect since text will also be captured in child UILabel - // We also may be masking this UIButton but child UILabel may remain unmasked - wireframe.value = isButtonSensitive(button) ? text.mask() : text - } - } - - if let label = view as? UILabel { - wireframe.type = "text" - if let text = label.text { - wireframe.text = isLabelSensitive(label) ? text.mask() : text - } - wireframe.disabled = !label.isEnabled - style.color = label.textColor?.toRGBString() - style.fontFamily = label.font?.familyName - if let fontSize = label.font?.pointSize.toInt() { - style.fontSize = fontSize - } - setAlignment(label.textAlignment, style) - } - - if view is WKWebView { - wireframe.type = "web_view" - } - - if let progressView = view as? UIProgressView { - wireframe.type = "input" - wireframe.inputType = "progress" - wireframe.value = progressView.progress - wireframe.max = 1 - // UIProgressView theres not circular format, only custom view or swiftui - style.bar = "horizontal" - } - - if view is UIActivityIndicatorView { - wireframe.type = "input" - wireframe.inputType = "progress" - style.bar = "circular" - } - - // TODO: props: backgroundImage (probably not needed) - // TODO: componenets: UITabBar, UINavigationBar, UISlider, UIStepper, UIDatePicker - - style.backgroundColor = view.backgroundColor?.toRGBString() - let layer = view.layer - style.borderWidth = layer.borderWidth.toInt() - style.borderRadius = layer.cornerRadius.toInt() - style.borderColor = layer.borderColor?.toRGBString() - - wireframe.style = style - - if !view.subviews.isEmpty { - var childWireframes: [RRWireframe] = [] - for subview in view.subviews { - if let child = toWireframe(subview) { - childWireframes.append(child) - } - } - wireframe.childWireframes = childWireframes - } - - return wireframe - } - /// Captures the current native window for the native-screen bridge. /// [episodeFirstFrame] renders with `afterScreenUpdates` so a /// freshly-presented screen isn't captured black, and re-arms the @@ -1607,49 +1438,6 @@ ) } - /// The render sits between two mask samples instead of one: measure geometry, render - /// off-main, measure again, mask the per-owner union — provably covering wherever the - /// content sat while the render ran, so no threshold is needed. Always uses - /// `drawHierarchy`, never the presentation-tree renderer: that reads `layer.presentation()`, - /// which is main-only, and this path's other reads already sit inside `main.sync`. - @discardableResult - private func performBracketedBackgroundCapture(window: UIWindow, screenName: String?, postHog: PostHogSDK) -> Bool { - defer { finishScreenshotRender() } - - guard let before = DispatchQueue.main.sync(execute: { self.collectMaskedRegions(in: window) }) else { - return false - } - // Off-main on purpose, and the reason this mode exists: drawHierarchy on main was too - // slow to keep up. UIKit documents it as main-thread-only, so it stays experimental - // behind `screenshotModeBackgroundCapture` — the bracketing above is what keeps masks - // aligned with pixels despite the render happening on this thread. - let image = window.toImage(preferFidelityRenderer: true) - let capture = DispatchQueue.main.sync { () -> ScreenshotCapture? in - let after = self.collectMaskedRegions(in: window) - guard let rects = Self.sweptRects(before: before, after: after) else { - hedgeLog("[Session Replay] Skipping snapshot: mask samples could not be paired") - return nil - } - // No renderer preference: `renderImage: false` means this call never renders — the - // image was already taken off-main above. - return self.collectScreenshotMetadata(window, overrideMaskRects: rects, renderImage: false) - } - - guard let capture, let image, postHog.isSessionReplayActive() else { - return false - } - - return renderAndEnqueueScreenshot( - capture.wireframe, - window: window, - windowSize: capture.windowSize, - screenName: screenName, - postHog: postHog, - timestampDate: capture.timestampDate, - image: image - ) - } - /// Settle-then-shoot: after one display-pipeline depth, unchanged mask geometry proves the /// displayed frame identical to the current tree, so full-fidelity drawHierarchy is safe /// (blur/video/Metal intact); drift within budget keeps drawHierarchy with masks swept to @@ -1689,36 +1477,17 @@ return } - var screenName: String? - - if let controller = window.rootViewController { - // SwiftUI only supported with screenshotMode - if controller is AnyObjectUIHostingViewController, !postHog.config.sessionReplayConfig.screenshotMode { - hedgeLog("SwiftUI snapshot not supported, enable screenshotMode.") - return - // screen name only makes sense if we are not using SwiftUI - } else if !postHog.config.sessionReplayConfig.screenshotMode { - screenName = UIViewController.getViewControllerName(controller) - } - } - - if postHog.config.sessionReplayConfig.screenshotMode { - guard tryStartScreenshotRender() else { - return - } - - if postHog.config.sessionReplayConfig.screenshotModeBackgroundCapture { - PostHogReplayIntegration.dispatchQueue.async { [weak self] in - self?.performBracketedBackgroundCapture(window: window, screenName: screenName, postHog: postHog) - } - } else { - scheduleSettledCapture(window: window, screenName: screenName, postHog: postHog) - } + guard tryStartScreenshotRender() else { return } - // Wireframe mode always stays on main thread - generateSnapshot(window, screenName, postHog: postHog, timestampDate: Date()) + // The replay meta `href`: the visible screen, with SwiftUI hosting generics unwrapped. + // Type names only: a controller's title is display text and isn't masked. + let screenName = UIViewController.ph_topViewController(base: window.rootViewController) + .flatMap(UIViewController.getViewControllerTypeName) + .flatMap { PostHogScreenNameSanitizer.sanitize(rawScreenName: $0) } + + scheduleSettledCapture(window: window, screenName: screenName, postHog: postHog) } private func handleEventCaptured(event: String) { @@ -1856,12 +1625,9 @@ // MARK: - Debug properties extension PostHogReplayIntegration { - // ponytail: host-name heuristic; replace with an explicit host mode if a third hybrid host appears /// `$sdk_debug_replay_capture_mode`, shared by `debugProperties()` and the no-integration /// fallback in `PostHogSDK.buildProperties`, so both stay in sync. - static func captureMode(config: PostHogConfig?) -> String { - (config?.sessionReplayConfig.screenshotMode == true || postHogSdkName == "posthog-flutter") ? "screenshot" : "wireframe" - } + static let captureMode = "screenshot" /// Shared by `$sdk_debug_replay_linked_flag_trigger_status` and `$sdk_debug_replay_event_trigger_status`. static func triggerStatus(isConfigured: Bool, isActivated: Bool) -> String { @@ -1913,7 +1679,7 @@ // Config-derived and freshly-computed keys stay present regardless of `enabled` — they // never go stale, unlike a cached hold reason would. - props["$sdk_debug_replay_capture_mode"] = Self.captureMode(config: config) + props["$sdk_debug_replay_capture_mode"] = Self.captureMode // The unsent snapshot count: the held buffer while buffering, else the persisted queue. props["$sdk_debug_replay_internal_buffer_length"] = (buffering ? replayQueue?.bufferDepth : replayQueue?.depth) ?? 0 @@ -2009,8 +1775,10 @@ // pixels, and the cover hides those. var maskChildren = false for ancestor in ancestors.reversed() { - // `ph-no-mask` drops every heuristic mask below it, the cover included. + // `ph-no-mask` drops every heuristic mask below it, the cover included, + // except for secure-entry fields. if ancestor.isNoMask() { + findSecureTextEntries(cover, window, &maskableWidgets) return } guard ancestor.isNoCapture() || maskChildren else { @@ -2029,10 +1797,6 @@ } } - private protocol AnyObjectUIHostingViewController: AnyObject {} - - extension UIHostingController: AnyObjectUIHostingViewController {} - #if TESTING extension PostHogReplayIntegration { static func clearInstalls() { @@ -2041,6 +1805,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 0da3a649c5..76419a3e5c 100644 --- a/PostHog/Replay/PostHogSessionReplayConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConfig.swift @@ -24,15 +24,6 @@ /// Default: true @objc public var maskAllSandboxedViews: Bool = true - /// Deprecated placeholder for photo-library image masking. - /// - /// This property has no effect. To manually mask user photos, see the privacy controls - /// documentation: https://posthog.com/docs/session-replay/privacy?tab=iOS - /// - /// Default: `false`. - @available(*, deprecated, message: "This property has no effect and will be removed in the next major release. To learn how to manually mask user photos please see our Privacy controls documentation: https://posthog.com/docs/session-replay/privacy?tab=iOS") - @objc public var maskPhotoLibraryImages: Bool = false - /// Enable recording touch coordinates in session replay. Screenshot capture is unaffected. /// Set before SDK setup. Runtime changes are not supported. /// Default: true @@ -41,41 +32,23 @@ /// Enable capturing network telemetry /// Default: true /// - /// Note: When enabled, can be disabled remotely via project settings (requires `PostHogConfig.remoteConfig` to be enabled) + /// Note: When enabled, can be disabled remotely via project settings @objc public var captureNetworkTelemetry: Bool = true - /// By default Session replay will capture all the views on the screen as a wireframe, - /// By enabling this option, PostHog will capture the screenshot of the screen. - /// The screenshot may contain sensitive information, use with caution. - /// Default: false - @objc public var screenshotMode: Bool = false - - /// Schedule screenshot image capture on a background queue when `screenshotMode` is enabled. + /// Deprecated. Session replay always captures screenshots of the screen, with sensitive + /// content masked according to the masking options above. /// - /// Warning: Enabling this option will trigger Main Thread Checker warnings and may - /// briefly freeze the app the first time a screenshot is captured. Consider disabling - /// Main Thread Checker in your scheme's run diagnostics when enabling this. - /// - /// Experimental. - /// Default: false - @objc public var screenshotModeBackgroundCapture: Bool = false - - /// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact - /// This is used for capturing the view as a wireframe or screenshot - /// The lower the number more snapshots will be captured but higher the performance impact - /// Defaults to 1s - @available(*, deprecated, message: "Deprecated in favor of 'throttleDelay' which provides identical functionality. Will be removed in the next major release.") - @objc public var debouncerDelay: TimeInterval { - get { throttleDelay } - set { throttleDelay = newValue } + /// This property has no effect: it always reads `true` and ignores writes. + @available(*, deprecated, message: "Wireframe capture was removed and session replay always records masked screenshots. This property has no effect and will be removed in a future major release. Use the masking options or postHogMask() to hide sensitive content.") + @objc public var screenshotMode: Bool { + get { true } + set {} // swiftlint:disable:this unused_setter_value } /// Throttle delay used to reduce the number of snapshots captured and reduce performance impact - /// This is used for capturing the view as a wireframe or screenshot + /// This is used for capturing the screenshot /// The lower the number more snapshots will be captured but higher the performance impact /// Defaults to 1s - /// - /// Note: Previously `debouncerDelay` @objc public var throttleDelay: TimeInterval = 1 /// Enable capturing console output for session replay. @@ -91,7 +64,7 @@ /// /// Defaults to `false` /// - /// Note: When enabled, can be disabled remotely via project settings (requires `PostHogConfig.remoteConfig` to be enabled) + /// Note: When enabled, can be disabled remotely via project settings @objc public var captureLogs: Bool = false /// Further configuration for capturing console output @@ -118,19 +91,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/PostHogSessionReplayConsoleLogConfig.swift b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift index 1dda69e788..a77c6a33da 100644 --- a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift @@ -23,9 +23,11 @@ /// - Detect log level (best effort) /// - Process OSLog messages to remove metadata /// + /// The block runs on a background thread, so it must not read main-actor state. + /// /// - Parameter output: The raw console output to process. - /// - Returns: A `PostHogLogEntry` to capture, or `nil` to skip the log output. - @objc public var logSanitizer: ((String) -> PostHogLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer + /// - Returns: A `PostHogConsoleLogEntry` to capture, or `nil` to skip the log output. + @objc public var logSanitizer: (@Sendable (String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer /// The minimum log level to capture in session replay. /// Only log messages with this level or higher will be captured. @@ -35,13 +37,13 @@ /// - `.info` messages will be skipped /// /// Defaults to `.error` to minimize noise in session replays. - @objc public var minLogLevel: PostHogLogLevel = .error + @objc public var minLogLevel: PostHogConsoleLogLevel = .error /// Default implementation for processing console output. - static func defaultLogSanitizer(_ message: String) -> PostHogLogEntry? { + @Sendable static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? { let message = String(message) // Determine console log level - let level: PostHogLogLevel = { + let level: PostHogConsoleLogLevel = { if message.range(of: logMessageWarningPattern, options: .regularExpression) != nil { return .warn } if message.range(of: logMessageErrorPattern, options: .regularExpression) != nil { return .error } return .info @@ -55,7 +57,7 @@ return message }() : message - return PostHogLogEntry(level: level, message: sanitizedMessage) + return PostHogConsoleLogEntry(level: level, message: sanitizedMessage) } /// Default regular expression pattern used to identify error-level log messages. diff --git a/PostHog/Replay/RRStyle.swift b/PostHog/Replay/RRStyle.swift deleted file mode 100644 index 43624fd215..0000000000 --- a/PostHog/Replay/RRStyle.swift +++ /dev/null @@ -1,96 +0,0 @@ -// swiftlint:disable cyclomatic_complexity - -// -// RRStyle.swift -// PostHog -// -// Created by Manoel Aranda Neto on 21.03.24. -// - -import Foundation - -class RRStyle { - var color: String? - var backgroundColor: String? - var backgroundImage: String? - var borderWidth: Int? - var borderRadius: Int? - var borderColor: String? - var fontSize: Int? - var fontFamily: String? - var horizontalAlign: String? - var verticalAlign: String? - var paddingTop: Int? - var paddingBottom: Int? - var paddingLeft: Int? - var paddingRight: Int? - var bar: String? - - func toDict() -> [String: Any] { - var dict: [String: Any] = [:] - - if let color = color { - dict["color"] = color - } - - if let backgroundColor = backgroundColor { - dict["backgroundColor"] = backgroundColor - } - - if let backgroundImage = backgroundImage { - dict["backgroundImage"] = backgroundImage - } - - if let borderWidth = borderWidth { - dict["borderWidth"] = borderWidth - } - - if let borderRadius = borderRadius { - dict["borderRadius"] = borderRadius - } - - if let borderColor = borderColor { - dict["borderColor"] = borderColor - } - - if let fontSize = fontSize { - dict["fontSize"] = fontSize - } - - if let fontFamily = fontFamily { - dict["fontFamily"] = fontFamily - } - - if let horizontalAlign = horizontalAlign { - dict["horizontalAlign"] = horizontalAlign - } - - if let verticalAlign = verticalAlign { - dict["verticalAlign"] = verticalAlign - } - - if let paddingTop = paddingTop { - dict["paddingTop"] = paddingTop - } - - if let paddingBottom = paddingBottom { - dict["paddingBottom"] = paddingBottom - } - - if let paddingLeft = paddingLeft { - dict["paddingLeft"] = paddingLeft - } - - if let paddingRight = paddingRight { - dict["paddingRight"] = paddingRight - } - - if let bar = bar { - dict["bar"] = bar - } - - return dict - } -} - -// swiftlint:enable cyclomatic_complexity diff --git a/PostHog/Replay/RRWireframe.swift b/PostHog/Replay/RRWireframe.swift index 30adeb634e..18a15d45ea 100644 --- a/PostHog/Replay/RRWireframe.swift +++ b/PostHog/Replay/RRWireframe.swift @@ -1,5 +1,3 @@ -// swiftlint:disable cyclomatic_complexity - // // RRWireframe.swift // PostHog @@ -18,12 +16,7 @@ class RRWireframe { var posY: Int = 0 var width: Int = 0 var height: Int = 0 - var childWireframes: [RRWireframe]? - var type: String? // text|image|rectangle|input|div|screenshot - var inputType: String? - var text: String? - var label: String? - var value: Any? // string or number + var type: String? // screenshot #if os(iOS) var image: UIImage? var maskableWidgets: [CGRect]? @@ -33,13 +26,6 @@ class RRWireframe { private(set) var maskRenderFailed = false #endif var base64: String? - var style: RRStyle? - var disabled: Bool? - var checked: Bool? - var options: [String]? - var max: Int? - // internal - var parentId: Int? #if os(iOS) private func hasMaskableWidgets() -> Bool { @@ -86,30 +72,10 @@ class RRWireframe { "height": height, ] - if let childWireframes = childWireframes { - dict["childWireframes"] = childWireframes.map { $0.toDict() } - } - if let type = type { dict["type"] = type } - if let inputType = inputType { - dict["inputType"] = inputType - } - - if let text = text { - dict["text"] = text - } - - if let label = label { - dict["label"] = label - } - - if let value = value { - dict["value"] = value - } - #if os(iOS) if let image = image { if hasMaskableWidgets() { @@ -133,28 +99,6 @@ class RRWireframe { dict["base64"] = base64 } - if let style = style { - dict["style"] = style.toDict() - } - - if let disabled = disabled { - dict["disabled"] = disabled - } - - if let checked = checked { - dict["checked"] = checked - } - - if let options = options { - dict["options"] = options - } - - if let max = max { - dict["max"] = max - } - return dict } } - -// swiftlint:enable cyclomatic_complexity diff --git a/PostHog/Replay/String+Util.swift b/PostHog/Replay/String+Util.swift deleted file mode 100644 index ea8d1fe6fd..0000000000 --- a/PostHog/Replay/String+Util.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// String+Util.swift -// PostHog -// -// Created by Manoel Aranda Neto on 21.03.24. -// - -import Foundation - -extension String { - func mask() -> String { - String(repeating: "*", count: count) - } -} diff --git a/PostHog/Replay/UIColor+Util.swift b/PostHog/Replay/UIColor+Util.swift deleted file mode 100644 index b3d04639b7..0000000000 --- a/PostHog/Replay/UIColor+Util.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// UIColor+Util.swift -// PostHog -// -// Created by Manoel Aranda Neto on 21.03.24. -// -#if os(iOS) - - import Foundation - import UIKit - - extension UIColor { - func toRGBString() -> String? { - cgColor.toRGBString() - } - } -#endif diff --git a/PostHog/Replay/UIImage+Util.swift b/PostHog/Replay/UIImage+Util.swift index 1d43290061..5de42f401a 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? { @@ -37,8 +41,7 @@ /// - image: Image to encode. /// - compressionQuality: Compression quality from `0.0` to `1.0`. Defaults to `0.3`. /// - Returns: A `data:image/...;base64` string, or `nil` if encoding fails. - @available(*, deprecated, message: "imageToBase64(_:_:) becomes SDK-internal in PostHog 4.0. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") - public func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? { + @_spi(PostHogInternal) public func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? { image.toBase64(compressionQuality) } #endif diff --git a/PostHog/Replay/UIView+Util.swift b/PostHog/Replay/UIView+Util.swift index 872ad54149..d67232a3ef 100644 --- a/PostHog/Replay/UIView+Util.swift +++ b/PostHog/Replay/UIView+Util.swift @@ -111,7 +111,7 @@ } func toImage(afterScreenUpdates: Bool = false, preferFidelityRenderer: Bool = true) -> UIImage? { - // Background capture also enters here; controller traversal belongs on main. + // Replay callers render on main; the sync is defensive, since controller traversal belongs on main. let hasCamera = { (self as? UIWindow ?? self.window)?.hasCameraForReplay() ?? false } @@ -160,8 +160,7 @@ /// Mask geometry only. During a CA animation the model layer parks at the destination /// while the presentation layer holds the in-flight position the screenshot renders, so - /// mask rects have to come from the presentation tree. Wireframe geometry is a separate - /// consumer with no pixels to agree with and stays on `toAbsoluteRect`. + /// mask rects have to come from the presentation tree. func toPresentationRect(_ window: UIWindow?) -> CGRect { guard layer.presentation() != nil else { // UIView's own convert, not the layer's: it resolves a nil window to the view's. diff --git a/PostHog/Surveys/BottomSection.swift b/PostHog/Surveys/BottomSection.swift index c12f647db0..e617caab19 100644 --- a/PostHog/Surveys/BottomSection.swift +++ b/PostHog/Surveys/BottomSection.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 18/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct BottomSection: View { let label: String let action: () -> Void diff --git a/PostHog/Surveys/ConfirmationMessage.swift b/PostHog/Surveys/ConfirmationMessage.swift index 283ac05cda..a7e1ffb3c7 100644 --- a/PostHog/Surveys/ConfirmationMessage.swift +++ b/PostHog/Surveys/ConfirmationMessage.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct ConfirmationMessage: View { @Environment(\.surveyAppearance) private var appearance @@ -35,7 +34,6 @@ } } - @available(iOS 15.0, *) #Preview { ConfirmationMessage {} } diff --git a/PostHog/Surveys/IntroMessage.swift b/PostHog/Surveys/IntroMessage.swift index a74da86d99..370f83b48d 100644 --- a/PostHog/Surveys/IntroMessage.swift +++ b/PostHog/Surveys/IntroMessage.swift @@ -5,12 +5,11 @@ // 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 /// trailing `ConfirmationMessage`. Advancing records no response and sends no survey event. - @available(iOS 15.0, *) struct IntroMessage: View { @Environment(\.surveyAppearance) private var appearance @@ -39,7 +38,6 @@ } } - @available(iOS 15.0, *) #Preview { IntroMessage {} } diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index 63a5550739..66f65b3328 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) @@ -119,9 +119,7 @@ clearActiveSurvey() unsubscribeFromRemoteConfigUpdates() #if os(iOS) - if #available(iOS 15.0, *) { - config?.surveysConfig.surveysDelegate.cleanupSurveys() - } + config?.surveysConfig.surveysDelegate.cleanupSurveys() #endif } @@ -241,7 +239,9 @@ ) { getCachedOrReload( getCached: remoteConfig.getFeatureFlags, - reload: { remoteConfig.reloadFeatureFlags(callback: $0) }, + reload: { callback in + remoteConfig.reloadFeatureFlags(callback: callback.map { callback in { callback($0.featureFlags) } }) + }, forceReload: forceReload, callback: callback ) @@ -291,11 +291,6 @@ /// Shows next survey in queue. No-op if a survey is already being shown func showNextSurvey() { #if os(iOS) - guard #available(iOS 15.0, *) else { - hedgeLog("[Surveys] Surveys can be rendered only on iOS 15+") - return - } - guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.showNextSurvey() } return @@ -340,8 +335,7 @@ private func refreshActiveSurveyTranslations() { // `updateSurvey` is optional; without it, skip so the tracked language never advances past // what's actually on screen. - guard #available(iOS 15.0, *), - let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey + guard let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey else { return } // Enqueue the update inside `activeSurveyLock` so main-queue order matches commit order: @@ -551,8 +545,7 @@ /// but before the survey was on screen — a window where `updateSurvey` is dropped and later /// refreshes no-op. Pushes one update to catch up. private func reconcileRenderedTranslationOnShow(activeSurvey: PostHogSurvey, attemptId: UUID?) { - guard #available(iOS 15.0, *), - let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey + guard let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey else { return } activeSurveyLock.withLock { @@ -1190,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 08a18350c3..edb702d4f7 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 e11f7821dc..49132efe97 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,9 +28,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { onSurveyResponse: @escaping OnPostHogSurveyResponse, onSurveyClosed: @escaping OnPostHogSurveyClosed ) { - #if os(iOS) - guard #available(iOS 15.0, *) else { return } - + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) if displayController == nil { // setup window for first-time display setupWindow() @@ -52,9 +50,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { } func updateSurvey(_ survey: PostHogDisplaySurvey) { - #if os(iOS) - guard #available(iOS 15.0, *) else { return } - + #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 { @@ -66,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) { @@ -77,8 +73,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { #endif } - #if os(iOS) - @available(iOS 15.0, *) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) private func setupWindow() { if let activeWindow = UIApplication.getCurrentWindow(), let activeScene = activeWindow.windowScene { let controller = SurveyDisplayController() @@ -146,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 403b8c5938..b621492a2f 100644 --- a/PostHog/Surveys/QuestionHeader.swift +++ b/PostHog/Surveys/QuestionHeader.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct QuestionHeader: View { @Environment(\.surveyAppearance) private var appearance @@ -38,7 +37,6 @@ } } - @available(iOS 15.0, *) #Preview { QuestionHeader( question: "What can we do to improve our product?", diff --git a/PostHog/Surveys/QuestionTypes.swift b/PostHog/Surveys/QuestionTypes.swift index c8f1d7f361..bd26a9d9a1 100644 --- a/PostHog/Surveys/QuestionTypes.swift +++ b/PostHog/Surveys/QuestionTypes.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct OpenTextQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -81,7 +80,6 @@ } } - @available(iOS 15.0, *) struct LinkQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -113,7 +111,6 @@ } } - @available(iOS 15.0, *) struct RatingQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -173,7 +170,6 @@ } } - @available(iOS 15.0, *) struct SingleChoiceQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -240,7 +236,6 @@ } } - @available(iOS 15.0, *) struct MultipleChoiceQuestionView: View { @Environment(\.surveyAppearance) private var appearance diff --git a/PostHog/Surveys/SurveyDisplayController.swift b/PostHog/Surveys/SurveyDisplayController.swift index a56e7d85f4..07e30329a6 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 f4c967b626..f5b3e1b680 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 80a4f81193..5118515863 100644 --- a/PostHog/Surveys/SurveySheet.swift +++ b/PostHog/Surveys/SurveySheet.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 12/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15, *) struct SurveySheet: View { // Observed so the sheet re-renders when the displayed survey is updated in place // (e.g. re-translated after a language change), not just when questions advance. @@ -90,7 +89,6 @@ } } - @available(iOS 15, *) private struct SurveyDismissButton: View { @Environment(\.surveyAppearance) private var appearance let action: () -> Void @@ -106,7 +104,6 @@ } extension View { - @available(iOS 15, *) func surveyBottomSheet(height: CGFloat) -> some View { modifier( SurveyBottomSheetWithWithDetents(height: height) @@ -114,7 +111,6 @@ } } - @available(iOS 15.0, *) private struct SurveyBottomSheetWithWithDetents: ViewModifier { @Environment(\.surveyAppearance) private var appearance @@ -195,13 +191,11 @@ var placeholder: String? } - @available(iOS 15.0, *) private struct SurveyAppearanceEnvironmentKey: EnvironmentKey { static let defaultValue: SwiftUISurveyAppearance = .getAppearanceWithDefaults() } extension EnvironmentValues { - @available(iOS 15.0, *) var surveyAppearance: SwiftUISurveyAppearance { get { self[SurveyAppearanceEnvironmentKey.self] } set { self[SurveyAppearanceEnvironmentKey.self] = newValue } @@ -209,7 +203,6 @@ } extension SwiftUISurveyAppearance { - @available(iOS 15.0, *) static func getAppearanceWithDefaults(_ appearance: PostHogDisplaySurveyAppearance? = nil) -> SwiftUISurveyAppearance { SwiftUISurveyAppearance( fontFamily: Font.customFont(family: appearance?.fontFamily ?? "") ?? Font.body, @@ -237,13 +230,11 @@ ) } - @available(iOS 15.0, *) private static func colorFrom(css hex: String?, defaultColor: UIColor) -> Color { guard let hex = hex, !hex.isEmpty else { return Color(uiColor: defaultColor) } return Color(uiColor: UIColor(hex: hex)) } - @available(iOS 15.0, *) private static func colorFrom(css hex: String?) -> Color? { guard let hex = hex, !hex.isEmpty else { return nil } return Color(uiColor: UIColor(hex: hex)) @@ -253,7 +244,6 @@ /// - Use user-provided inputBackground if set /// - Otherwise use #f8f8f8 if survey background is white (for slight contrast) /// - Otherwise default to white - @available(iOS 15.0, *) var effectiveInputBackground: Color { if let userInputBg = inputBackground { return userInputBg @@ -267,7 +257,6 @@ /// Computed input text color matching JS SDK behavior: /// - Use user-provided inputTextColor if set /// - Otherwise auto-contrast from effectiveInputBackground - @available(iOS 15.0, *) var effectiveInputTextColor: Color { inputTextColor ?? effectiveInputBackground.getContrastingTextColor() } diff --git a/PostHog/Surveys/SurveysRootView.swift b/PostHog/Surveys/SurveysRootView.swift index ede54adbfa..96662d1959 100644 --- a/PostHog/Surveys/SurveysRootView.swift +++ b/PostHog/Surveys/SurveysRootView.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 07/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct SurveysRootView: View { @EnvironmentObject private var displayManager: SurveyDisplayController diff --git a/PostHog/Surveys/SurveysWindow.swift b/PostHog/Surveys/SurveysWindow.swift index e810ce238c..245451cc8a 100644 --- a/PostHog/Surveys/SurveysWindow.swift +++ b/PostHog/Surveys/SurveysWindow.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 06/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI import UIKit - @available(iOS 15.0, *) final class SurveysWindow: PassthroughWindow { init(controller: SurveyDisplayController, scene: UIWindowScene) { super.init(windowScene: scene) diff --git a/PostHog/Surveys/Utils/EdgeBorder.swift b/PostHog/Surveys/Utils/EdgeBorder.swift index d03196c923..be0c11e636 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 913dd25a8b..b87235890b 100644 --- a/PostHog/Surveys/Utils/EmojiRating.swift +++ b/PostHog/Surveys/Utils/EmojiRating.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct EmojiRating: View { @Environment(\.surveyAppearance) private var appearance @Binding var selectedValue: Int? diff --git a/PostHog/Surveys/Utils/MultipleChoiceOptions.swift b/PostHog/Surveys/Utils/MultipleChoiceOptions.swift index 4d9b1c7dc5..1c63f43b4a 100644 --- a/PostHog/Surveys/Utils/MultipleChoiceOptions.swift +++ b/PostHog/Surveys/Utils/MultipleChoiceOptions.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct MultipleChoiceOptions: View { @Environment(\.surveyAppearance) private var appearance @@ -131,7 +130,6 @@ } } - @available(iOS 15.0, *) private struct SurveyOptionStyle: ViewModifier { let isChecked: Bool let textColor: Color diff --git a/PostHog/Surveys/Utils/NumberRating.swift b/PostHog/Surveys/Utils/NumberRating.swift index eeb7a9287d..2bafac3284 100644 --- a/PostHog/Surveys/Utils/NumberRating.swift +++ b/PostHog/Surveys/Utils/NumberRating.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct NumberRating: View { @Environment(\.surveyAppearance) private var appearance diff --git a/PostHog/Surveys/Utils/PostHogSurveyMatching.swift b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift index 9b432a95fa..990b95b277 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 b8b0f2930e..c8a66d15af 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 2e152aaca5..31f6c09170 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 ec440b982c..c25a66f6da 100644 --- a/PostHog/Surveys/Utils/Survey+Util.swift +++ b/PostHog/Surveys/Utils/Survey+Util.swift @@ -24,13 +24,13 @@ // 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 // swiftlint:disable identifier_name /// Convenience helpers for creating and describing survey colors. - public extension UIColor { + extension UIColor { /** Creates an immutable UIColor instance specified by a hex string, CSS color name, or nil. @@ -71,7 +71,6 @@ - Returns: A new `String` with the color's hexadecimal value. */ - @available(*, deprecated, message: "UIColor.hexDescription(_:) is removed from the public API in PostHog 4.0.") func hexDescription(_ includeAlpha: Bool = false) -> String { guard cgColor.numberOfComponents == 4 else { return "Color not RGB." @@ -291,7 +290,6 @@ } extension Color { - @available(iOS 15.0, *) func getContrastingTextColor() -> Color { var r, g, b, a: CGFloat (r, g, b, a) = (0, 0, 0, 0) diff --git a/PostHog/Surveys/Utils/SurveyButton.swift b/PostHog/Surveys/Utils/SurveyButton.swift index 7ac992f485..6bf1c45886 100644 --- a/PostHog/Surveys/Utils/SurveyButton.swift +++ b/PostHog/Surveys/Utils/SurveyButton.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct SurveyButtonStyle: ButtonStyle { @Environment(\.surveyAppearance) private var appearance @Environment(\.isEnabled) private var isEnabled diff --git a/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift b/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift index 22f757d7e0..9736a33904 100644 --- a/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift +++ b/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 22/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct SurveyPresentationDetentsRepresentable: UIViewControllerRepresentable { enum Detent: Hashable, Identifiable, Comparable { case medium diff --git a/PostHog/Surveys/Utils/SurveyTranslationResolver.swift b/PostHog/Surveys/Utils/SurveyTranslationResolver.swift index 106c5afd2c..6cc23c9041 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 5773836e55..62d4ddbf92 100644 --- a/PostHog/Surveys/Utils/SwiftUI+Util.swift +++ b/PostHog/Surveys/Utils/SwiftUI+Util.swift @@ -5,12 +5,11 @@ // Created by Ioannis Josephides on 10/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI extension View { /// Reads frame changes of current view in a coordinate space (default global) - @available(iOS 14.0, *) func readFrame( in coordinateSpace: CoordinateSpace = .global, onFrame: @escaping (CGRect) -> Void @@ -24,7 +23,6 @@ } /// Reads current view's safe area insets - @available(iOS 14.0, *) func readSafeAreaInsets( onSafeAreaInsets: @escaping (EdgeInsets) -> Void ) -> some View { @@ -41,7 +39,6 @@ } } - @available(iOS 14.0, *) private struct ReadFrameModifier: ViewModifier { /// Helper for notifying parents for child view frame changes struct FramePreferenceKey: PreferenceKey { @@ -69,7 +66,6 @@ } } - @available(iOS 14.0, *) private struct ReadSafeAreaInsetsModifier: ViewModifier { /// Helper for notifying parents for child view's safe area insets struct SafeAreaInsetsPreferenceKey: PreferenceKey { diff --git a/PostHog/SwiftUI/PostHogDeepLinkListener.swift b/PostHog/SwiftUI/PostHogDeepLinkListener.swift index 6744129772..126f80ab9a 100644 --- a/PostHog/SwiftUI/PostHogDeepLinkListener.swift +++ b/PostHog/SwiftUI/PostHogDeepLinkListener.swift @@ -15,17 +15,13 @@ func body(content: Content) -> some View { #if os(iOS) || os(tvOS) || os(macOS) - if #available(iOS 14.0, tvOS 14.0, macOS 11.0, *) { - content - .onOpenURL { url in - posthog?.captureDeepLink(url: url) - } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - posthog?.captureDeepLink(userActivity: activity) - } - } else { - content - } + content + .onOpenURL { url in + posthog?.captureDeepLink(url: url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + posthog?.captureDeepLink(userActivity: activity) + } #else content #endif diff --git a/PostHog/UIViewController.swift b/PostHog/UIViewController.swift index 9265bd2e0c..6450575379 100644 --- a/PostHog/UIViewController.swift +++ b/PostHog/UIViewController.swift @@ -90,7 +90,7 @@ } private static func secondaryViewController(of split: UISplitViewController) -> UIViewController? { - if #available(iOS 14.0, tvOS 14.0, *), split.style != .unspecified { + if split.style != .unspecified { let secondary = split.viewController(for: .secondary) // Column-style splits wrap a plain column controller in a navigation // controller that holds the pushed screens. `navigationController` can also @@ -124,15 +124,19 @@ } static func getViewControllerName(_ viewController: UIViewController) -> String? { - var title: String? = String(describing: viewController.classForCoder).replacingOccurrences(of: "ViewController", with: "") + // Plain storyboard controllers have no meaningful class name, so fall back to the title. + getViewControllerTypeName(viewController) ?? viewController.title + } - // Plain storyboard controllers have no meaningful class name (stripping - // "ViewController" from "UIViewController" would otherwise leave "UI"). - if title?.isEmpty == true || viewController.classForCoder == UIViewController.self { - title = viewController.title ?? nil + /// The controller's class name without "ViewController", or nil for a plain `UIViewController`. + /// Never the display title, which can contain user data. + static func getViewControllerTypeName(_ viewController: UIViewController) -> String? { + // Stripping "ViewController" from "UIViewController" would otherwise leave "UI". + if viewController.classForCoder == UIViewController.self { + return nil } - - return title + let name = String(describing: viewController.classForCoder).replacingOccurrences(of: "ViewController", with: "") + return name.isEmpty ? nil : name } } #endif diff --git a/PostHog/Utils/BeforeSendChain.swift b/PostHog/Utils/BeforeSendChain.swift index 446ad4cc2e..098c1b084f 100644 --- a/PostHog/Utils/BeforeSendChain.swift +++ b/PostHog/Utils/BeforeSendChain.swift @@ -5,11 +5,13 @@ import Foundation -/// Composable `(T) -> T?` pipeline shared by the events and logs configs. +/// Composable `@Sendable (T) -> T?` pipeline shared by the events and logs configs. /// Blocks run in registration order; the first `nil` drops the value. /// Reference type so concurrent `set` / `run` see a coherent block snapshot. final class BeforeSendChain { - typealias Block = (T) -> T? + // @Sendable to match the public block types: converting their arrays to a + // non-Sendable element type fails at runtime (`_arrayForceCast`). + typealias Block = @Sendable (T) -> T? private let lock = NSLock() private var block: Block = { $0 } diff --git a/PostHog/Utils/BoxedBeforeSend.swift b/PostHog/Utils/BoxedBeforeSend.swift index 15e36f39c4..f3c29e862e 100644 --- a/PostHog/Utils/BoxedBeforeSend.swift +++ b/PostHog/Utils/BoxedBeforeSend.swift @@ -4,11 +4,7 @@ // import Foundation -#if compiler(>=6.0) - internal import PostHogObjCExceptionSupport -#else - @_implementationOnly import PostHogObjCExceptionSupport -#endif +internal import PostHogObjCExceptionSupport /// ObjC wrappers for the Swift function-typed `beforeSend` chains: Swift /// function types aren't `@objc`-bridgeable, and `@objc` classes can't be @@ -17,7 +13,7 @@ import Foundation /// ObjC wrapper for the events `beforeSend` block. Use with /// `PostHogConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendBlock: NSObject { +@objc public final class BoxedBeforeSendBlock: NSObject, Sendable { /// Wrapped event callback. @objc public let block: BeforeSendBlock @@ -39,7 +35,7 @@ import Foundation /// ObjC wrapper for the logs `beforeSend` block. Use with /// `PostHogLogsConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendLogBlock: NSObject { +@objc public final class BoxedBeforeSendLogBlock: NSObject, Sendable { /// Wrapped log callback. @objc public let block: PostHogBeforeSendLogBlock diff --git a/PostHog/Utils/Data+Gzip.swift b/PostHog/Utils/Data+Gzip.swift index ef88000e50..a2920bc6d3 100644 --- a/PostHog/Utils/Data+Gzip.swift +++ b/PostHog/Utils/Data+Gzip.swift @@ -38,47 +38,47 @@ import Foundation #endif /// Namespace for gzip constants used by the SDK's compression helpers. -public enum Gzip { +enum Gzip { /// Maximum value for windowBits (`MAX_WBITS`). - public static let maxWindowBits = MAX_WBITS + static let maxWindowBits = MAX_WBITS } /// Compression level whose raw value is based on zlib constants. -public struct CompressionLevel: RawRepresentable, Sendable { +struct CompressionLevel: RawRepresentable, Sendable { /// Compression level in the range of `0` (no compression) to `9` (maximum compression). - public let rawValue: Int32 + let rawValue: Int32 /// zlib `Z_NO_COMPRESSION` level. - public static let noCompression = Self(Z_NO_COMPRESSION) + static let noCompression = Self(Z_NO_COMPRESSION) /// zlib `Z_BEST_SPEED` level. - public static let bestSpeed = Self(Z_BEST_SPEED) + static let bestSpeed = Self(Z_BEST_SPEED) /// zlib `Z_BEST_COMPRESSION` level. - public static let bestCompression = Self(Z_BEST_COMPRESSION) + static let bestCompression = Self(Z_BEST_COMPRESSION) /// zlib `Z_DEFAULT_COMPRESSION` level. - public static let defaultCompression = Self(Z_DEFAULT_COMPRESSION) + static let defaultCompression = Self(Z_DEFAULT_COMPRESSION) /// Creates a compression level from a zlib raw value. /// /// - Parameter rawValue: zlib compression level constant. - public init(rawValue: Int32) { + init(rawValue: Int32) { self.rawValue = rawValue } /// Creates a compression level from a zlib raw value. /// /// - Parameter rawValue: zlib compression level constant. - public init(_ rawValue: Int32) { + init(_ rawValue: Int32) { self.rawValue = rawValue } } /// Errors on gzipping/gunzipping based on the zlib error codes. -public struct GzipError: Swift.Error, Sendable { +struct GzipError: Swift.Error, Sendable { // cf. http://www.zlib.net/manual.html /// High-level zlib error category. - public enum Kind: Equatable, Sendable { + enum Kind: Equatable, Sendable { /// The stream structure was inconsistent. /// /// - Note: Underlying zlib error: `Z_STREAM_ERROR` (-2). @@ -112,10 +112,10 @@ public struct GzipError: Swift.Error, Sendable { } /// Error kind. - public let kind: Kind + let kind: Kind /// Returned message by zlib. - public let message: String + let message: String init(code: Int32, msg: UnsafePointer?) { message = msg.flatMap(String.init(validatingUTF8:)) ?? "Unknown gzip error" @@ -123,7 +123,7 @@ public struct GzipError: Swift.Error, Sendable { } /// Human-readable zlib error message. - public var localizedDescription: String { + var localizedDescription: String { message } } diff --git a/PostHog/Utils/DateUtils.swift b/PostHog/Utils/DateUtils.swift index c59b558f13..818996a2d6 100644 --- a/PostHog/Utils/DateUtils.swift +++ b/PostHog/Utils/DateUtils.swift @@ -36,7 +36,7 @@ let apiDateFormatter = PostHogAPIDateFormatter() /// /// - Parameter date: Date to format. /// - Returns: An ISO-8601-like timestamp string with milliseconds. -public func toISO8601String(_ date: Date) -> String { +func toISO8601String(_ date: Date) -> String { apiDateFormatter.string(from: date) } @@ -44,7 +44,7 @@ public func toISO8601String(_ date: Date) -> String { /// /// - Parameter date: Timestamp string with optional milliseconds. /// - Returns: A parsed `Date`, or `nil` when the string is invalid. -public func toISO8601Date(_ date: String) -> Date? { +func toISO8601Date(_ date: String) -> Date? { apiDateFormatter.date(from: date) } diff --git a/PostHog/Utils/DictUtils.swift b/PostHog/Utils/DictUtils.swift index 61ab6834fc..66a8936320 100644 --- a/PostHog/Utils/DictUtils.swift +++ b/PostHog/Utils/DictUtils.swift @@ -45,7 +45,7 @@ func fromJSONData(_ data: Data, options: JSONSerialization.ReadingOptions = []) /// /// - Parameter dict: Dictionary to sanitize. /// - Returns: A sanitized dictionary, or `nil` when the input is `nil` or empty. -public func sanitizeDictionary(_ dict: [String: Any]?) -> [String: Any]? { +func sanitizeDictionary(_ dict: [String: Any]?) -> [String: Any]? { if dict == nil || dict!.isEmpty { return nil } diff --git a/PostHog/Utils/FileUtils.swift b/PostHog/Utils/FileUtils.swift index dcc7bb1f51..abbe3a67be 100644 --- a/PostHog/Utils/FileUtils.swift +++ b/PostHog/Utils/FileUtils.swift @@ -10,7 +10,7 @@ import Foundation /// Deletes a file if it exists, logging and ignoring any removal error. /// /// - Parameter file: File URL to delete. -public func deleteSafely(_ file: URL) { +func deleteSafely(_ file: URL) { if FileManager.default.fileExists(atPath: file.path) { do { try FileManager.default.removeItem(at: file) diff --git a/PostHog/Utils/Reachability.swift b/PostHog/Utils/Reachability.swift index 19d66b9c51..bd9daefeeb 100644 --- a/PostHog/Utils/Reachability.swift +++ b/PostHog/Utils/Reachability.swift @@ -1,93 +1,17 @@ -/* - Copyright (c) 2014, Ashley Mills - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - POSSIBILITY OF SUCH DAMAGE. - */ +// +// Reachability.swift +// PostHog +// import Foundation #if !os(watchOS) - import SystemConfiguration - - /// Errors thrown while creating or configuring reachability monitoring. - public enum ReachabilityError: Error { - /// Failed to create reachability with a socket address. - /// - /// - Parameters: - /// - sockaddr: Socket address used to create the reachability reference. - /// - errorCode: SystemConfiguration `SCError()` code. - case failedToCreateWithAddress(sockaddr, Int32) - /// Failed to create reachability with a hostname. - /// - /// - Parameters: - /// - hostname: Hostname used to create the reachability reference. - /// - errorCode: SystemConfiguration `SCError()` code. - case failedToCreateWithHostname(String, Int32) - /// Failed to install the reachability callback. - /// - /// - Parameter errorCode: SystemConfiguration `SCError()` code. - case unableToSetCallback(Int32) - /// Failed to set the reachability dispatch queue. - /// - /// - Parameter errorCode: SystemConfiguration `SCError()` code. - case unableToSetDispatchQueue(Int32) - /// Failed to read current reachability flags. - /// - /// - Parameter errorCode: SystemConfiguration `SCError()` code. - case unableToGetFlags(Int32) - } - - /// Legacy reachability notification name. - @available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") - public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") - - extension Notification.Name { - static let reachabilityChanged = Notification.Name("reachabilityChanged") - } + import Network - class Reachability { - @available(*, unavailable, renamed: "Connection") - enum NetworkStatus: CustomStringConvertible { - case notReachable, reachableViaWiFi, reachableViaWWAN - var description: String { - switch self { - case .reachableViaWWAN: return "Cellular" - case .reachableViaWiFi: return "WiFi" - case .notReachable: return "No Connection" - } - } - } - - enum Connection: CustomStringConvertible { + /// Tracks network connectivity with `NWPathMonitor`. + final class Reachability { + enum Connection { case unavailable, wifi, cellular - var description: String { - switch self { - case .cellular: return "Cellular" - case .wifi: return "WiFi" - case .unavailable: return "No Connection" - } - } } /// Multicast hooks: every subscriber gets called on every transition. @@ -96,307 +20,101 @@ import Foundation let onReachable = PostHogMulticastCallback() let onUnreachable = PostHogMulticastCallback() - /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) - var allowsCellularConnection: Bool - - // The notification center on which "reachability changed" events are being posted - var notificationCenter: NotificationCenter = .default - - @available(*, unavailable, renamed: "connection") - var currentReachabilityStatus: Connection { - connection - } - - var connection: Connection { - if flags == nil { - try? setReachabilityFlags() - } - - switch flags?.connection { - case .unavailable?, nil: return .unavailable - case .cellular?: return allowsCellularConnection ? .cellular : .unavailable - case .wifi?: return .wifi - } - } - - private(set) var notifierRunning = false - private let reachabilityRef: SCNetworkReachability - private let reachabilitySerialQueue: DispatchQueue + private let monitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "com.posthog.Reachability") private let notificationQueue: DispatchQueue? - private(set) var flags: SCNetworkReachabilityFlags? { - didSet { - guard flags != oldValue else { return } - notifyReachabilityChanged() - } - } - - required init(reachabilityRef: SCNetworkReachability, - queueQoS: DispatchQoS = .default, - targetQueue: DispatchQueue? = nil, - notificationQueue: DispatchQueue? = .main) - { - allowsCellularConnection = true - self.reachabilityRef = reachabilityRef - reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue) + private let firstPath = DispatchGroup() + private let firstPathDeadline = DispatchTime.now() + .milliseconds(100) + private let lock = NSLock() + private var latestConnection: Connection? + private var notifierRunning = false + + /// - Parameter monitorsPaths: `false` skips `NWPathMonitor` so tests can drive `update(_:)`. + init(notificationQueue: DispatchQueue? = .main, monitorsPaths: Bool = true) { self.notificationQueue = notificationQueue - } - - convenience init(hostname: String, - queueQoS: DispatchQoS = .default, - targetQueue: DispatchQueue? = nil, - notificationQueue: DispatchQueue? = .main) throws - { - guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { - throw ReachabilityError.failedToCreateWithHostname(hostname, SCError()) + firstPath.enter() + guard monitorsPaths else { return } + monitor.pathUpdateHandler = { [weak self] path in + self?.update(Self.connection(for: path)) } - self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) - } - - convenience init(queueQoS: DispatchQoS = .default, - targetQueue: DispatchQueue? = nil, - notificationQueue: DispatchQueue? = .main) throws - { - var zeroAddress = sockaddr() - zeroAddress.sa_len = UInt8(MemoryLayout.size) - zeroAddress.sa_family = sa_family_t(AF_INET) - - guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { - throw ReachabilityError.failedToCreateWithAddress(zeroAddress, SCError()) - } - - self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + monitor.start(queue: monitorQueue) } deinit { - stopNotifier() + monitor.cancel() } - } - extension Reachability { - // MARK: - *** Notifier methods *** - - func startNotifier() throws { - guard !notifierRunning else { return } - - let callback: SCNetworkReachabilityCallBack = { _, flags, info in - guard let info = info else { return } - - // `weakifiedReachability` is guaranteed to exist by virtue of our - // retain/release callbacks which we provided to the `SCNetworkReachabilityContext`. - let weakifiedReachability = Unmanaged.fromOpaque(info).takeUnretainedValue() - - // The weak `reachability` _may_ no longer exist if the `Reachability` - // object has since been deallocated but a callback was already in flight. - weakifiedReachability.reachability?.flags = flags + /// The current connection, or `nil` if the monitor hasn't reported a path yet. + /// + /// `NWPathMonitor` delivers its first path asynchronously right after it starts, + /// so reads wait for it, up to 100 ms after init in total. Events captured during + /// SDK setup would otherwise miss their network properties. + var connection: Connection? { + if let connection = lock.withLock({ latestConnection }) { + return connection } + _ = firstPath.wait(timeout: firstPathDeadline) + return lock.withLock { latestConnection } + } - let weakifiedReachability = ReachabilityWeakifier(reachability: self) - let opaqueWeakifiedReachability = Unmanaged.passUnretained(weakifiedReachability).toOpaque() - - var context = SCNetworkReachabilityContext( - version: 0, - info: UnsafeMutableRawPointer(opaqueWeakifiedReachability), - retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in - let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) - _ = unmanagedWeakifiedReachability.retain() - return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque()) - }, - release: { (info: UnsafeRawPointer) in - let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) - unmanagedWeakifiedReachability.release() - }, - copyDescription: { (info: UnsafeRawPointer) -> Unmanaged in - let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) - let weakifiedReachability = unmanagedWeakifiedReachability.takeUnretainedValue() - let description = weakifiedReachability.reachability?.description ?? "nil" - return Unmanaged.passRetained(description as CFString) - } - ) - - if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { - stopNotifier() - throw ReachabilityError.unableToSetCallback(SCError()) + /// Starts calling `onReachable` / `onUnreachable` on connectivity changes. + /// + /// Like the old `SCNetworkReachability` initial check, the first start also reports + /// the current connection, so subscribers flush anything queued while the app was closed. + func startNotifier() { + let (started, hasPath) = lock.withLock { () -> (Bool, Bool) in + defer { notifierRunning = true } + return (!notifierRunning, latestConnection != nil) } - - if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { - stopNotifier() - throw ReachabilityError.unableToSetDispatchQueue(SCError()) + // If no path has arrived yet, `update(_:)` reports the first one. + if started, hasPath { + notify() } - - // Perform an initial check - try setReachabilityFlags() - - notifierRunning = true } func stopNotifier() { - defer { notifierRunning = false } - - SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) - SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) + lock.withLock { notifierRunning = false } } - var description: String { - flags?.description ?? "unavailable flags" + /// Wi-Fi means "reachable and not cellular", so wired connections count as Wi-Fi. + /// `.requiresConnection` (e.g. VPN on demand) counts as reachable, since sending a + /// request brings the connection up. + private static func connection(for path: NWPath) -> Connection { + guard path.status != .unsatisfied else { return .unavailable } + return path.usesInterfaceType(.cellular) ? .cellular : .wifi } - } - private extension Reachability { - func setReachabilityFlags() throws { - try reachabilitySerialQueue.sync { [unowned self] in - var flags = SCNetworkReachabilityFlags() - if !SCNetworkReachabilityGetFlags(reachabilityRef, &flags) { - stopNotifier() - throw ReachabilityError.unableToGetFlags(SCError()) - } - - self.flags = flags + func update(_ connection: Connection) { + // Notify on every path update, like the old `SCNetworkReachability` callback did on + // every flags change. Paths also update for changes that keep the same connection + // (e.g. DNS), which only costs a no-op flush. + let (isFirstPath, shouldNotify) = lock.withLock { () -> (Bool, Bool) in + let isFirstPath = latestConnection == nil + latestConnection = connection + return (isFirstPath, notifierRunning) + } + if isFirstPath { + firstPath.leave() + } + if shouldNotify { + notify() } } - func notifyReachabilityChanged() { + /// Reads the connection at delivery, not when queued, so a notification queued + /// before a newer path can't deliver a stale state after it. + private func notify() { let notify = { [weak self] in - guard let self = self else { return } - if self.connection != .unavailable { + guard let self else { return } + if self.lock.withLock({ self.latestConnection }) != .unavailable { self.onReachable.invoke(self) } else { self.onUnreachable.invoke(self) } - self.notificationCenter.post(name: .reachabilityChanged, object: self) } - // notify on the configured `notificationQueue`, or the caller's (i.e. `reachabilitySerialQueue`) + // notify on the configured `notificationQueue`, or the monitor's queue notificationQueue?.async(execute: notify) ?? notify() } } - - extension SCNetworkReachabilityFlags { - typealias Connection = Reachability.Connection - - var connection: Connection { - guard isReachableFlagSet else { return .unavailable } - - // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi - #if targetEnvironment(simulator) - return .wifi - #else - var connection = Connection.unavailable - - if !isConnectionRequiredFlagSet { - connection = .wifi - } - - if isConnectionOnTrafficOrDemandFlagSet { - if !isInterventionRequiredFlagSet { - connection = .wifi - } - } - - if isOnWWANFlagSet { - connection = .cellular - } - - return connection - #endif - } - - var isOnWWANFlagSet: Bool { - #if os(iOS) || os(visionOS) - return contains(.isWWAN) - #else - return false - #endif - } - - var isReachableFlagSet: Bool { - contains(.reachable) - } - - var isConnectionRequiredFlagSet: Bool { - contains(.connectionRequired) - } - - var isInterventionRequiredFlagSet: Bool { - contains(.interventionRequired) - } - - var isConnectionOnTrafficFlagSet: Bool { - contains(.connectionOnTraffic) - } - - var isConnectionOnDemandFlagSet: Bool { - contains(.connectionOnDemand) - } - - var isConnectionOnTrafficOrDemandFlagSet: Bool { - !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty - } - - var isTransientConnectionFlagSet: Bool { - contains(.transientConnection) - } - - var isLocalAddressFlagSet: Bool { - contains(.isLocalAddress) - } - - var isDirectFlagSet: Bool { - contains(.isDirect) - } - - var description: String { - let W = isOnWWANFlagSet ? "W" : "-" - let R = isReachableFlagSet ? "R" : "-" - let c = isConnectionRequiredFlagSet ? "c" : "-" - let t = isTransientConnectionFlagSet ? "t" : "-" - let i = isInterventionRequiredFlagSet ? "i" : "-" - let C = isConnectionOnTrafficFlagSet ? "C" : "-" - let D = isConnectionOnDemandFlagSet ? "D" : "-" - let l = isLocalAddressFlagSet ? "l" : "-" - let d = isDirectFlagSet ? "d" : "-" - - return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" - } - } - - /** - `ReachabilityWeakifier` weakly wraps the `Reachability` class - in order to break retain cycles when interacting with CoreFoundation. - - CoreFoundation callbacks expect a pair of retain/release whenever an - opaque `info` parameter is provided. These callbacks exist to guard - against memory management race conditions when invoking the callbacks. - - #### Race Condition - - If we passed `SCNetworkReachabilitySetCallback` a direct reference to our - `Reachability` class without also providing corresponding retain/release - callbacks, then a race condition can lead to crashes when: - - `Reachability` is deallocated on thread X - - A `SCNetworkReachability` callback(s) is already in flight on thread Y - - #### Retain Cycle - - If we pass `Reachability` to CoreFoundtion while also providing retain/ - release callbacks, we would create a retain cycle once CoreFoundation - retains our `Reachability` class. This fixes the crashes and his how - CoreFoundation expects the API to be used, but doesn't play nicely with - Swift/ARC. This cycle would only be broken after manually calling - `stopNotifier()` — `deinit` would never be called. - - #### ReachabilityWeakifier - - By providing both retain/release callbacks and wrapping `Reachability` in - a weak wrapper, we: - - interact correctly with CoreFoundation, thereby avoiding a crash. - See "Memory Management Programming Guide for Core Foundation". - - don't alter the public API of `Reachability.swift` in any way - - still allow for automatic stopping of the notifier on `deinit`. - */ - private class ReachabilityWeakifier { - weak var reachability: Reachability? - init(reachability: Reachability) { - self.reachability = reachability - } - } #endif diff --git a/PostHog/Utils/UIApplication+.swift b/PostHog/Utils/UIApplication+.swift index afe9b2bc00..a6df53a17e 100644 --- a/PostHog/Utils/UIApplication+.swift +++ b/PostHog/Utils/UIApplication+.swift @@ -19,15 +19,8 @@ for scene in windowScenes { // attempt to retrieve directly from UIWindowScene - if #available(iOS 15.0, tvOS 15.0, *) { - if let keyWindow = scene.keyWindow { - return keyWindow - } - } else { - // check scene.windows.isKeyWindow - for window in scene.windows where window.isKeyWindow { - return window - } + if let keyWindow = scene.keyWindow { + return keyWindow } // check scene.delegate.window property diff --git a/PostHog/Utils/UIImage+WebP.swift b/PostHog/Utils/UIImage+WebP.swift index b0c3669320..3fa2ceeea2 100644 --- a/PostHog/Utils/UIImage+WebP.swift +++ b/PostHog/Utils/UIImage+WebP.swift @@ -6,16 +6,11 @@ // // 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 - #if compiler(>=6.0) - internal import phlibwebp - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import phlibwebp - #endif + internal import phlibwebp import UIKit extension UIImage { diff --git a/PostHogExample/AppDelegate.swift b/PostHogExample/AppDelegate.swift index 5fb36adbb4..c6d7ca0529 100644 --- a/PostHogExample/AppDelegate.swift +++ b/PostHogExample/AppDelegate.swift @@ -31,13 +31,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { #endif #if os(iOS) - if #available(iOS 15.0, *) { - config.surveys = true - // Uncomment to force-render any matching survey in a specific language regardless of device locale. - // config.surveysConfig.overrideDisplayLanguage = "fr" - } + config.surveys = true + // Uncomment to force-render any matching survey in a specific language regardless of device locale. + // config.surveysConfig.overrideDisplayLanguage = "fr" config.sessionReplay = false - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllTextInputs = true config.sessionReplayConfig.maskAllImages = true config.sessionReplayConfig.captureLogs = true @@ -46,7 +43,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Skip some logs guard !log.contains("[SKIP]") else { return nil } // all logs are lowercased and info level - return PostHogLogEntry(level: .info, message: log.lowercased()) + return PostHogConsoleLogEntry(level: .info, message: log.lowercased()) } #endif diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 9229609c3f..9a5fc9cbe1 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -36,6 +36,7 @@ class SignInViewModel: NSObject, ObservableObject, ASWebAuthenticationPresentati } } +@MainActor class FeatureFlagsModel: ObservableObject { @Published var boolValue: Bool? @Published var stringValue: String? @@ -55,8 +56,11 @@ class FeatureFlagsModel: ObservableObject { func reload() { isReloading = true - PostHogSDK.shared.reloadFeatureFlags { - self.isReloading = false + PostHogSDK.shared.reloadFeatureFlags { _ in + // The callback can run on a background thread; @Published changes belong on main. + Task { @MainActor in + self.isReloading = false + } } } } diff --git a/PostHogExample/SessionRecordingView.swift b/PostHogExample/SessionRecordingView.swift index ba7e5b8dff..fdfb641d75 100644 --- a/PostHogExample/SessionRecordingView.swift +++ b/PostHogExample/SessionRecordingView.swift @@ -52,8 +52,9 @@ struct SessionRecordingView: View { Section("Event Trigger") { Button { PostHogSDK.shared.stopSessionRecording() + PostHogSDK.shared.endSession() + PostHogSDK.shared.startSession() PostHogSDK.shared.startSessionRecording() - PostHogSessionManager.shared.setSessionId(UUID().uuidString) refreshStatus() } label: { Text("Restart & Rotate Session Id") diff --git a/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj b/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj index 231f1a365d..b08982f432 100644 --- a/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj +++ b/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj @@ -929,7 +929,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_VERSION = ""; @@ -978,7 +978,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; SDKROOT = iphoneos; SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; @@ -993,7 +993,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/PostHogExampleAutocapture/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; MARKETING_VERSION = 17; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExampleAutocapture; @@ -1014,7 +1014,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/PostHogExampleAutocapture/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; MARKETING_VERSION = 17; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExampleAutocapture; diff --git a/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift b/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift index 32e237279e..c83c6b968c 100755 --- a/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift +++ b/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift @@ -22,7 +22,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { config.sessionReplay = true config.captureScreenViews = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllTextInputs = false config.sessionReplayConfig.maskAllImages = false diff --git a/PostHogExampleStoryboard/AppDelegate.swift b/PostHogExampleStoryboard/AppDelegate.swift index b75a0189b1..52f663bc86 100644 --- a/PostHogExampleStoryboard/AppDelegate.swift +++ b/PostHogExampleStoryboard/AppDelegate.swift @@ -24,7 +24,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { config.sendFeatureFlagEvent = false config.sessionReplay = true config.sessionReplayConfig.maskAllTextInputs = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllImages = true config.sessionReplayConfig.captureNetworkTelemetry = true diff --git a/PostHogObjCExample/AppDelegate.m b/PostHogObjCExample/AppDelegate.m index 0f680a993f..3df93236df 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -30,10 +30,10 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( PostHogConfig *config = [[PostHogConfig alloc] projectToken:@"_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"]; config.preloadFeatureFlags = YES; config.sessionReplayConfig.captureLogs = YES; - config.sessionReplayConfig.captureLogsConfig.minLogLevel = PostHogLogLevelWarn; - config.sessionReplayConfig.captureLogsConfig.logSanitizer = ^PostHogLogEntry * _Nullable(NSString * _Nonnull text) { + config.sessionReplayConfig.captureLogsConfig.minLogLevel = PostHogConsoleLogLevelWarn; + config.sessionReplayConfig.captureLogsConfig.logSanitizer = ^PostHogConsoleLogEntry * _Nullable(NSString * _Nonnull text) { // Simple implementation: just return the text as-is with info level - return [[PostHogLogEntry alloc] initWithLevel:PostHogLogLevelInfo message:text]; + return [[PostHogConsoleLogEntry alloc] initWithLevel:PostHogConsoleLogLevelInfo message:text]; }; @@ -94,7 +94,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( NSLog(@"getFeatureFlagResult payload: %@", [[PostHogSDK shared] getFeatureFlagResult:@"myFlag"].payload); [[PostHogSDK shared] reloadFeatureFlags]; - [[PostHogSDK shared] reloadFeatureFlagsWithCallback:^(){ + [[PostHogSDK shared] reloadFeatureFlagsWithCallback:^(PostHogFeatureFlagsLoaded *result){ NSLog(@"called"); }]; diff --git a/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift new file mode 100644 index 0000000000..40aa2549f5 --- /dev/null +++ b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift @@ -0,0 +1,108 @@ +// +// PostHogCallbackIsolationTests.swift +// PostHogSwift6Tests +// + +import Foundation +import PostHog +import Testing + +/// Calls the public callback APIs from main-actor code, as a Swift 6 app would. +/// +/// 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. +#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? + var loaded: PostHogFeatureFlagsLoaded? + var deliveredOnMain = false + } + + /// Nothing listens on this port, so requests fail fast without leaving the machine. + private func makeSDK(bootstrapFlags: [String: Any]? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_swift6_\(UUID().uuidString)", host: "http://127.0.0.1:9") + config.captureApplicationLifecycleEvents = false + config.preloadFeatureFlags = false + if let bootstrapFlags { + let bootstrap = PostHogBootstrapConfig() + bootstrap.featureFlags = bootstrapFlags + config.bootstrap = bootstrap + } + return PostHogSDK.with(config) + } + + @Test("reloadFeatureFlags callback written in main-actor code runs without an isolation trap") + func reloadCallbackFromMainActorCode() async { + let sdk = makeSDK() + defer { sdk.close() } + let state = AppState() + + // The SDK invokes this from its network completion, off the main thread. + await withCheckedContinuation { continuation in + sdk.reloadFeatureFlags { result in + // Sending the result into the hop requires it to be Sendable. + Task { @MainActor in + state.reloadResult = result + continuation.resume() + } + } + } + + #expect(state.reloadResult != nil) + } + + @Test("onFeatureFlags callback written in main-actor code runs on main with a Sendable payload") + func onFeatureFlagsDeliversOnMainActor() async { + let sdk = makeSDK(bootstrapFlags: ["swift6-flag": true]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + // Touches main-actor state directly, with no hop. + guard state.loaded == nil else { return } + state.loaded = loaded + state.deliveredOnMain = Thread.isMainThread + continuation.resume() + } + } + subscription?.unsubscribe() + + #expect(state.deliveredOnMain) + // Sending the payload to another task requires it to be Sendable. + let loaded = state.loaded + let flags = await Task.detached { loaded?.flags ?? [] }.value + #expect(flags == ["swift6-flag"]) + } + + @Test("onFeatureFlags payload keeps its variant values when a mutable bootstrap value changes") + func onFeatureFlagsPayloadIsImmutable() async { + let source = NSMutableString(string: "original") + let sdk = makeSDK(bootstrapFlags: ["swift6-variant": source]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + guard state.loaded == nil else { return } + state.loaded = loaded + continuation.resume() + } + } + subscription?.unsubscribe() + + source.setString("changed") + let loaded = state.loaded + let variant = await Task.detached { loaded?.variants["swift6-variant"] as? String }.value + #expect(variant == "original") + } +} diff --git a/PostHogTests/ExampleSanitizer.swift b/PostHogTests/ExampleSanitizer.swift deleted file mode 100644 index 92714ebefc..0000000000 --- a/PostHogTests/ExampleSanitizer.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// ExampleSanitizer.swift -// PostHogTests -// -// Created by Manoel Aranda Neto on 06.08.24. -// - -import Foundation -import PostHog - -class ExampleSanitizer: PostHogPropertiesSanitizer { - func sanitize(_ properties: [String: Any]) -> [String: Any] { - var sanitizedProperties = properties - // Perform sanitization - // For example, removing keys with empty values - for (key, value) in properties { - if let stringValue = value as? String, stringValue.isEmpty { - sanitizedProperties.removeValue(forKey: key) - } - } - return sanitizedProperties - } -} diff --git a/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift b/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift index 11b468bbe2..93a3cf6b0c 100644 --- a/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift +++ b/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift @@ -208,7 +208,8 @@ final class PostHogAppLifeCycleIntegrationTest { print("running tests") #expect(events.first?.properties["$app_version"] != nil) #expect(events.first?.properties["$app_build"] != nil) - #expect(events.first?.properties["build"] != nil) + #expect(events.first?.properties["version"] == nil) + #expect(events.first?.properties["build"] == nil) #expect(events.first?.properties["$app_build_xcode"] as? String == Bundle.main.infoDictionary?["DTXcode"] as? String) #expect(UserDefaults.standard.string(forKey: "PHGVersionKey") != nil) @@ -243,7 +244,8 @@ final class PostHogAppLifeCycleIntegrationTest { print("running tests") #expect(events.first?.properties["$app_version"] != nil) #expect(events.first?.properties["$app_build"] != nil) - #expect(events.first?.properties["build"] != nil) + #expect(events.first?.properties["version"] == nil) + #expect(events.first?.properties["build"] == nil) #expect(events.first?.properties["previous_build"] != nil) #expect(events.first?.properties["$app_build_xcode"] as? String == Bundle.main.infoDictionary?["DTXcode"] as? String) @@ -279,7 +281,8 @@ final class PostHogAppLifeCycleIntegrationTest { print("running tests") #expect(events.first?.properties["$app_version"] != nil) #expect(events.first?.properties["$app_build"] != nil) - #expect(events.first?.properties["build"] != nil) + #expect(events.first?.properties["version"] == nil) + #expect(events.first?.properties["build"] == nil) #expect(UserDefaults.standard.string(forKey: "PHGVersionKey") != nil) #expect(UserDefaults.standard.string(forKey: "PHGBuildKeyV2") != nil) @@ -351,8 +354,9 @@ final class PostHogAppLifeCycleIntegrationTest { #expect(events[1].properties["from_background"] as? Bool == false) #if targetEnvironment(simulator) - #expect(events[1].properties["version"] != nil) - #expect(events[1].properties["build"] != nil) + #expect(events[1].properties["version"] == nil) + #expect(events[1].properties["build"] == nil) + #expect(events[1].properties["$app_version"] != nil) #expect(events[1].properties["$app_build"] != nil) #endif @@ -430,9 +434,10 @@ final class PostHogAppLifeCycleIntegrationTest { #expect(events[3].properties["from_background"] as? Bool == true) #if targetEnvironment(simulator) - #expect(events[1].properties["version"] != nil) - #expect(events[1].properties["build"] != nil) - #expect(events[1].properties["$app_build"] != nil) + #expect(events[3].properties["version"] == nil) + #expect(events[3].properties["build"] == nil) + #expect(events[3].properties["$app_version"] != nil) + #expect(events[3].properties["$app_build"] != nil) #endif sut.close() diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index d856efd046..f0bf9363dc 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -41,37 +41,6 @@ struct PostHogConfigTest { let config = PostHogConfig(projectToken: testProjectToken) #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - } - - @Test("deprecated init(apiKey:) maps to project token") - func deprecatedInitApiKeyMapsToProjectToken() { - let config = PostHogConfig(apiKey: testProjectToken) - - #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - #expect(config.host == URL(string: PostHogConfig.defaultHost)) - } - - @Test("deprecated init(apiKey:host:) maps to project token and host") - func deprecatedInitApiKeyHostMapsToProjectTokenAndHost() throws { - let config = PostHogConfig(apiKey: testProjectToken, host: "localhost:9000") - - #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - #expect(config.host == (try #require(URL(string: "localhost:9000")))) - } - - @Test("deprecated init(apiKey:host:) trims whitespace-sensitive values") - func deprecatedInitApiKeyHostTrimsWhitespace() { - let config = PostHogConfig( - apiKey: " \n\(testProjectToken)\t ", - host: " \nhttps://eu.i.posthog.com/\t " - ) - - #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - #expect(config.host == URL(string: "https://eu.i.posthog.com/")) } @Test("trims whitespace-sensitive config values") @@ -82,7 +51,6 @@ struct PostHogConfigTest { ) #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) #expect(config.host == URL(string: "https://eu.i.posthog.com/")) } diff --git a/PostHogTests/PostHogConsoleLogInterceptorTest.swift b/PostHogTests/PostHogConsoleLogInterceptorTest.swift index d6cb046c7d..d20579ef0d 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 @@ -13,7 +13,7 @@ @Suite("Console Log Interceptor Tests", .serialized) class PostHogConsoleLogInterceptorTest { private func getConfig() -> PostHogConfig { - PostHogConfig(apiKey: "test-api-key") + PostHogConfig(projectToken: "test-api-key") } /// A broken pipe on a descriptor the SDK owns has to surface as `EPIPE`, not as a diff --git a/PostHogTests/PostHogContextTest.swift b/PostHogTests/PostHogContextTest.swift index 38ba5abfb9..fd9cd53eee 100644 --- a/PostHogTests/PostHogContextTest.swift +++ b/PostHogTests/PostHogContextTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("PostHogContext", .serialized, .resetsGlobalState) @@ -18,13 +18,7 @@ struct PostHogContextTest { private func getSut() -> PostHogContext { #if !os(watchOS) - var reachability: Reachability? - do { - reachability = try Reachability() - } catch { - // ignored - } - return PostHogContext(reachability) + return PostHogContext(Reachability(notificationQueue: nil, monitorsPaths: false)) #else return PostHogContext() #endif @@ -62,10 +56,36 @@ struct PostHogContextTest { #expect(context["$locale"] as? String != nil) #expect(context["$timezone"] as? String != nil) - #expect(context["$network_wifi"] as? Bool != nil) - #expect(context["$network_cellular"] as? Bool != nil) } + #if !os(watchOS) + @Test("omits network properties until a path arrives") + func omitsNetworkPropertiesWhileConnectionUnknown() { + let sut = PostHogContext(Reachability(notificationQueue: nil, monitorsPaths: false)) + + let context = sut.dynamicContext() + + #expect(context["$network_wifi"] == nil) + #expect(context["$network_cellular"] == nil) + } + + @Test("reports network properties from the current connection") + func reportsNetworkPropertiesFromConnection() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + let sut = PostHogContext(reachability) + + reachability.update(.wifi) + var context = sut.dynamicContext() + #expect(context["$network_wifi"] as? Bool == true) + #expect(context["$network_cellular"] as? Bool == false) + + reachability.update(.cellular) + context = sut.dynamicContext() + #expect(context["$network_wifi"] as? Bool == false) + #expect(context["$network_cellular"] as? Bool == true) + } + #endif + @Test("returns sdk info") func returnsSdkInfo() { let sut = getSut() diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift index acfabeac15..8fae4fdc6b 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/PostHogDeviceBucketingTests.swift b/PostHogTests/PostHogDeviceBucketingTests.swift index 4ec8648353..65aadbff56 100644 --- a/PostHogTests/PostHogDeviceBucketingTests.swift +++ b/PostHogTests/PostHogDeviceBucketingTests.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -28,7 +28,6 @@ class PostHogDeviceBucketingTests { config.maxBatchSize = flushAt config.disableFlushOnBackgroundForTesting = true config.disableQueueTimerForTesting = true - config.remoteConfig = false config.preloadFeatureFlags = false let sut = PostHogSDK.with(config) cleanupJobs.append { @@ -103,7 +102,7 @@ class PostHogDeviceBucketingTests { let deviceId = sut.getDeviceId() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -121,7 +120,7 @@ class PostHogDeviceBucketingTests { sut.identify("user-123") await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } diff --git a/PostHogTests/PostHogEventSnapshotTests.swift b/PostHogTests/PostHogEventSnapshotTests.swift index b9d55b5ee9..b59d7069a7 100644 --- a/PostHogTests/PostHogEventSnapshotTests.swift +++ b/PostHogTests/PostHogEventSnapshotTests.swift @@ -1,5 +1,5 @@ import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -118,7 +118,7 @@ final class PostHogEventSnapshotTests { ) let loaded = AsyncLatch() - sut.reloadFeatureFlags { loaded.signal() } + sut.reloadFeatureFlags { _ in loaded.signal() } await loaded.wait() let request = try #require(server.flagsRequests.first) @@ -273,8 +273,7 @@ final class PostHogEventSnapshotTests { // destination (xcodebuild), absent under SwiftPM's macOS command-line test bundle. // Validate then omit so the golden doesn't depend on which host compiled the test. if let captureMode = properties["$sdk_debug_replay_capture_mode"] { - let mode = try #require(captureMode as? String) - #expect(mode == "wireframe" || mode == "screenshot") + #expect(captureMode as? String == "screenshot") properties.removeValue(forKey: "$sdk_debug_replay_capture_mode") } if let appBuild = properties["$app_build"] { diff --git a/PostHogTests/PostHogEventTest.swift b/PostHogTests/PostHogEventTest.swift new file mode 100644 index 0000000000..18ef37a43b --- /dev/null +++ b/PostHogTests/PostHogEventTest.swift @@ -0,0 +1,38 @@ +import Foundation +@testable import PostHog +import Testing + +@Suite("PostHogEvent JSON decoding") +struct PostHogEventTest { + @Test("ignores the 2.x top-level $set and message_id") + func ignoresV2Fields() throws { + let messageId = "5CE069F8-E967-4B47-9D89-207EF7519453" + let json: [String: Any] = [ + "event": "test", + "distinct_id": "user", + "properties": ["key": "value"], + "$set": ["plan": "pro"], + "message_id": messageId, + ] + + let event = try #require(PostHogEvent.fromJSON(json)) + + #expect(event.properties["$set"] == nil) + #expect(event.properties["key"] as? String == "value") + #expect(event.uuid.uuidString != messageId) + } + + @Test("reads uuid") + func readsUuid() throws { + let uuid = "019A0000-0000-7000-8000-000000000001" + let json: [String: Any] = [ + "event": "test", + "distinct_id": "user", + "uuid": uuid, + ] + + let event = try #require(PostHogEvent.fromJSON(json)) + + #expect(event.uuid.uuidString == uuid) + } +} diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index 89954bd5f8..2139544b7f 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -145,6 +145,38 @@ enum PostHogFeatureFlagsTest { #expect(sut.getFeatureFlagPayload("payload-json") as? [String: String] == ["foo": "bar"]) } + + private func sutWithCachedPayloads(_ payloads: [String: Any]) -> PostHogRemoteConfig { + let storage = PostHogStorage(config) + storage.setDictionary(forKey: .enabledFeatureFlags, contents: ["checkout": "blue", "beta-ui": true]) + storage.setDictionary(forKey: .enabledFeatureFlagPayloads, contents: payloads) + return getSut(storage: storage) + } + + @Test("malformed payload is nil, keeping the flag and sibling payloads", arguments: ["{broken", "", " "]) + func malformedPayloadIsNil(input: String) throws { + let sut = sutWithCachedPayloads(["checkout": input, "beta-ui": "{\"color\":\"green\"}"]) + + #expect(sut.getFeatureFlagPayload("checkout") == nil) + let result = try #require(sut.getFeatureFlagResult("checkout")) + #expect(result.enabled && result.variant == "blue") + #expect(result.payload == nil) + + let all = Dictionary(uniqueKeysWithValues: (sut.getAllFeatureFlagResults() ?? []).map { ($0.key, $0) }) + #expect(all["checkout"]?.variant == "blue") + #expect(all["checkout"]?.payload == nil) + #expect(all["beta-ui"]?.payload as? [String: String] == ["color": "green"]) + } + + @Test("valid JSON payloads keep their decoded value") + func validPayloadsKeepValue() { + let sut = sutWithCachedPayloads(["string": "\"hello\"", "empty": "\"\"", "false": "false", "null": "null"]) + + #expect(sut.getFeatureFlagPayload("string") as? String == "hello") + #expect(sut.getFeatureFlagPayload("empty") as? String == "") + #expect(sut.getFeatureFlagPayload("false") as? Bool == false) + #expect(sut.getFeatureFlagPayload("null") is NSNull) + } } @Suite("Test feature flags loading") @@ -309,7 +341,7 @@ enum PostHogFeatureFlagsTest { // Verify they can be retrieved by testing the internal state // Since getPersonPropertiesForFlags is private, we'll test via flag loading await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -348,7 +380,7 @@ enum PostHogFeatureFlagsTest { sut.setPersonPropertiesForFlags(["property2": "value2", "shared": "updated"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -386,7 +418,7 @@ enum PostHogFeatureFlagsTest { sut.resetPersonPropertiesForFlags() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -423,7 +455,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("organization", properties: properties) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -464,7 +496,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("team", properties: ["role": "engineering"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -502,7 +534,7 @@ enum PostHogFeatureFlagsTest { sut.resetGroupPropertiesForFlags("organization") await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -540,7 +572,7 @@ enum PostHogFeatureFlagsTest { sut.resetGroupPropertiesForFlags() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -569,7 +601,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("organization", properties: ["org_plan": "enterprise"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -614,7 +646,7 @@ enum PostHogFeatureFlagsTest { sut.capture("test_event", properties: ["event_prop": "value"], userProperties: ["user_plan": "premium"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -651,7 +683,7 @@ enum PostHogFeatureFlagsTest { sut.group(type: "organization", key: "org123", groupProperties: ["org_plan": "enterprise"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -685,7 +717,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -703,7 +735,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -721,7 +753,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -739,7 +771,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -754,7 +786,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -777,7 +809,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -804,7 +836,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -831,7 +863,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -865,7 +897,7 @@ enum PostHogFeatureFlagsTest { func returnsAllFlagsIncludingDisabled() async { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let all = sut.getAllFeatureFlags() @@ -894,7 +926,7 @@ enum PostHogFeatureFlagsTest { func decodesPayloads() async { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let byKey = Dictionary(uniqueKeysWithValues: (sut.getAllFeatureFlags() ?? []).map { ($0.key, $0) }) @@ -911,7 +943,7 @@ enum PostHogFeatureFlagsTest { func matchesSingleKeyResult() async throws { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let flags = try #require(sut.getAllFeatureFlags()) @@ -932,7 +964,7 @@ enum PostHogFeatureFlagsTest { config.flushAt = 1 let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } server.reset(batchCount: 1) @@ -1033,7 +1065,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1073,7 +1105,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1104,7 +1136,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1134,7 +1166,7 @@ enum PostHogFeatureFlagsTest { // Initially no evaluation contexts await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1153,7 +1185,7 @@ enum PostHogFeatureFlagsTest { // Reload flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1174,55 +1206,6 @@ enum PostHogFeatureFlagsTest { #expect(evaluationContexts.contains("staging"), "Expected 'staging' in evaluation contexts") #expect(evaluationContexts.contains("mobile"), "Expected 'mobile' in evaluation contexts") } - - @Test("Deprecated evaluationEnvironments property still works") - func deprecatedEvaluationEnvironmentsStillWorks() async { - // Use the deprecated property - config.evaluationEnvironments = ["production", "api"] - let sut = track(PostHogSDK.with(config)) - - // Verify the deprecated property maps to evaluationContexts - #expect(config.evaluationContexts?.count == 2, "Expected evaluationContexts to be set via deprecated property") - #expect(config.evaluationContexts?.contains("production") == true, "Expected 'production' in evaluationContexts") - #expect(config.evaluationContexts?.contains("api") == true, "Expected 'api' in evaluationContexts") - - // Verify reading from deprecated property returns same values - #expect(config.evaluationEnvironments?.count == 2, "Expected evaluationEnvironments getter to return same values") - #expect(config.evaluationEnvironments?.contains("production") == true, "Expected 'production' from evaluationEnvironments getter") - #expect(config.evaluationEnvironments?.contains("api") == true, "Expected 'api' from evaluationEnvironments getter") - - // Enable person processing - sut.identify("test_user") - - // Load feature flags - await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { - continuation.resume() - } - } - - // Verify the request included evaluation contexts (set via deprecated property) - #expect(server.flagsRequests.count > 0, "Expected at least one flags request to be made") - - guard let lastRequest = server.flagsRequests.last else { - #expect(Bool(false), "No flags request found") - return - } - - guard let requestBody = server.parseRequest(lastRequest, gzip: false) else { - #expect(Bool(false), "Failed to parse request body") - return - } - - guard let evaluationContexts = requestBody["evaluation_contexts"] as? [String] else { - #expect(Bool(false), "Evaluation contexts not found in request body: \(requestBody)") - return - } - - #expect(evaluationContexts.count == 2, "Expected 2 evaluation contexts") - #expect(evaluationContexts.contains("production"), "Expected 'production' in evaluation contexts") - #expect(evaluationContexts.contains("api"), "Expected 'api' in evaluation contexts") - } } @Suite("Test Bootstrap Feature Flags") @@ -1285,6 +1268,27 @@ enum PostHogFeatureFlagsTest { #expect(sut.getFeatureFlagPayload("beta-ui") as? [String: String] == ["color": "blue"]) } + @Test("Bootstrapped string payloads are not JSON-decoded again", arguments: ["hello", "123", "true"]) + func bootstrappedStringPayloadNotDecodedAgain(payload: String) { + let config = bootstrapConfig(featureFlags: ["beta-ui": "variant-a"], featureFlagPayloads: ["beta-ui": payload]) + let sut = getSut(storage: freshStorage(config), config: config) + + #expect(sut.getFeatureFlagResult("beta-ui")?.payload as? String == payload) + } + + @Test("Bootstrapped string payloads survive a restart without bootstrap", arguments: ["hello", "123", "true"]) + func bootstrappedStringPayloadSurvivesRestart(payload: String) { + let config = bootstrapConfig(featureFlags: ["beta-ui": "variant-a"], featureFlagPayloads: ["beta-ui": payload]) + let storage = freshStorage(config) + _ = getSut(storage: storage, config: config) + + // Next launch: same storage, no bootstrap, and no /flags response yet. + let restartConfig = bootstrapConfig(featureFlags: [:]) + let sut = getSut(storage: PostHogStorage(restartConfig), config: restartConfig) + + #expect(sut.getFeatureFlagResult("beta-ui")?.payload as? String == payload) + } + @Test("Loaded flags override bootstrapped values") func loadedFlagsOverrideBootstrap() async { let config = bootstrapConfig(featureFlags: ["bool-value": "bootstrapped-variant"]) @@ -1451,7 +1455,7 @@ enum PostHogFeatureFlagsTest { // Receive a /flags response first await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1474,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") @@ -1523,7 +1527,7 @@ enum PostHogFeatureFlagsTest { let flags = await withCheckedContinuation { (continuation: CheckedContinuation<[String: Any]?, Never>) in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: nil, groups: [:], callback: { flags in - continuation.resume(returning: flags) + continuation.resume(returning: flags.featureFlags) }) // displaces the reload above out of the pending slot @@ -1559,7 +1563,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"]) sut.identify("test_user") - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1574,7 +1578,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true) @@ -1587,7 +1591,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.reloadFeatureFlags() - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) } @@ -1604,7 +1608,7 @@ enum PostHogFeatureFlagsTest { stubFlagsRequiringOverride(delay: 0) await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true) @@ -1612,7 +1616,7 @@ enum PostHogFeatureFlagsTest { HTTPStubsResponse(jsonObject: ["error": "nope"], statusCode: 400, headers: nil) } await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true, "a failed reload must not clear cached flags") @@ -1637,7 +1641,7 @@ enum PostHogFeatureFlagsTest { /// Waits for the main-queue delivery that follows a completed reload. private func reload(_ sut: PostHogSDK) async { await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } await MainActor.run {} } @@ -1701,6 +1705,70 @@ enum PostHogFeatureFlagsTest { #expect(received.last?.variants["string-value"] as? String == "test") } + @Test("reloadFeatureFlags passes the loaded flags to its callback") + func reloadCallbackResult() async { + let sut = track(PostHogSDK.with(config)) + + let result = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: $0) } + } + + #expect(result.errorsLoading == false) + #expect(result.variants["string-value"] as? String == "test") + } + + @Test("reloadFeatureFlags reports errorsLoading with the last known flags when the request fails") + func reloadCallbackErrorsLoading() async { + let sut = track(PostHogSDK.with(config)) + await reload(sut) + + server.flagsResponseHandler = { _ in + HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + let result = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: $0) } + } + + #expect(result.errorsLoading == true) + #expect(result.variants["string-value"] as? String == "test") + } + + @Test("coalesced reload callbacks keep the failed attempt's last known flags if one calls reset()") + func reloadCallbackCoalescedFailureReset() async { + let sut = track(PostHogSDK.with(config)) + await reload(sut) + + // Hold the first failed response so the next two reloads coalesce into one pending request + let firstResponseGate = DispatchSemaphore(value: 0) + let lock = NSLock() + var failedRequests = 0 + server.flagsResponseHandler = { _ in + let count = lock.withLock { + failedRequests += 1 + return failedRequests + } + if count == 1 { + _ = firstResponseGate.wait(timeout: .now() + 5) + } + return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + sut.reloadFeatureFlags { _ in } + var requestsBeforeReset = 0 + sut.reloadFeatureFlags { _ in + requestsBeforeReset = lock.withLock { failedRequests } + sut.reset() + } + let (result, flagAfterReset) = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: ($0, sut.getFeatureFlag("string-value"))) } + firstResponseGate.signal() + } + + #expect(requestsBeforeReset == 2) + #expect(result.errorsLoading == true) + #expect(result.variants["string-value"] as? String == "test") + #expect(flagAfterReset == nil) + } + @Test("a listener registered while a delivery is queued on main ends on the newest values") func lateListenerAfterQueuedDelivery() async { let sut = track(PostHogSDK.with(config)) @@ -1720,7 +1788,7 @@ enum PostHogFeatureFlagsTest { var received: [PostHogFeatureFlagsLoaded] = [] await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in sut.onFeatureFlags { received.append($0) } mainBlocked.signal() continuation.resume() @@ -1802,7 +1870,7 @@ enum PostHogFeatureFlagsTest { let mainBlocked = DispatchSemaphore(value: 0) DispatchQueue.main.async { _ = mainBlocked.wait(timeout: .now() + 5) } await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in sut.remoteConfig?.canReloadFlagsForTesting = false sut.reset() sut.onFeatureFlags { received.append($0) } diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index 8ad9e6726b..a6b2629576 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -31,6 +31,22 @@ struct PostHogFileBackedQueueAlignmentTest { data.map { String(data: $0, encoding: .utf8)! } } + @Test("deletes a v2 queue file without importing its events") + func deletesLegacyQueueFile() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-align-\(UUID().uuidString)") + let oldQueue = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-plist-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + try Data(#"[{"event":"v2 event","distinct_id":"user","timestamp":"2023-10-25T14:14:04.407Z"}]"#.utf8) + .write(to: oldQueue) + + let queue = PostHogFileBackedQueue(queue: dir, oldQueues: [oldQueue]) + + #expect(!FileManager.default.fileExists(atPath: oldQueue.path)) + #expect(queue.depth == 0) + } + @Test("a failed write to a full queue preserves existing entries", .enabled(if: geteuid() != 0)) func failedWritePreservesFullQueue() throws { let (queue, dir) = makeQueue() diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index 8291066c35..bcbaa1a35c 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -69,7 +69,7 @@ class PostHogGeoIpTests { let sut = getSut() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -87,7 +87,7 @@ class PostHogGeoIpTests { let sut = getSut(disableGeoIp: true) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } diff --git a/PostHogTests/PostHogIdentityTests.swift b/PostHogTests/PostHogIdentityTests.swift index a8d8ff1a67..cfb4697d97 100644 --- a/PostHogTests/PostHogIdentityTests.swift +++ b/PostHogTests/PostHogIdentityTests.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Identity tests", .serialized) diff --git a/PostHogTests/PostHogIntegrationInstallationTest.swift b/PostHogTests/PostHogIntegrationInstallationTest.swift index 857ca47ef9..d5d9302c37 100644 --- a/PostHogTests/PostHogIntegrationInstallationTest.swift +++ b/PostHogTests/PostHogIntegrationInstallationTest.swift @@ -23,14 +23,10 @@ class PostHogIntegrationInstallationTest { PostHogAutocaptureIntegration.clearInstalls() #endif #if os(iOS) || os(macOS) - if #available(iOS 14.0, macOS 11.0, *) { - PostHogPushNotificationOpenIntegration.clearInstalls() - } + PostHogPushNotificationOpenIntegration.clearInstalls() #endif #if os(iOS) - if #available(iOS 14.0, *) { - PostHogPushNotificationSubscriptionIntegration.clearInstalls() - } + PostHogPushNotificationSubscriptionIntegration.clearInstalls() #endif PostHogAppLifeCycleIntegration.clearInstalls() PostHogScreenViewIntegration.clearInstalls() @@ -86,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) @@ -190,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. @@ -354,12 +350,32 @@ 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) @Test("push notification opened integration installed only once, on first instance") func pushNotificationOpenedIntegrationInstalledOnce() async { - guard #available(iOS 14.0, macOS 11.0, *) else { return } let first = getSut(projectToken: "test_project_token", capturePushNotificationOpened: true) let second = getSut(projectToken: "test_project_token", capturePushNotificationOpened: true) @@ -372,7 +388,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification opened integration not installed when the flag is disabled") func pushNotificationOpenedIntegrationNotInstalledWhenDisabled() async { - guard #available(iOS 14.0, macOS 11.0, *) else { return } let sut = getSut(projectToken: "test_project_token", capturePushNotificationOpened: false) #expect(sut.getPushNotificationIntegration() == nil) @@ -382,7 +397,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification opened integration skipped when swizzling is disabled") func pushNotificationOpenedIntegrationSkippedWithoutSwizzling() async { - guard #available(iOS 14.0, macOS 11.0, *) else { return } let sut = getSut(projectToken: "test_project_token", enableSwizzling: false, capturePushNotificationOpened: true) #expect(sut.getPushNotificationIntegration() == nil) @@ -395,7 +409,6 @@ class PostHogIntegrationInstallationTest { #if os(iOS) @Test("push notification subscription integration installed only once, on first instance") func pushNotificationSubscriptionIntegrationInstalledOnce() async { - guard #available(iOS 14.0, *) else { return } let first = getSut(projectToken: "test_project_token", capturePushNotificationSubscriptions: true) let second = getSut(projectToken: "test_project_token", capturePushNotificationSubscriptions: true) @@ -408,7 +421,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification subscription integration not installed when the flag is disabled") func pushNotificationSubscriptionIntegrationNotInstalledWhenDisabled() async { - guard #available(iOS 14.0, *) else { return } let sut = getSut(projectToken: "test_project_token", capturePushNotificationSubscriptions: false) #expect(sut.getPushNotificationSubscriptionIntegration() == nil) @@ -418,7 +430,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification subscription integration skipped when swizzling is disabled") func pushNotificationSubscriptionIntegrationSkippedWithoutSwizzling() async { - guard #available(iOS 14.0, *) else { return } let sut = getSut(projectToken: "test_project_token", enableSwizzling: false, capturePushNotificationSubscriptions: true) #expect(sut.getPushNotificationSubscriptionIntegration() == nil) diff --git a/PostHogTests/PostHogLegacyQueueTest.swift b/PostHogTests/PostHogLegacyQueueTest.swift deleted file mode 100644 index 7cb0870cfd..0000000000 --- a/PostHogTests/PostHogLegacyQueueTest.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// PostHogLegacyQueueTest.swift -// PostHogTests -// -// Created by Manoel Aranda Neto on 30.10.23. -// - -import Foundation -@testable import PostHog -import Testing - -@Suite("PostHog legacy queue migration", .serialized, .resetsGlobalState) -struct PostHogLegacyQueueTest { - init() { - deleteSafely(applicationSupportDirectoryURL()) - } - - @Test("migrate old queue to new queue") - func migrateOldQueueToNewQueue() throws { - let baseUrl = applicationSupportDirectoryURL() - try FileManager.default.createDirectory(atPath: baseUrl.path, withIntermediateDirectories: true) - - let newURL = baseUrl.appendingPathComponent("queue") - try FileManager.default.createDirectory(atPath: newURL.path, withIntermediateDirectories: true) - - let oldURL = baseUrl.appendingPathComponent("oldQueue") - - let eventsArray = - """ - [ - { - "properties": { - "$network_cellular": false, - "$groups": { - "some-group": "id:4" - }, - "$app_build": 1, - "$os_name": "iOS", - "$feature/multivariant": "payload", - "$screen_width": 852, - "$app_version": "1.0", - "$device_type": "Mobile", - "$active_feature_flags__0": "multivariant", - "$feature/4535-funnel-bar-viz": true, - "$network_wifi": true, - "$timezone": "Europe/Vienna", - "$device_id": "48DA3429-495A-4903-B347-F096FC31C3AB", - "$active_feature_flags": [ - "$feature/multivariant", - "$feature/testJson", - "$feature/4535-funnel-bar-viz", - "$feature/disabledFlag" - ], - "$active_feature_flags__3": "disabledFlag", - "$device_name": "iPhone", - "$app_name": "", - "$app_namespace": "com.posthog.CocoapodsExample", - "$locale": "en-US", - "$feature/disabledFlag": true, - "$active_feature_flags__1": "testJson", - "$screen_height": 393, - "$device_model": "arm64", - "$device_manufacturer": "Apple", - "$feature/testJson": "theInteger", - "$lib_version": "2.1.0", - "$os_version": "17.0.1", - "$lib": "posthog-ios", - "$active_feature_flags__2": "4535-funnel-bar-viz" - }, - "timestamp": "2023-10-25T14:14:04.407Z", - "message_id": "5CE069F8-E967-4B47-9D89-207EF7519453", - "event": "Cocoapods Example Button", - "distinct_id": "Prateek" - } - ] - """ - - let eventsData = try #require(eventsArray.data(using: .utf8)) - try eventsData.write(to: oldURL) - - #expect(FileManager.default.fileExists(atPath: oldURL.path)) - - migrateOldQueue(queue: newURL, oldQueue: oldURL) - - #expect(!FileManager.default.fileExists(atPath: oldURL.path)) - - let items = try FileManager.default.contentsOfDirectory(atPath: newURL.path) - - let firstItem = try #require(items.first) - let eventURL = newURL.appendingPathComponent(firstItem) - #expect(FileManager.default.fileExists(atPath: eventURL.path)) - - let eventData = try Data(contentsOf: eventURL) - let eventObject = try #require(try JSONSerialization.jsonObject(with: eventData, options: .allowFragments) as? [String: Any]) - - #expect(eventObject["distinct_id"] as? String == "Prateek") - #expect(eventObject["event"] as? String == "Cocoapods Example Button") - #expect(eventObject["message_id"] as? String == "5CE069F8-E967-4B47-9D89-207EF7519453") - #expect(eventObject["timestamp"] as? String == "2023-10-25T14:14:04.407Z") - #expect(eventObject["properties"] as? [String: Any] != nil) - - deleteSafely(oldURL) - deleteSafely(newURL) - } - - @Test("ignore and delete corrupted file") - func ignoreAndDeleteCorruptedFile() throws { - let baseUrl = applicationSupportDirectoryURL() - try FileManager.default.createDirectory(atPath: baseUrl.path, withIntermediateDirectories: true) - - let newURL = baseUrl.appendingPathComponent("queue") - try FileManager.default.createDirectory(atPath: newURL.path, withIntermediateDirectories: true) - - let oldURL = baseUrl.appendingPathComponent("oldQueue") - - let eventsArray = - """ - [ - i am broken - ] - """ - - let eventsData = try #require(eventsArray.data(using: .utf8)) - try eventsData.write(to: oldURL) - - #expect(FileManager.default.fileExists(atPath: oldURL.path)) - - migrateOldQueue(queue: newURL, oldQueue: oldURL) - - #expect(!FileManager.default.fileExists(atPath: oldURL.path)) - - let items = try FileManager.default.contentsOfDirectory(atPath: newURL.path) - #expect(items.isEmpty) - - deleteSafely(oldURL) - deleteSafely(newURL) - } -} diff --git a/PostHogTests/PostHogLogsQueueTest.swift b/PostHogTests/PostHogLogsQueueTest.swift index 7e18b5131b..6c8106d518 100644 --- a/PostHogTests/PostHogLogsQueueTest.swift +++ b/PostHogTests/PostHogLogsQueueTest.swift @@ -6,7 +6,7 @@ import Foundation import OHHTTPStubs import OHHTTPStubsSwift -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -673,12 +673,10 @@ final class PostHogLogsQueueTests { @Test("flush is suppressed while reachability reports unreachable, resumes on reconnect") func reachabilityPauseAndResume() async throws { - // `notificationQueue: nil` makes reachability notify synchronously instead of dispatching to - // main. Otherwise the initial check inside `startNotifier()` queues an async `onReachable` on - // main that lands *after* our manual `onUnreachable` below, wiping the paused flag and flaking - // the test. With nil, that initial notification fires during `start()`, before we stop the - // notifier — so only the manual `invoke(...)` calls drive state from here on. - let reachability = try Reachability(notificationQueue: nil) + // No live `NWPathMonitor`: only the `update(_:)` calls below change connectivity, and + // `notificationQueue: nil` delivers each one synchronously. + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + reachability.update(.wifi) let (queue, _) = makeQueue( reachability: reachability, disableReachabilityForTesting: false @@ -687,11 +685,8 @@ final class PostHogLogsQueueTests { queue.stop() } - // Stop the live notifier so no further system callbacks can race the manual events below. - reachability.stopNotifier() - - // Simulate network going down. Subsequent flushes are paused. - reachability.onUnreachable.invoke(reachability) + // Network goes down. Subsequent flushes are paused. + reachability.update(.unavailable) queue.add(makeRecord(body: "while-offline")) for _ in 0 ..< 3 { @@ -702,9 +697,9 @@ final class PostHogLogsQueueTests { #expect(queue.depth == 1) #expect(queue.currentRetryCountForTesting == 0) - // Simulate WiFi back. The reachable callback unpauses and proactively + // WiFi is back. The reachable callback unpauses and proactively // triggers a flush. - reachability.onReachable.invoke(reachability) + reachability.update(.wifi) waitForLogsRequests(count: 1) #expect(server.logsRequests.count == 1) await waitUntil { queue.depth == 0 } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index b9b90f91cd..e7dea373be 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 @@ -312,6 +312,19 @@ #expect(rects.contains(secret.frame)) } + @Test("a full-window no-capture leaf is masked itself") + func fullWindowNoCaptureLeaf() throws { + let sut = makeSut() + defer { teardown(sut) } + + let secret = CustomDrawnSecretView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + secret.accessibilityIdentifier = "ph-no-capture" + let window = makeWindow(containing: secret) + try #require(secret.toAbsoluteRect(window).equalTo(window.frame)) + + #expect(sut.integration.collectMaskableRects(in: window) == [secret.frame]) + } + @Test("inherited masking respects explicit no-mask and stays within the marked subtree") func inheritedMaskRespectsScopeAndNoMask() { let sut = makeSut() diff --git a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift index f7acbba8d4..7dd6ffd52b 100644 --- a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift +++ b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift @@ -68,7 +68,6 @@ config.captureScreenViews = false config.sessionReplayConfig.maskAllTextInputs = true config.sessionReplayConfig.maskAllImages = true - config.sessionReplayConfig.screenshotMode = true sdk = PostHogSDK.with(config) _ = integration.install(sdk) integration.stop() @@ -161,7 +160,6 @@ } @Test("medium sheet keeps visible presenter PII and sheet PII masked") - @available(iOS 15.0, *) func mediumSheet() async throws { let base = Secrets() let h = try Harness(root: base.controller) @@ -230,7 +228,6 @@ func updateUIView(_: UIView, context _: Context) {} } - @available(iOS 14.0, *) private struct SwiftUIScreen: View { @ObservedObject var model: SwiftUIModel var body: some View { @@ -250,7 +247,6 @@ } } - @available(iOS 14.0, *) @Test("SwiftUI fullScreenCover masks its explicit text, automatic Text, TextField and Image") func swiftUISensitiveCover() async throws { let model = SwiftUIModel() @@ -595,6 +591,24 @@ #expect(try h.rects().isEmpty, "Fully faded banner must not veto the opaque cover") } + @Test("secure fields in a cover stay masked under a no-mask ancestor") + func secureFieldInCoverUnderNoMaskAncestor() async throws { + let base = UIViewController() + let h = try Harness(root: base) + defer { h.close() } + h.window.accessibilityIdentifier = "ph-no-mask" + let cover = Secrets() + cover.field.isSecureTextEntry = true + try await present(cover.controller, over: base, in: h) + let selected = try #require(PostHogPresentationCover.frontmostFullWindowCover(in: h.window)) + try #require(selected !== h.window) + try #require(cover.field.isDescendant(of: selected)) + + try h.expectMasked(cover.field, context: "secure field under no-mask window") + let labelRect = cover.label.toPresentationRect(h.window) + #expect(try h.rects().allSatisfy { !$0.intersects(labelRect) }, "No-mask still unmasks ordinary text") + } + @Test("rotated cover bounding box is not proof the presenter is hidden") func rotatedCover() async throws { let base = Secrets(background: .yellow) diff --git a/PostHogTests/PostHogMaskPresentationTest.swift b/PostHogTests/PostHogMaskPresentationTest.swift index aecbcb7386..0849a24169 100644 --- a/PostHogTests/PostHogMaskPresentationTest.swift +++ b/PostHogTests/PostHogMaskPresentationTest.swift @@ -25,7 +25,6 @@ @Published var isPresented = false } - @available(iOS 14.0, *) private struct MaskedScreen: View { @ObservedObject var model: CoverModel @@ -85,7 +84,6 @@ // MARK: - Tests - @available(iOS 14.0, *) @Test("a SwiftUI fullScreenCover drops the masks of the screen it covers") func fullScreenCoverDropsMasksBehindIt() async { let model = CoverModel() diff --git a/PostHogTests/PostHogMaskScenarioTest.swift b/PostHogTests/PostHogMaskScenarioTest.swift index aa45519052..cc5fa23b8f 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 @@ -497,116 +497,5 @@ #expect(inflated[0].minY <= 95) #expect(inflated[0].maxY >= 125.31) } - - @Test("swept union covers both sample positions, in after order") - func sweptRectsCoversBothSamplesInAfterOrder() throws { - let a = NSObject() - let b = NSObject() - let before = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - // b moves; a is stationary. after is in swapped order vs before, so - // ordering must be recovered from `owner`, not index. - let after = [ - region(b, CGRect(x: 0, y: 130, width: 100, height: 20)), - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - ] - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept.count == 2) - - // swept[0] pairs with after[0] (b): union of y=100..120 and y=130..150. - #expect(swept[0] == CGRect(x: 0, y: 100, width: 100, height: 50)) - - // swept[1] pairs with after[1] (a): stationary, so it collapses to a's rect. - #expect(swept[1] == CGRect(x: 0, y: 0, width: 100, height: 20)) - } - - @Test("nil before makes sweptRects nil") - func sweptRectsNilBefore() { - let a = NSObject() - let after = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - #expect(PostHogReplayIntegration.sweptRects(before: nil, after: after) == nil) - } - - @Test("nil after makes sweptRects nil") - func sweptRectsNilAfter() { - let a = NSObject() - let before = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - #expect(PostHogReplayIntegration.sweptRects(before: before, after: nil) == nil) - } - - @Test("an owner that appears mid-render is covered, not dropped") - func sweptRectsCountMismatch() throws { - let a = NSObject() - let b = NSObject() - let before = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - let after = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - // b appeared mid-render, so it was displayed for part of the window and is covered - // rather than costing the whole frame. - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept == [ - CGRect(x: 0, y: 0, width: 100, height: 20), - CGRect(x: 0, y: 100, width: 100, height: 20), - ]) - } - - @Test("duplicate owner in before makes sweptRects nil") - func sweptRectsDuplicateOwnerInBefore() { - let a = NSObject() - let b = NSObject() - let before = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - ] - let after = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - #expect(PostHogReplayIntegration.sweptRects(before: before, after: after) == nil) - } - - @Test("duplicate owner in after makes sweptRects nil") - func sweptRectsDuplicateOwnerInAfter() { - let a = NSObject() - let b = NSObject() - let before = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - let after = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - ] - #expect(PostHogReplayIntegration.sweptRects(before: before, after: after) == nil) - } - - @Test("a fully recycled owner set covers both samples, not nil") - func sweptRectsDisjointOwnerSet() throws { - let a = NSObject() - let b = NSObject() - let before = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - let after = [region(b, CGRect(x: 0, y: 200, width: 100, height: 20))] - // Neither owner can be paired, but both were on screen during the render window: - // `after` entries come first, then the ones that went away. - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept == [ - CGRect(x: 0, y: 200, width: 100, height: 20), - CGRect(x: 0, y: 0, width: 100, height: 20), - ]) - } - - @Test("a stationary owner's swept rect equals its rect unchanged") - func sweptRectsStationaryOwnerUnchanged() throws { - let a = NSObject() - let rect = CGRect(x: 10, y: 20, width: 100, height: 20) - let before = [region(a, rect)] - let after = [region(a, rect)] - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept == [rect]) - } } #endif diff --git a/PostHogTests/PostHogMaskSnapshotTest.swift b/PostHogTests/PostHogMaskSnapshotTest.swift index c51305adb4..fc77e9eb6b 100644 --- a/PostHogTests/PostHogMaskSnapshotTest.swift +++ b/PostHogTests/PostHogMaskSnapshotTest.swift @@ -420,7 +420,7 @@ private let integration = PostHogReplayIntegration() init() { - let config = PostHogConfig(apiKey: "phc_maskSnapshotTest") + let config = PostHogConfig(projectToken: "phc_maskSnapshotTest") config.disableReachabilityForTesting = true sdk = PostHogSDK.with(config) _ = integration.install(sdk) diff --git a/PostHogTests/PostHogMaskingCharacterizationTest.swift b/PostHogTests/PostHogMaskingCharacterizationTest.swift index 45ab9ecce1..efb449ec69 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/PostHogMemoryExceptionReporterTest.swift b/PostHogTests/PostHogMemoryExceptionReporterTest.swift index b723b9c281..140291186e 100644 --- a/PostHogTests/PostHogMemoryExceptionReporterTest.swift +++ b/PostHogTests/PostHogMemoryExceptionReporterTest.swift @@ -1,5 +1,5 @@ import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing #if os(iOS) && !targetEnvironment(macCatalyst) && compiler(>=6.4) diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index 8dce0ec542..9809334385 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -249,7 +249,6 @@ } @Test("the public prewarm API reaches the publisher") - @available(iOS 14.0, macOS 11.0, *) func publicPrewarmApiReachesPublisher() { #expect(publisher.prewarmCount == 0) PostHogSDK.prewarmPushNotificationOpenCapture() @@ -257,7 +256,6 @@ } /// Mirrors `PostHogIntegrationInstallationTest.getSut`, trimmed to what the discard gate reads. - @available(iOS 14.0, macOS 11.0, *) private func makeSut( optOut: Bool = false, capturePushNotificationOpened: Bool = true, @@ -286,7 +284,6 @@ } @Test("setup() releases a prewarm when push-open capture is disabled") - @available(iOS 14.0, macOS 11.0, *) func setupDiscardsPrewarmWhenCaptureDisabled() { let sut = makeSut(capturePushNotificationOpened: false) defer { sut.close() } @@ -297,7 +294,6 @@ /// `setup()` skips `installIntegrations()` entirely while opted out, so the discard cannot /// live there without leaving an opted-out app swizzled for the process lifetime. @Test("setup() releases a prewarm while opted out, even with push-open capture enabled") - @available(iOS 14.0, macOS 11.0, *) func setupDiscardsPrewarmWhileOptedOut() { let sut = makeSut(optOut: true, capturePushNotificationOpened: true) defer { sut.close() } @@ -306,7 +302,6 @@ } @Test("setup() with push-open capture enabled does not discard the prewarm") - @available(iOS 14.0, macOS 11.0, *) func setupKeepsPrewarmWhenCaptureEnabled() { publisher.prewarmNotificationResponseCapture() diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 8add01fe3b..403e715f0f 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -15,13 +15,9 @@ let projectToken = "push_\(UUID().uuidString)" init() { - if #available(iOS 14.0, macOS 11.0, *) { - PostHogPushNotificationOpenIntegration.clearInstalls() - } + PostHogPushNotificationOpenIntegration.clearInstalls() #if os(iOS) - if #available(iOS 14.0, *) { - PostHogPushNotificationSubscriptionIntegration.clearInstalls() - } + PostHogPushNotificationSubscriptionIntegration.clearInstalls() #endif PostHogAppLifeCycleIntegration.clearInstalls() PostHogScreenViewIntegration.clearInstalls() @@ -197,8 +193,6 @@ @Test("getIntegrations includes the opened integration only when its flag is enabled") func getIntegrationsGatesOpenedIntegration() { - guard #available(iOS 14.0, macOS 11.0, *) else { return } - let enabled = PostHogConfig(projectToken: projectToken) enabled.capturePushNotificationOpened = true #expect(enabled.getIntegrations().contains { $0 is PostHogPushNotificationOpenIntegration }) @@ -211,8 +205,6 @@ #if os(iOS) @Test("getIntegrations includes the subscription integration only when its flag is enabled (iOS)") func getIntegrationsGatesSubscriptionIntegration() { - guard #available(iOS 14.0, *) else { return } - let enabled = PostHogConfig(projectToken: projectToken) enabled.capturePushNotificationSubscriptions = true #expect(enabled.getIntegrations().contains { $0 is PostHogPushNotificationSubscriptionIntegration }) @@ -1769,9 +1761,7 @@ let sut = getSDK(enableSwizzling: false, capturePushNotificationOpened: true) defer { sut.close() } - if #available(iOS 14.0, macOS 11.0, *) { - #expect(sut.getPushNotificationIntegration() == nil) - } + #expect(sut.getPushNotificationIntegration() == nil) sut.capturePushNotificationOpened( title: "Hello", @@ -2074,53 +2064,47 @@ #if os(iOS) @Test("opting back in re-requests the push token so push re-arms without an app restart") func optInReRequestsPushToken() { - if #available(iOS 14.0, *) { - let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh - defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } - var refetchCount = 0 - PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } - - let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: true) - defer { sut.close() } - - #expect(sut.isOptOut()) - sut.optIn() - #expect(refetchCount == 1, "opt-in should re-request the APNs token, not just restore consent") - } + let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh + defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } + var refetchCount = 0 + PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } + + let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: true) + defer { sut.close() } + + #expect(sut.isOptOut()) + sut.optIn() + #expect(refetchCount == 1, "opt-in should re-request the APNs token, not just restore consent") } @Test("opt-in does not re-request the token when auto-capture is disabled") func optInSkipsReRequestWhenAutoCaptureDisabled() { - if #available(iOS 14.0, *) { - let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh - defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } - var refetchCount = 0 - PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } + let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh + defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } + var refetchCount = 0 + PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } - let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: false) - defer { sut.close() } + let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: false) + defer { sut.close() } - sut.optIn() - #expect(refetchCount == 0, "manual push mode leaves the token lifecycle to the host") - } + sut.optIn() + #expect(refetchCount == 0, "manual push mode leaves the token lifecycle to the host") } @Test("opt-in does not re-request the token when swizzling is disabled") func optInSkipsReRequestWhenSwizzlingDisabled() { - if #available(iOS 14.0, *) { - let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh - defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } - var refetchCount = 0 - PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } - - // Auto-capture on, but swizzling off: the subscription integration is not installed, - // so refetching would fire the host's APNs lifecycle with no observer to catch it. - let sut = getSDK(optOut: true, enableSwizzling: false, capturePushNotificationSubscriptions: true) - defer { sut.close() } - - sut.optIn() - #expect(refetchCount == 0, "no observer is installed without swizzling, so opt-in must not refetch") - } + let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh + defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } + var refetchCount = 0 + PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } + + // Auto-capture on, but swizzling off: the subscription integration is not installed, + // so refetching would fire the host's APNs lifecycle with no observer to catch it. + let sut = getSDK(optOut: true, enableSwizzling: false, capturePushNotificationSubscriptions: true) + defer { sut.close() } + + sut.optIn() + #expect(refetchCount == 0, "no observer is installed without swizzling, so opt-in must not refetch") } #endif @@ -2295,7 +2279,6 @@ #expect(try #require(server.parseRequest(del))["distinct_id"] as? String == "user-1") } - @available(iOS 14.0, macOS 11.0, *) @Test("posthog-ios#746: opt-out during an in-flight unregister must still send the DELETE") func optOutDuringUnregisterStrandsDelete() async throws { let parked = ParkedMint() diff --git a/PostHogTests/PostHogReachabilityTest.swift b/PostHogTests/PostHogReachabilityTest.swift index 12db040a49..d5d03aba09 100644 --- a/PostHogTests/PostHogReachabilityTest.swift +++ b/PostHogTests/PostHogReachabilityTest.swift @@ -11,8 +11,8 @@ import Testing @Suite("Reachability multicast") final class PostHogReachabilityTests { @Test("multiple subscribers all fire on every transition") - func multicastNoStomp() throws { - let reachability = try Reachability() + func multicastNoStomp() { + let reachability = Reachability() var subscriberAReachable = 0 var subscriberBReachable = 0 var subscriberAUnreachable = 0 @@ -44,8 +44,8 @@ import Testing } @Test("releasing a reachable subscription token unregisters that subscriber") - func tokenDeallocUnsubscribesOnReachable() throws { - let reachability = try Reachability() + func tokenDeallocUnsubscribesOnReachable() { + let reachability = Reachability() var calls = 0 do { @@ -62,8 +62,8 @@ import Testing } @Test("releasing an unreachable subscription token unregisters that subscriber") - func tokenDeallocUnsubscribesOnUnreachable() throws { - let reachability = try Reachability() + func tokenDeallocUnsubscribesOnUnreachable() { + let reachability = Reachability() var calls = 0 do { @@ -77,4 +77,85 @@ import Testing #expect(calls == 1) } } + + @Suite("Reachability notifier") + final class PostHogReachabilityNotifierTests { + @Test("the first start reports the current connection once") + func firstStartReportsCurrentConnection() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + var reachable = 0 + let token = reachability.onReachable.subscribe { _ in reachable += 1 } + defer { _ = token } + + reachability.update(.wifi) + #expect(reachable == 0) + + reachability.startNotifier() + #expect(reachable == 1) + + reachability.startNotifier() + #expect(reachable == 1) + } + + @Test("paths after start are reported, paths after stop are not") + func reportsPathsWhileRunning() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + var reachable = 0 + var unreachable = 0 + let tokens = [ + reachability.onReachable.subscribe { _ in reachable += 1 }, + reachability.onUnreachable.subscribe { _ in unreachable += 1 }, + ] + defer { _ = tokens } + + // No path yet, so starting reports nothing. + reachability.startNotifier() + #expect(reachable == 0) + + reachability.update(.cellular) + reachability.update(.unavailable) + #expect(reachable == 1) + #expect(unreachable == 1) + + reachability.stopNotifier() + reachability.update(.wifi) + #expect(reachable == 1) + #expect(reachability.connection == .wifi) + } + + @Test("queued notifications report the connection at delivery, not a stale one") + func notificationsReportConnectionAtDelivery() { + let notificationQueue = DispatchQueue(label: "test.reachability.notifications") + let reachability = Reachability(notificationQueue: notificationQueue, monitorsPaths: false) + var reachable = 0 + var unreachable = 0 + let tokens = [ + reachability.onReachable.subscribe { _ in reachable += 1 }, + reachability.onUnreachable.subscribe { _ in unreachable += 1 }, + ] + defer { _ = tokens } + + // Offline at launch, then the network comes back before the start notification runs. + reachability.update(.unavailable) + notificationQueue.suspend() + reachability.startNotifier() + reachability.update(.wifi) + notificationQueue.resume() + notificationQueue.sync {} + + // A stale `onUnreachable` would pause the queues while online. + #expect(unreachable == 0) + #expect(reachable == 2) + } + + @Test("without a path, connection is nil and only the first reads wait") + func connectionWithoutPath() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + #expect(reachability.connection == nil) + + let start = Date() + #expect(reachability.connection == nil) + #expect(Date().timeIntervalSince(start) < 0.05) + } + } #endif diff --git a/PostHogTests/PostHogReactNativeMaskingTest.swift b/PostHogTests/PostHogReactNativeMaskingTest.swift index 310e6ada21..b82c561132 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 @@ -136,6 +136,25 @@ #expect(sut.integration.collectMaskableRects(in: window) == []) } + @Test("A secure UITextField nested under a ph-no-mask ancestor stays masked") + func noMaskAncestorKeepsSecureFieldMasked() { + let sut = makeSut(maskText: false, maskImages: false) + defer { teardown(sut) } + + let container = UIView(frame: CGRect(x: 0, y: 80, width: 320, height: 200)) + container.accessibilityIdentifier = "ph-no-mask" + let wrapper = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 200)) + let secureField = UITextField(frame: CGRect(x: 12, y: 12, width: 160, height: 40)) + secureField.isSecureTextEntry = true + secureField.text = "hunter2" + wrapper.addSubview(secureField) + wrapper.addSubview(UITextField(frame: CGRect(x: 12, y: 64, width: 160, height: 40))) + container.addSubview(wrapper) + let window = makeWindow(containing: container) + + #expect(sut.integration.collectMaskableRects(in: window) == [CGRect(x: 12, y: 92, width: 160, height: 40)]) + } + @Test("A ph-no-mask accessibilityLabel token unmasks Fabric paragraph view") func noMaskLabelTokenUnmasksParagraph() { let sut = makeSut(maskText: true, maskImages: false) diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index d8e6237617..a6defd2acb 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -5,7 +5,7 @@ // Created by Yiannis Josephides on 20/01/2025. // -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -430,11 +430,11 @@ enum PostHogRemoteConfigTest { let bothResults = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "user1", anonymousId: nil, groups: [:]) { flags in - firstResult = flags + firstResult = flags.featureFlags bothResults.signal() } sut.loadFeatureFlags(distinctId: "user2", anonymousId: nil, groups: [:]) { flags in - secondResult = flags + secondResult = flags.featureFlags bothResults.signal() } @@ -456,7 +456,7 @@ enum PostHogRemoteConfigTest { await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "single_user", anonymousId: nil, groups: [:]) { flags in - result = flags + result = flags.featureFlags continuation.resume() } } diff --git a/PostHogTests/PostHogReplayBatchBoundaryTest.swift b/PostHogTests/PostHogReplayBatchBoundaryTest.swift index 5f633c2523..ccd859e98e 100644 --- a/PostHogTests/PostHogReplayBatchBoundaryTest.swift +++ b/PostHogTests/PostHogReplayBatchBoundaryTest.swift @@ -151,29 +151,71 @@ struct PostHogReplayBatchBoundaryTest { @Test("Reachability pauses continuation between groups") func pauseBetweenGroups() async throws { - let reachability = try Reachability(notificationQueue: nil) + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + reachability.update(.wifi) let sender = Sender() let queue = queue(config(), sender, reachability: reachability) queue.start(disableReachabilityForTesting: false, disableQueueTimerForTesting: true) - reachability.stopNotifier() defer { queue.clear() queue.stop() } queue.add(event("first")) queue.add(event("later", session: "b")) try await flush(queue, sender, count: 1) - reachability.onUnreachable.invoke(reachability) + reachability.update(.unavailable) sender.complete(0, status: 200) try await Task.sleep(nanoseconds: 50_000_000) #expect(sender.batches.count == 1) #expect(queue.depth == 1) - reachability.onReachable.invoke(reachability) + reachability.update(.wifi) await waitUntil { sender.batches.count == 2 } try #require(sender.batches.count == 2) sender.complete(1, status: 200) #expect(queue.depth == 0) } + @Test("Wi-Fi data mode waits for a Wi-Fi path and stays paused on cellular") + func wifiModeWaitsForWifi() async throws { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + let sender = Sender() + let config = config() + config.dataMode = .wifi + let queue = queue(config, sender, reachability: reachability) + queue.start(disableReachabilityForTesting: false, disableQueueTimerForTesting: true) + defer { queue.clear() + queue.stop() + } + queue.add(event("first")) + + // No path yet. + queue.flush() + try await Task.sleep(nanoseconds: 50_000_000) + #expect(sender.batches.isEmpty) + + reachability.update(.cellular) + queue.flush() + try await Task.sleep(nanoseconds: 50_000_000) + #expect(sender.batches.isEmpty) + + // The reachable callback unpauses and flushes. + reachability.update(.wifi) + await waitUntil { sender.batches.count == 1 } + #expect(sender.batches.count == 1) + } + + @Test("Any data mode sends before the first path arrives") + func anyModeSendsWithoutPath() async throws { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + let sender = Sender() + let queue = queue(config(), sender, reachability: reachability) + queue.start(disableReachabilityForTesting: false, disableQueueTimerForTesting: true) + defer { queue.clear() + queue.stop() + } + queue.add(event("first")) + try await flush(queue, sender, count: 1) + } + @Test("Terminal responses continue to the next group", arguments: [400, 413]) func terminalContinues(status: Int) async throws { let sender = Sender() diff --git a/PostHogTests/PostHogReplayCameraTest.swift b/PostHogTests/PostHogReplayCameraTest.swift index fdc3313467..527418113d 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 @@ -68,7 +68,6 @@ func bridgeRecoversAfterCamera() throws { let config = PostHogConfig(projectToken: UUID().uuidString) config.sessionReplay = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.captureNetworkTelemetry = false config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true @@ -118,7 +117,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 ef673d6047..6139dde819 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 @@ -62,7 +62,6 @@ private func makeSut(captureTouches: Bool? = nil) throws -> (PostHogSDK, PostHogReplayIntegration, Snapshots) { let config = PostHogConfig(projectToken: UUID().uuidString) config.sessionReplay = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.captureNetworkTelemetry = false if let captureTouches { config.sessionReplayConfig.captureTouches = captureTouches diff --git a/PostHogTests/PostHogReplayLayoutCaptureTest.swift b/PostHogTests/PostHogReplayLayoutCaptureTest.swift new file mode 100644 index 0000000000..8c1a5b010d --- /dev/null +++ b/PostHogTests/PostHogReplayLayoutCaptureTest.swift @@ -0,0 +1,120 @@ +#if os(iOS) && TEST_PRESENTATION_MASKS + import Foundation + @testable import PostHog + import SwiftUI + import Testing + import UIKit + + // Needs the app host: snapshot() reads the key window of a foreground-active scene. + @Suite("Replay layout-driven capture", .serialized) + @MainActor + struct PostHogReplayLayoutCaptureTest { + enum Root: String, CaseIterable, CustomTestStringConvertible { + case uiKit, plainTitled, swiftUI + + var testDescription: String { rawValue } + + var expectedHref: String? { + switch self { + case .uiKit: "Checkout" + // The title is display text, so it never becomes the href. + case .plainTitled: nil + case .swiftUI: "Text" + } + } + + @MainActor func makeController() -> UIViewController { + switch self { + case .uiKit, .plainTitled: + let controller = self == .uiKit ? CheckoutViewController() : UIViewController() + controller.title = "Invoice for Jane Doe" + let label = UILabel(frame: CGRect(x: 20, y: 100, width: 200, height: 40)) + label.text = "secret" + controller.view.addSubview(label) + return controller + case .swiftUI: + return UIHostingController(rootView: Text("secret")) + } + } + } + + private final class CheckoutViewController: UIViewController {} + + private final class Snapshots { + private let lock = NSLock() + private var values: [[String: Any]] = [] + + func record(_ event: PostHogEvent) { + guard event.event == "$snapshot", + let data = event.properties["$snapshot_data"] as? [[String: Any]] + else { return } + lock.withLock { values.append(contentsOf: data) } + } + + private func data(ofType type: Int) -> [[String: Any]] { + lock.withLock { + values.filter { $0["type"] as? Int == type }.compactMap { $0["data"] as? [String: Any] } + } + } + + var metas: [[String: Any]] { data(ofType: 4) } + var wireframes: [[String: Any]] { data(ofType: 2).flatMap { $0["wireframes"] as? [[String: Any]] ?? [] } } + } + + private func drainReplayQueue() async { + await withCheckedContinuation { continuation in + PostHogReplayIntegration.dispatchQueue.async { continuation.resume() } + } + } + + @Test("Default config captures UIKit and SwiftUI screens as screenshots", arguments: Root.allCases) + func defaultConfigCapturesScreenshots(_ root: Root) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = scene.coordinateSpace.bounds + window.rootViewController = root.makeController() + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + try #require(UIApplication.getCurrentWindow() === window) + + let config = PostHogConfig(projectToken: UUID().uuidString) + config.sessionReplay = true + config.sessionReplayConfig.captureNetworkTelemetry = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.disableRemoteConfigForTesting = true + config.preloadFeatureFlags = false + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + let snapshots = Snapshots() + config.setBeforeSend { event in + snapshots.record(event) + return nil + } + PostHogStorage(config).setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["endpoint": "/s/"]]) + PostHogReplayIntegration.clearInstalls() + let sut = PostHogSDK.with(config) + defer { sut.close() } + #expect(sut.isSessionReplayActive()) + + // Each frame is encoded on the serial replay queue, which is slow on the simulator. + // Nudge sparingly so frames don't pile up, then let the queue drain before closing. + for _ in 0 ..< 40 where snapshots.wireframes.isEmpty { + window.setNeedsLayout() + window.layoutIfNeeded() + try await Task.sleep(nanoseconds: 500_000_000) + } + await drainReplayQueue() + + let wireframe = try #require(snapshots.wireframes.first, "No snapshot was captured") + #expect(wireframe["type"] as? String == "screenshot") + #expect(!(wireframe["base64"] as? String ?? "").isEmpty) + let meta = try #require(snapshots.metas.first) + #expect(meta["href"] as? String == root.expectedHref) + } + } +#endif diff --git a/PostHogTests/PostHogReplayScreenshotDedupTest.swift b/PostHogTests/PostHogReplayScreenshotDedupTest.swift index d021502013..8f71358560 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 @@ -29,7 +29,7 @@ imageHash: 42, lastImageHash: 42, hasPendingSnapshotData: true, expectedSkip: false ), DedupCase( - name: "never skips when there is no image hash (wireframe mode)", + name: "never skips when there is no image hash", imageHash: nil, lastImageHash: 42, hasPendingSnapshotData: false, expectedSkip: false ), DedupCase( diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 2fc5310ddc..121a94fec3 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog #if SWIFT_PACKAGE import PostHogTestsObjC #endif @@ -61,7 +61,6 @@ private final class SDKTestFixture { captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, optOut: Bool = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = nil, personProfiles: PostHogPersonProfiles = .identifiedOnly, setDefaultPersonProperties: Bool = true, beforeSend: [BeforeSendBlock]? = nil) -> PostHogSDK @@ -75,7 +74,6 @@ private final class SDKTestFixture { config.disableFlushOnBackgroundForTesting = true config.captureApplicationLifecycleEvents = captureApplicationLifecycleEvents config.optOut = optOut - config.propertiesSanitizer = propertiesSanitizer config.personProfiles = personProfiles config.setDefaultPersonProperties = setDefaultPersonProperties @@ -121,15 +119,13 @@ final class PostHogSDKTests { sendFeatureFlagEvent: Bool = false, captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, - optOut: Bool = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = nil) -> PostHogSDK + optOut: Bool = false) -> PostHogSDK { fixture.getSut(preloadFeatureFlags: preloadFeatureFlags, sendFeatureFlagEvent: sendFeatureFlagEvent, captureApplicationLifecycleEvents: captureApplicationLifecycleEvents, flushAt: flushAt, - optOut: optOut, - propertiesSanitizer: propertiesSanitizer) + optOut: optOut) } private func bootstrapReconcileConfig(existing: (anon: String, distinct: String?, identified: Bool)) -> PostHogConfig { @@ -162,18 +158,6 @@ final class PostHogSDKTests { #expect(sut.getSessionId() == nil) } - @Test("no-ops setup when legacy api key is empty after trimming") - func noOpsSetupWhenLegacyApiKeyIsEmpty() { - let config = PostHogConfig(apiKey: " \n\t ", host: "http://localhost:9001") - - let sut = PostHogSDK.with(config) - - #expect(sut.config.projectToken.isEmpty) - #expect(sut.storage == nil) - #expect(sut.getDistinctId().isEmpty) - #expect(sut.getSessionId() == nil) - } - @Test("merges an anonymous local user into an identified bootstrap") func mergesAnonymousUserIntoIdentifiedBootstrap() { let config = bootstrapReconcileConfig(existing: (anon: "anon-abc", distinct: nil, identified: false)) @@ -399,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 == "screenshot") + #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 { @@ -467,23 +453,6 @@ 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 } - - let sut = getSut() - sut.screen("theScreen") - - let events = getBatchedEvents(server) - #expect(events.first?.properties["$sdk_debug_replay_capture_mode"] as? String == "screenshot") - - sut.reset() - sut.close() - } - @Test("SDK-computed debug keys win over a same-named registered super property") func sdkComputedDebugKeysWinOverRegisteredSuperProperty() throws { server.reset(batchCount: 1) @@ -528,12 +497,11 @@ final class PostHogSDKTests { let sut = getSut() sut.close() - var called = false - sut.reloadFeatureFlags { - called = true - } + var result: PostHogFeatureFlagsLoaded? + sut.reloadFeatureFlags { result = $0 } - #expect(called) + #expect(result?.errorsLoading == true) + #expect(result?.flags.isEmpty == true) } @Test("captures a screen event") @@ -646,7 +614,7 @@ final class PostHogSDKTests { let group = DispatchGroup() group.enter() - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in group.leave() } @@ -1237,24 +1205,6 @@ final class PostHogSDKTests { #expect(FileManager.default.fileExists(atPath: appFolder.path) == true) } - @Test("client sanitize properties") - func clientSanitizeProperties() throws { - let sanitizer = ExampleSanitizer() - let sut = getSut(propertiesSanitizer: sanitizer) - - let props: [String: Any] = ["empty": ""] - - sut.capture("event", properties: props) - - let events = getBatchedEvents(server) - - let event = try #require(events.first) - #expect(event.properties["empty"] as? String == nil) - - sut.reset() - sut.close() - } - @Test("reset reloads flags as anon user") func resetReloadsFlagsAsAnonUser() { let sut = getSut() @@ -1357,7 +1307,7 @@ final class PostHogSDKTests { _ = sut.getFeatureFlag("some_key") let reloaded = XCTestExpectation(description: "second flag lookup completed") - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in _ = sut.getFeatureFlag("some_key") reloaded.fulfill() } @@ -1383,7 +1333,7 @@ final class PostHogSDKTests { // Change the mock server to return a different value for the same key server.disabledFlag = true - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in // Second call gets a true value _ = sut.getFeatureFlag("disabled-flag") sut.capture("force_batch_flush") diff --git a/PostHogTests/PostHogSamplingTest.swift b/PostHogTests/PostHogSamplingTest.swift index bc571aae0e..6d64cf8441 100644 --- a/PostHogTests/PostHogSamplingTest.swift +++ b/PostHogTests/PostHogSamplingTest.swift @@ -1,5 +1,5 @@ import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Session Replay Sampling Tests") @@ -100,7 +100,7 @@ class PostHogSamplingTests { } } -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) // MARK: - parseSampleRate Tests @@ -225,7 +225,6 @@ class PostHogSamplingTests { @Test("parses sample rate from remote config response") func parsesSampleRateFromRemoteConfig() async { let config = PostHogConfig(projectToken: self.config.projectToken, host: "http://localhost:9001") - config.remoteConfig = true config.preloadFeatureFlags = false config.storageManager = PostHogStorageManager(config) @@ -248,7 +247,6 @@ class PostHogSamplingTests { @Test("remote config without sample rate leaves it nil") func remoteConfigWithoutSampleRate() async { let config = PostHogConfig(projectToken: self.config.projectToken, host: "http://localhost:9001") - config.remoteConfig = true config.preloadFeatureFlags = false config.storageManager = PostHogStorageManager(config) diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 4043183f35..c8f0c5fd53 100644 --- a/PostHogTests/PostHogSessionManagerTest.swift +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite(.serialized, .resetsGlobalState) @@ -198,7 +198,6 @@ enum PostHogSessionManagerTest { captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, optOut: Bool = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = nil, personProfiles: PostHogPersonProfiles = .identifiedOnly ) -> PostHogSDK { let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9001") @@ -211,7 +210,6 @@ enum PostHogSessionManagerTest { config.captureApplicationLifecycleEvents = captureApplicationLifecycleEvents config.captureScreenViews = false config.optOut = optOut - config.propertiesSanitizer = propertiesSanitizer config.personProfiles = personProfiles config.maxBatchSize = max(flushAt, config.maxBatchSize) server.batchProjectToken = config.projectToken diff --git a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift index 6f6aebc903..088ec47e58 100644 --- a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift +++ b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift @@ -1,6 +1,6 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation - @testable import PostHog + @_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Session Replay Event Triggers", .serialized) diff --git a/PostHogTests/PostHogSessionReplayManualModeTest.swift b/PostHogTests/PostHogSessionReplayManualModeTest.swift index de56f37f98..cedcb141fa 100644 --- a/PostHogTests/PostHogSessionReplayManualModeTest.swift +++ b/PostHogTests/PostHogSessionReplayManualModeTest.swift @@ -1,6 +1,6 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation - @testable import PostHog + @_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Session Replay Manual Mode", .serialized) diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index d877a244b6..148c3f0497 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -1,6 +1,6 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation - @testable import PostHog + @_spi(PostHogInternal) @testable import PostHog #if !SWIFT_PACKAGE import CwlCatchException #endif diff --git a/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift b/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift index b770ff7843..9949e90abd 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 c8c0ee7316..ee0f40fb5d 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/PostHogStorageManagerTest.swift b/PostHogTests/PostHogStorageManagerTest.swift index 9eab4a41a8..cb120c433b 100644 --- a/PostHogTests/PostHogStorageManagerTest.swift +++ b/PostHogTests/PostHogStorageManagerTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("PostHog storage manager", .serialized, .resetsGlobalState) diff --git a/PostHogTests/PostHogStorageMigrationTest.swift b/PostHogTests/PostHogStorageMigrationTest.swift index a270a8c1d3..901038d4c7 100644 --- a/PostHogTests/PostHogStorageMigrationTest.swift +++ b/PostHogTests/PostHogStorageMigrationTest.swift @@ -256,7 +256,7 @@ class PostHogStorageMigrationTest { ], timestamp: Date(timeIntervalSince1970: 1707221234000), uuid: UUID(), - apiKey: "test_project_token" + projectToken: "test_project_token" ), PostHogEvent( event: "$snapshot", @@ -285,7 +285,7 @@ class PostHogStorageMigrationTest { ], timestamp: Date(timeIntervalSince1970: 1707221235), uuid: UUID(), - apiKey: "test_project_token" + projectToken: "test_project_token" ), ] diff --git a/PostHogTests/PostHogStorageTest.swift b/PostHogTests/PostHogStorageTest.swift index 9bbed3cb3b..80cf1e943a 100644 --- a/PostHogTests/PostHogStorageTest.swift +++ b/PostHogTests/PostHogStorageTest.swift @@ -124,6 +124,28 @@ class PostHogStorageTest { sut.reset() } + @Test("deletes a v2 queue file left in the legacy base folder without importing it") + func deletesLegacyV2QueueFileFromBaseFolder() throws { + let fileManager = FileManager.default + let baseUrl = applicationSupportDirectoryURL() + try fileManager.createDirectory(at: baseUrl, withIntermediateDirectories: true) + let legacyPlist = baseUrl.appendingPathComponent(PostHogStorage.StorageKey.oldQueuePlist.rawValue) + try Data(#"[{"event":"v2 event","distinct_id":"user","timestamp":"2023-10-25T14:14:04.407Z"}]"#.utf8) + .write(to: legacyPlist) + + let storage = PostHogStorage(PostHogConfig(projectToken: "v2_queue_\(UUID().uuidString)")) + defer { try? fileManager.removeItem(at: storage.appFolderUrl) } + // Same keys as the `/batch` endpoint. + let queue = PostHogFileBackedQueue( + queue: storage.url(forKey: .queue), + oldQueues: [.oldQueueFolder, .oldQueuePlist].map { storage.url(forKey: $0) } + ) + + #expect(!fileManager.fileExists(atPath: legacyPlist.path)) + #expect(!fileManager.fileExists(atPath: storage.url(forKey: .oldQueuePlist).path)) + #expect(queue.depth == 0) + } + @Test("writes to disk in a project token folder under application support directory") func writesToDiskInAProjectTokenFolderUnderApplicationSupportDirectory() { let config = PostHogConfig(projectToken: "test_project_token") diff --git a/PostHogTests/PostHogSurveyEventsTest.swift b/PostHogTests/PostHogSurveyEventsTest.swift index 5a01cc21ff..012d4fe127 100644 --- a/PostHogTests/PostHogSurveyEventsTest.swift +++ b/PostHogTests/PostHogSurveyEventsTest.swift @@ -5,962 +5,880 @@ // Created by Ioannis Josephides on 03/10/2025. // -import Foundation -@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 7da10dbeac..550dc25247 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 ba28c10c85..c3c1c80351 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 f20b12a15c..6a2ca3d2e4 100644 --- a/PostHogTests/PostHogSurveySheetDetentsTest.swift +++ b/PostHogTests/PostHogSurveySheetDetentsTest.swift @@ -5,20 +5,18 @@ // Created by Anna Garcia on 28/09/2026. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import Foundation @testable import PostHog import Testing @Suite("Survey sheet detents") struct PostHogSurveySheetDetentsTest { - @available(iOS 15.0, *) @Test("a sheet shorter than the window gets a detent of its own height") func shortSheetGetsItsOwnHeight() { #expect(SurveyPresentationDetentsRepresentable.detents(forSheetHeight: 400, availableHeight: 844) == [.height(400)]) } - @available(iOS 15.0, *) @Test("a sheet taller than the window can be expanded") func tallSheetIsExpandable() { #expect(SurveyPresentationDetentsRepresentable.detents(forSheetHeight: 900, availableHeight: 844) == [.medium, .large]) @@ -29,7 +27,6 @@ /// `UIScreen.main` still reports the 678pt outer display. A sheet between the two doesn't fit. /// Unfolded in portrait the window is 951pt tall, so sheets between 678 and 951pt used to be /// shown at half height even though they fit. - @available(iOS 15.0, *) @Test("a sheet taller than the window but shorter than the main screen can be expanded") func sheetBetweenWindowAndMainScreenIsExpandable() { #expect(SurveyPresentationDetentsRepresentable.detents(forSheetHeight: 672, availableHeight: 669) == [.medium, .large]) diff --git a/PostHogTests/PostHogSurveyTranslationsTest.swift b/PostHogTests/PostHogSurveyTranslationsTest.swift index 45d31c753d..89b89094dc 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 @@ -566,9 +566,7 @@ @Test("changing the language person property re-translates the active survey") func languageChangeRetranslatesActiveSurvey() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(translatedSurvey(), language: nil) @@ -584,9 +582,7 @@ @Test("re-resolving the same language does not push an update") func sameLanguageIsNoop() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy let integration = try getSurveyIntegration(postHog) // Already showing the French translation @@ -613,9 +609,7 @@ @Test("delegate without updateSurvey keeps the rendered language") func delegateWithoutUpdateSurveyKeepsState() async throws { - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = NoLiveUpdateSurveysDelegate() + postHog.config.surveysConfig.surveysDelegate = NoLiveUpdateSurveysDelegate() let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(translatedSurvey(), language: nil) @@ -629,9 +623,7 @@ @Test("resetting person properties reverts the active survey language") func resetRevertsActiveSurveyLanguage() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(translatedSurvey(), language: nil) @@ -650,9 +642,7 @@ @Test("no active survey means no update is pushed") func noActiveSurveyIsNoop() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy _ = try getSurveyIntegration(postHog) postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) diff --git a/PostHogTests/PostHogSurveysTest.swift b/PostHogTests/PostHogSurveysTest.swift index 7eb79c8152..9668023bdc 100644 --- a/PostHogTests/PostHogSurveysTest.swift +++ b/PostHogTests/PostHogSurveysTest.swift @@ -5,2557 +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"]) - @available(iOS 15.0, *) - @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]) - @available(iOS 15.0, *) - @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/PostHogTracingHeadersTest.swift b/PostHogTests/PostHogTracingHeadersTest.swift index 82cfbbad93..f57bf1fcea 100644 --- a/PostHogTests/PostHogTracingHeadersTest.swift +++ b/PostHogTests/PostHogTracingHeadersTest.swift @@ -133,10 +133,6 @@ @Test("adds distinct and session tracing headers to listed hosts for async await URLSession APIs", arguments: asyncCases) func addsHeadersToListedHostsForAsyncAwaitURLSessionAPIs(_ testCase: AsyncTracingCase) async throws { - guard #available(iOS 15.0, *) else { - return - } - try await withTracingSut(tracingHeaders: [Self.primaryHost]) { sut in let capture = CapturedRequest() stubRequest(host: Self.primaryHost, capture: capture) @@ -151,10 +147,6 @@ @Test("adds distinct and session tracing headers to PostHog URLSession wrapper APIs", arguments: wrapperCases) func addsHeadersToPostHogURLSessionWrapperAPIs(_ testCase: WrapperTracingCase) async throws { - guard #available(iOS 15.0, *) else { - return - } - try await withTracingSut(tracingHeaders: [Self.primaryHost]) { sut in let capture = CapturedRequest() stubRequest(host: Self.primaryHost, capture: capture) @@ -313,7 +305,6 @@ } } - @available(iOS 15.0, *) private func invokeAsyncTracingCase(_ testCase: AsyncTracingCase) async throws { let url = try makeURL(host: Self.primaryHost) let request = URLRequest(url: url) @@ -342,7 +333,6 @@ } } - @available(iOS 15.0, *) private func invokeWrapperTracingCase(_ testCase: WrapperTracingCase) async throws { let url = try makeURL(host: Self.primaryHost) let request = URLRequest(url: url) @@ -365,7 +355,6 @@ } } - @available(iOS 15.0, *) private func consumeFirstByte(from bytes: URLSession.AsyncBytes) async throws { var iterator = bytes.makeAsyncIterator() _ = try await iterator.next() diff --git a/PostHogTests/PostHogViewControllerTraversalTest.swift b/PostHogTests/PostHogViewControllerTraversalTest.swift index 7e3ebd5f99..ac6411391f 100644 --- a/PostHogTests/PostHogViewControllerTraversalTest.swift +++ b/PostHogTests/PostHogViewControllerTraversalTest.swift @@ -451,7 +451,7 @@ let split: SplitViewController let navigation = UINavigationController(rootViewController: OneViewController()) var wrappedDetail: UIViewController? - if setup != "classic", #available(iOS 14.0, tvOS 14.0, *) { + if setup != "classic" { split = SplitViewController(style: .doubleColumn) split.setViewController(ThreeViewController(), for: .primary) if setup == "wrapped column" { diff --git a/PostHogTests/PostHogWebPTest.swift b/PostHogTests/PostHogWebPTest.swift index 96a6b9fe63..d297286d76 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 1b8d64d58c..d31728a473 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/SwiftUITapAutocaptureTests.swift b/PostHogTests/SwiftUITapAutocaptureTests.swift index 6c4cc53969..c193b2b7fe 100644 --- a/PostHogTests/SwiftUITapAutocaptureTests.swift +++ b/PostHogTests/SwiftUITapAutocaptureTests.swift @@ -25,7 +25,6 @@ @Test(arguments: [false, true], [false, true]) func independentCaptureOptions(legacy: Bool, swiftUI: Bool) throws { - guard #available(iOS 13.4, *) else { return } PostHogAutocaptureEventTracker.eventProcessor = nil let server = MockPostHogServer() server.start() @@ -153,7 +152,6 @@ } @Test func genuineSwiftUIHostingViewCapturesOnlyThroughTouchObserver() { - guard #available(iOS 13.4, *) else { return } let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) let controller = UIHostingController(rootView: Color.clear) window.rootViewController = controller @@ -412,7 +410,6 @@ } @Test func edgeReleaseOutsideMarkerDoesNotCapture() { - guard #available(iOS 13.4, *) else { return } let (window, host) = fixture() let marker = PostHogLabelTaggerView(label: "edge-target") marker.frame = CGRect(x: 0, y: 0, width: 20, height: 40) @@ -451,7 +448,6 @@ } @Test func pointerTapUsesTheSameCaptureRoute() { - guard #available(iOS 13.4, *) else { return } let (window, host) = fixture() host.accessibilityIdentifier = "pointer-target" let publisher = TapTestPublisher() @@ -530,7 +526,6 @@ } } - @available(iOS 13.4, *) private final class PointerTestTouch: UITouch { let target: UIView let targetWindow: UIWindow diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 6fb8735ba3..1cebef4df1 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest diff --git a/PostHogTests/UtilsTest.swift b/PostHogTests/UtilsTest.swift index 620842ab03..969def647b 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( diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index e68f2522b4..946179d2e5 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -9,27 +9,6 @@ PostHog | BoxedBeforeSendBlock.init(block:) | init | @objc(block:) init(block: @ PostHog | BoxedBeforeSendLogBlock | class | @objc final class BoxedBeforeSendLogBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock PostHog | BoxedBeforeSendLogBlock.block | property | @objc let block: PostHogBeforeSendLogBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock(py)block PostHog | BoxedBeforeSendLogBlock.init(block:) | init | @objc(block:) init(block: @escaping PostHogBeforeSendLogBlock) | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock(im)block: -PostHog | CompressionLevel | struct | struct CompressionLevel | s:7PostHog16CompressionLevelV -PostHog | CompressionLevel.bestCompression | type.property | static let bestCompression: CompressionLevel | s:7PostHog16CompressionLevelV04bestC0ACvpZ -PostHog | CompressionLevel.bestSpeed | type.property | static let bestSpeed: CompressionLevel | s:7PostHog16CompressionLevelV9bestSpeedACvpZ -PostHog | CompressionLevel.defaultCompression | type.property | static let defaultCompression: CompressionLevel | s:7PostHog16CompressionLevelV07defaultC0ACvpZ -PostHog | CompressionLevel.init(_:) | init | init(_ rawValue: Int32) | s:7PostHog16CompressionLevelVyACs5Int32Vcfc -PostHog | CompressionLevel.init(rawValue:) | init | init(rawValue: Int32) | s:7PostHog16CompressionLevelV8rawValueACs5Int32V_tcfc -PostHog | CompressionLevel.noCompression | type.property | static let noCompression: CompressionLevel | s:7PostHog16CompressionLevelV02noC0ACvpZ -PostHog | CompressionLevel.rawValue | property | let rawValue: Int32 | s:7PostHog16CompressionLevelV8rawValues5Int32Vvp -PostHog | Gzip | enum | enum Gzip | s:7PostHog4GzipO -PostHog | Gzip.maxWindowBits | type.property | static let maxWindowBits: Int32 | s:7PostHog4GzipO13maxWindowBitss5Int32VvpZ -PostHog | GzipError | struct | struct GzipError | s:7PostHog9GzipErrorV -PostHog | GzipError.Kind | enum | enum Kind | s:7PostHog9GzipErrorV4KindO -PostHog | GzipError.Kind.buffer | enum.case | case buffer | s:7PostHog9GzipErrorV4KindO6bufferyA2EmF -PostHog | GzipError.Kind.data | enum.case | case data | s:7PostHog9GzipErrorV4KindO4datayA2EmF -PostHog | GzipError.Kind.memory | enum.case | case memory | s:7PostHog9GzipErrorV4KindO6memoryyA2EmF -PostHog | GzipError.Kind.stream | enum.case | case stream | s:7PostHog9GzipErrorV4KindO6streamyA2EmF -PostHog | GzipError.Kind.unknown(code:) | enum.case | case unknown(code: Int) | s:7PostHog9GzipErrorV4KindO7unknownyAESi_tcAEmF -PostHog | GzipError.Kind.version | enum.case | case version | s:7PostHog9GzipErrorV4KindO7versionyA2EmF -PostHog | GzipError.kind | property | let kind: GzipError.Kind | s:7PostHog9GzipErrorV4kindAC4KindOvp -PostHog | GzipError.localizedDescription | property | var localizedDescription: String { get } | s:7PostHog9GzipErrorV20localizedDescriptionSSvp -PostHog | GzipError.message | property | let message: String | s:7PostHog9GzipErrorV7messageSSvp PostHog | OnPostHogSurveyClosed | typealias | typealias OnPostHogSurveyClosed = (PostHogDisplaySurvey) -> Void | s:7PostHog02OnaB12SurveyCloseda PostHog | OnPostHogSurveyResponse | typealias | typealias OnPostHogSurveyResponse = (PostHogDisplaySurvey, Int, PostHogSurveyResponse) -> PostHogNextSurveyQuestion? | s:7PostHog02OnaB14SurveyResponsea PostHog | OnPostHogSurveyShown | typealias | typealias OnPostHogSurveyShown = (PostHogDisplaySurvey) -> Void | s:7PostHog02OnaB11SurveyShowna @@ -51,10 +30,8 @@ PostHog | PostHogCompression.none | enum.case | case none | c:@M@PostHog@E@PostH PostHog | PostHogConfig | class | @objc(PostHogConfig) class PostHogConfig | c:@M@PostHog@objc(cs)PostHogConfig PostHog | PostHogConfig.PostHogDataMode | enum | @frozen @objc(PostHogDataMode) enum PostHogDataMode | s:7PostHog0aB6ConfigC0aB8DataModeO PostHog | PostHogConfig.PostHogDataMode.any | enum.case | case any | s:7PostHog0aB6ConfigC0aB8DataModeO3anyyA2EmF -PostHog | PostHogConfig.PostHogDataMode.cellular | enum.case | case cellular | s:7PostHog0aB6ConfigC0aB8DataModeO8cellularyA2EmF PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB6ConfigC0aB8DataModeO8rawValueAESgSi_tcfc PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF -PostHog | PostHogConfig.apiKey | property | @objc var apiKey: String { get } | c:@M@PostHog@objc(cs)PostHogConfig(py)apiKey PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents @@ -72,14 +49,11 @@ PostHog | PostHogConfig.disableGeoIp | property | @objc var disableGeoIp: Bool | PostHog | PostHogConfig.enableSwizzling | property | @objc var enableSwizzling: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)enableSwizzling PostHog | PostHogConfig.errorTrackingConfig | property | @objc let errorTrackingConfig: PostHogErrorTrackingConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)errorTrackingConfig PostHog | PostHogConfig.evaluationContexts | property | @objc var evaluationContexts: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)evaluationContexts -PostHog | PostHogConfig.evaluationEnvironments | property | @objc var evaluationEnvironments: [String]? { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)evaluationEnvironments PostHog | PostHogConfig.featureFlagRequestMaxRetries | property | @objc var featureFlagRequestMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)featureFlagRequestMaxRetries PostHog | PostHogConfig.flushAt | property | @objc var flushAt: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)flushAt PostHog | PostHogConfig.flushIntervalSeconds | property | @objc var flushIntervalSeconds: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)flushIntervalSeconds PostHog | PostHogConfig.getAnonymousId | property | @objc var getAnonymousId: (UUID) -> UUID | c:@M@PostHog@objc(cs)PostHogConfig(py)getAnonymousId PostHog | PostHogConfig.host | property | @objc let host: URL | c:@M@PostHog@objc(cs)PostHogConfig(py)host -PostHog | PostHogConfig.init(apiKey:) | init | @objc(apiKey:) convenience init(apiKey: String) | c:@M@PostHog@objc(cs)PostHogConfig(im)apiKey: -PostHog | PostHogConfig.init(apiKey:host:) | init | @objc(apiKey:host:) convenience init(apiKey: String, host: String = defaultHost) | c:@M@PostHog@objc(cs)PostHogConfig(im)apiKey:host: PostHog | PostHogConfig.init(projectToken:) | init | @objc(projectToken:) init(projectToken: String) | c:@M@PostHog@objc(cs)PostHogConfig(im)projectToken: PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:host:) init(projectToken: String, host: String = defaultHost) | c:@M@PostHog@objc(cs)PostHogConfig(im)projectToken:host: PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs @@ -90,28 +64,32 @@ PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHo PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken -PostHog | PostHogConfig.propertiesSanitizer | property | @objc var propertiesSanitizer: (any PostHogPropertiesSanitizer)? { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)propertiesSanitizer PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig -PostHog | PostHogConfig.remoteConfig | property | @objc var remoteConfig: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)remoteConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureFlagEvent: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sendFeatureFlagEvent PostHog | PostHogConfig.sessionReplay | property | @objc var sessionReplay: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplay PostHog | PostHogConfig.sessionReplayConfig | property | @objc let sessionReplayConfig: PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplayConfig PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend: -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFcd_tF -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties -PostHog | PostHogConfig.snapshotEndpoint | property | @objc var snapshotEndpoint: String | c:@M@PostHog@objc(cs)PostHogConfig(py)snapshotEndpoint -PostHog | PostHogConfig.storageManager | property | var storageManager: PostHogStorageManager? | s:7PostHog0aB6ConfigC14storageManagerAA0ab7StorageE0CSgvp PostHog | PostHogConfig.surveys | property | @objc var surveys: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveys PostHog | PostHogConfig.surveysConfig | property | @objc var surveysConfig: PostHogSurveysConfig { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveysConfig PostHog | PostHogConfig.tracingHeaders | property | @objc var tracingHeaders: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)tracingHeaders PostHog | PostHogConfig.urlSessionConfiguration | property | @objc var urlSessionConfiguration: URLSessionConfiguration? | c:@M@PostHog@objc(cs)PostHogConfig(py)urlSessionConfiguration +PostHog | PostHogConsoleLogEntry | class | @objc class PostHogConsoleLogEntry | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry +PostHog | PostHogConsoleLogEntry.init(level:message:) | init | @objc init(level: PostHogConsoleLogLevel, message: String) | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry(im)initWithLevel:message: +PostHog | PostHogConsoleLogEntry.level | property | @objc let level: PostHogConsoleLogLevel | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry(py)level +PostHog | PostHogConsoleLogEntry.message | property | @objc let message: String | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry(py)message +PostHog | PostHogConsoleLogLevel | enum | @objc enum PostHogConsoleLogLevel | c:@M@PostHog@E@PostHogConsoleLogLevel +PostHog | PostHogConsoleLogLevel.error | enum.case | case error | c:@M@PostHog@E@PostHogConsoleLogLevel@PostHogConsoleLogLevelError +PostHog | PostHogConsoleLogLevel.info | enum.case | case info | c:@M@PostHog@E@PostHogConsoleLogLevel@PostHogConsoleLogLevelInfo +PostHog | PostHogConsoleLogLevel.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB15ConsoleLogLevelO8rawValueACSgSi_tcfc +PostHog | PostHogConsoleLogLevel.warn | enum.case | case warn | c:@M@PostHog@E@PostHogConsoleLogLevel@PostHogConsoleLogLevelWarn PostHog | PostHogDataMode | enum | @frozen enum PostHogDataMode | c:@M@PostHog@E@PostHogDataMode PostHog | PostHogDataMode.any | enum.case | case any | c:@M@PostHog@E@PostHogDataMode@PostHogDataModeAny -PostHog | PostHogDataMode.cellular | enum.case | case cellular | c:@M@PostHog@E@PostHogDataMode@PostHogDataModeCellular PostHog | PostHogDataMode.init(rawValue:) | init | init?(rawValue: Int) | s:So15PostHogDataModeV8rawValueABSgSi_tcfc PostHog | PostHogDataMode.wifi | enum.case | case wifi | c:@M@PostHog@E@PostHogDataMode@PostHogDataModeWifi PostHog | PostHogDisplayChoiceQuestion | class | @objc class PostHogDisplayChoiceQuestion | c:@M@PostHog@objc(cs)PostHogDisplayChoiceQuestion @@ -207,15 +185,6 @@ PostHog | PostHogFeatureFlagsLoaded.flags | property | @objc let flags: [String] PostHog | PostHogFeatureFlagsLoaded.variants | property | @objc let variants: [String : Any] | c:@M@PostHog@objc(cs)PostHogFeatureFlagsLoaded(py)variants PostHog | PostHogFeatureFlagsSubscription | class | @objc(PostHogFeatureFlagsSubscription) final class PostHogFeatureFlagsSubscription | c:@M@PostHog@objc(cs)PostHogFeatureFlagsSubscription PostHog | PostHogFeatureFlagsSubscription.unsubscribe() | method | @objc func unsubscribe() | c:@M@PostHog@objc(cs)PostHogFeatureFlagsSubscription(im)unsubscribe -PostHog | PostHogLogEntry | class | @objc class PostHogLogEntry | c:@M@PostHog@objc(cs)PostHogLogEntry -PostHog | PostHogLogEntry.init(level:message:) | init | @objc init(level: PostHogLogLevel, message: String) | c:@M@PostHog@objc(cs)PostHogLogEntry(im)initWithLevel:message: -PostHog | PostHogLogEntry.level | property | @objc let level: PostHogLogLevel | c:@M@PostHog@objc(cs)PostHogLogEntry(py)level -PostHog | PostHogLogEntry.message | property | @objc let message: String | c:@M@PostHog@objc(cs)PostHogLogEntry(py)message -PostHog | PostHogLogLevel | enum | @objc enum PostHogLogLevel | c:@M@PostHog@E@PostHogLogLevel -PostHog | PostHogLogLevel.error | enum.case | case error | c:@M@PostHog@E@PostHogLogLevel@PostHogLogLevelError -PostHog | PostHogLogLevel.info | enum.case | case info | c:@M@PostHog@E@PostHogLogLevel@PostHogLogLevelInfo -PostHog | PostHogLogLevel.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB8LogLevelO8rawValueACSgSi_tcfc -PostHog | PostHogLogLevel.warn | enum.case | case warn | c:@M@PostHog@E@PostHogLogLevel@PostHogLogLevelWarn PostHog | PostHogLogRecord | class | @objc final class PostHogLogRecord | c:@M@PostHog@objc(cs)PostHogLogRecord PostHog | PostHogLogRecord.attributes | property | @objc var attributes: [String : Any] | c:@M@PostHog@objc(cs)PostHogLogRecord(py)attributes PostHog | PostHogLogRecord.body | property | @objc var body: String | c:@M@PostHog@objc(cs)PostHogLogRecord(py)body @@ -261,8 +230,8 @@ PostHog | PostHogLogsConfig.resourceAttributes | property | @objc var resourceAt PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceName PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend: -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFcd_tF -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF PostHog | PostHogNextSurveyQuestion | class | @objc class PostHogNextSurveyQuestion | c:@M@PostHog@objc(cs)PostHogNextSurveyQuestion PostHog | PostHogNextSurveyQuestion.isSurveyCompleted | property | let isSurveyCompleted: Bool | s:7PostHog0aB18NextSurveyQuestionC02isD9CompletedSbvp PostHog | PostHogNextSurveyQuestion.questionIndex | property | let questionIndex: Int | s:7PostHog0aB18NextSurveyQuestionC13questionIndexSivp @@ -271,8 +240,6 @@ PostHog | PostHogPersonProfiles.always | enum.case | case always | c:@M@PostHog@ PostHog | PostHogPersonProfiles.identifiedOnly | enum.case | case identifiedOnly | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesIdentifiedOnly PostHog | PostHogPersonProfiles.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB14PersonProfilesO8rawValueACSgSi_tcfc PostHog | PostHogPersonProfiles.never | enum.case | case never | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesNever -PostHog | PostHogPropertiesSanitizer | protocol | @objc(PostHogPropertiesSanitizer) protocol PostHogPropertiesSanitizer | c:@M@PostHog@objc(pl)PostHogPropertiesSanitizer -PostHog | PostHogPropertiesSanitizer.sanitize(_:) | method | @objc func sanitize(_ properties: [String : Any]) -> [String : Any] | c:@M@PostHog@objc(pl)PostHogPropertiesSanitizer(im)sanitize: PostHog | PostHogRageClickConfig | class | @objc(PostHogRageClickConfig) class PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogRageClickConfig PostHog | PostHogRageClickConfig.enabled | property | @objc var enabled: Bool | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)enabled PostHog | PostHogRageClickConfig.minimumTapCount | property | @objc var minimumTapCount: Int | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)minimumTapCount @@ -319,7 +286,6 @@ PostHog | PostHogSDK.getDeviceId() | method | @objc func getDeviceId() -> String PostHog | PostHogSDK.getDistinctId() | method | @objc func getDistinctId() -> String | c:@M@PostHog@objc(cs)PostHogSDK(im)getDistinctId PostHog | PostHogSDK.getFeatureFlag(_:) | method | @objc func getFeatureFlag(_ key: String) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlag: PostHog | PostHogSDK.getFeatureFlag(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagWithKey:sendFeatureFlagEvent:) func getFeatureFlag(_ key: String, sendFeatureFlagEvent: Bool) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagWithKey:sendFeatureFlagEvent: -PostHog | PostHogSDK.getFeatureFlagPayload(_:) | method | @objc func getFeatureFlagPayload(_ key: String) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagPayload: PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFlagResult(_ key: String) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResult: PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId @@ -343,7 +309,7 @@ PostHog | PostHogSDK.register(_:) | method | @objc(registerProperties:) func reg PostHog | PostHogSDK.registerPushNotificationToken(_:) | method | @objc func registerPushNotificationToken(_ deviceToken: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken: PostHog | PostHogSDK.registerPushNotificationToken(_:appId:) | method | @objc func registerPushNotificationToken(_ deviceToken: String, appId: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken:appId: PostHog | PostHogSDK.reloadFeatureFlags() | method | @objc func reloadFeatureFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlags -PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping () -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: +PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: PostHog | PostHogSDK.reset() | method | @objc func reset() | c:@M@PostHog@objc(cs)PostHogSDK(im)reset PostHog | PostHogSDK.resetGroupPropertiesForFlags() | method | @objc func resetGroupPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlags PostHog | PostHogSDK.resetGroupPropertiesForFlags(_:) | method | @objc(resetGroupPropertiesForFlagsWithGroupType:) func resetGroupPropertiesForFlags(_ groupType: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlagsWithGroupType: @@ -368,37 +334,20 @@ PostHog | PostHogSDK.stopSessionRecording() | method | @objc func stopSessionRec PostHog | PostHogSDK.unregister(_:) | method | @objc(unregisterProperties:) func unregister(_ key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)unregisterProperties: PostHog | PostHogSDK.unregisterPushNotificationToken() | method | @objc func unregisterPushNotificationToken() | c:@M@PostHog@objc(cs)PostHogSDK(im)unregisterPushNotificationToken PostHog | PostHogSDK.with(_:) | type.method | @objc static func with(_ config: PostHogConfig) -> PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cm)with: -PostHog | PostHogSessionManager | class | @objc class PostHogSessionManager | c:@M@PostHog@objc(cs)PostHogSessionManager -PostHog | PostHogSessionManager.setSessionId(_:) | method | @objc func setSessionId(_ sessionId: String) | c:@M@PostHog@objc(cs)PostHogSessionManager(im)setSessionId: -PostHog | PostHogSessionManager.shared | type.property | @objc static var shared: PostHogSessionManager { get } | c:@M@PostHog@objc(cs)PostHogSessionManager(cpy)shared PostHog | PostHogSessionReplayConfig | class | @objc(PostHogSessionReplayConfig) class PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig PostHog | PostHogSessionReplayConfig.captureLogs | property | @objc var captureLogs: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureLogs PostHog | PostHogSessionReplayConfig.captureLogsConfig | property | @objc var captureLogsConfig: PostHogSessionReplayConsoleLogConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureLogsConfig PostHog | PostHogSessionReplayConfig.captureNetworkTelemetry | property | @objc var captureNetworkTelemetry: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureNetworkTelemetry PostHog | PostHogSessionReplayConfig.captureTouches | property | @objc var captureTouches: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureTouches -PostHog | PostHogSessionReplayConfig.debouncerDelay | property | @objc var debouncerDelay: TimeInterval { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)debouncerDelay PostHog | PostHogSessionReplayConfig.maskAllImages | property | @objc var maskAllImages: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskAllImages PostHog | PostHogSessionReplayConfig.maskAllSandboxedViews | property | @objc var maskAllSandboxedViews: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskAllSandboxedViews PostHog | PostHogSessionReplayConfig.maskAllTextInputs | property | @objc var maskAllTextInputs: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskAllTextInputs -PostHog | PostHogSessionReplayConfig.maskPhotoLibraryImages | property | @objc var maskPhotoLibraryImages: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskPhotoLibraryImages PostHog | PostHogSessionReplayConfig.sampleRate | property | @objc var sampleRate: NSNumber? { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)sampleRate -PostHog | PostHogSessionReplayConfig.screenshotMode | property | @objc var screenshotMode: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotMode -PostHog | PostHogSessionReplayConfig.screenshotModeBackgroundCapture | property | @objc var screenshotModeBackgroundCapture: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotModeBackgroundCapture +PostHog | PostHogSessionReplayConfig.screenshotMode | property | @objc var screenshotMode: Bool { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotMode PostHog | PostHogSessionReplayConfig.throttleDelay | property | @objc var throttleDelay: TimeInterval | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)throttleDelay PostHog | PostHogSessionReplayConsoleLogConfig | class | @objc class PostHogSessionReplayConsoleLogConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig -PostHog | PostHogSessionReplayConsoleLogConfig.logSanitizer | property | @objc var logSanitizer: (String) -> PostHogLogEntry? | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)logSanitizer -PostHog | PostHogSessionReplayConsoleLogConfig.minLogLevel | property | @objc var minLogLevel: PostHogLogLevel | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)minLogLevel -PostHog | PostHogStorageManager | class | class PostHogStorageManager | s:7PostHog0aB14StorageManagerC -PostHog | PostHogStorageManager.getAnonymousId() | method | func getAnonymousId() -> String | s:7PostHog0aB14StorageManagerC14getAnonymousIdSSyF -PostHog | PostHogStorageManager.getDeviceId() | method | func getDeviceId() -> String | s:7PostHog0aB14StorageManagerC11getDeviceIdSSyF -PostHog | PostHogStorageManager.getDistinctId() | method | func getDistinctId() -> String | s:7PostHog0aB14StorageManagerC13getDistinctIdSSyF -PostHog | PostHogStorageManager.isIdentified() | method | func isIdentified() -> Bool | s:7PostHog0aB14StorageManagerC12isIdentifiedSbyF -PostHog | PostHogStorageManager.isPersonProcessing() | method | func isPersonProcessing() -> Bool | s:7PostHog0aB14StorageManagerC18isPersonProcessingSbyF -PostHog | PostHogStorageManager.reset(keepAnonymousId:_:) | method | func reset(keepAnonymousId: Bool = false, _ resetStorage: Bool = false) | s:7PostHog0aB14StorageManagerC5reset15keepAnonymousId_ySb_SbtF -PostHog | PostHogStorageManager.setAnonymousId(_:) | method | func setAnonymousId(_ id: String) | s:7PostHog0aB14StorageManagerC14setAnonymousIdyySSF -PostHog | PostHogStorageManager.setDistinctId(_:) | method | func setDistinctId(_ id: String) | s:7PostHog0aB14StorageManagerC13setDistinctIdyySSF -PostHog | PostHogStorageManager.setIdentified(_:) | method | func setIdentified(_ isIdentified: Bool) | s:7PostHog0aB14StorageManagerC13setIdentifiedyySbF -PostHog | PostHogStorageManager.setPersonProcessing(_:) | method | func setPersonProcessing(_ enable: Bool) | s:7PostHog0aB14StorageManagerC19setPersonProcessingyySbF +PostHog | PostHogSessionReplayConsoleLogConfig.logSanitizer | property | @objc var logSanitizer: (String) -> PostHogConsoleLogEntry? | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)logSanitizer +PostHog | PostHogSessionReplayConsoleLogConfig.minLogLevel | property | @objc var minLogLevel: PostHogConsoleLogLevel | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)minLogLevel PostHog | PostHogSurveyResponse | class | @objc @objcMembers class PostHogSurveyResponse | c:@M@PostHog@objc(cs)PostHogSurveyResponse PostHog | PostHogSurveyResponse.link(_:) | type.method | static func link(_ clicked: Bool) -> PostHogSurveyResponse | c:@M@PostHog@objc(cs)PostHogSurveyResponse(cm)link: PostHog | PostHogSurveyResponse.linkClicked | property | let linkClicked: Bool? | s:7PostHog0aB14SurveyResponseC11linkClickedSbSgvp @@ -425,14 +374,6 @@ PostHog | PostHogSurveysDelegate.cleanupSurveys() | method | @objc func cleanupS PostHog | PostHogSurveysDelegate.renderSurvey(_:onSurveyShown:onSurveyResponse:onSurveyClosed:) | method | @objc func renderSurvey(_ survey: PostHogDisplaySurvey, onSurveyShown: @escaping OnPostHogSurveyShown, onSurveyResponse: @escaping OnPostHogSurveyResponse, onSurveyClosed: @escaping OnPostHogSurveyClosed) | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)renderSurvey:onSurveyShown:onSurveyResponse:onSurveyClosed: PostHog | PostHogSurveysDelegate.supportsSurveyResume | property | @objc optional var supportsSurveyResume: Bool { get } | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(py)supportsSurveyResume PostHog | PostHogSurveysDelegate.updateSurvey(_:) | method | @objc optional func updateSurvey(_ survey: PostHogDisplaySurvey) | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)updateSurvey: -PostHog | ReachabilityError | enum | enum ReachabilityError | s:7PostHog17ReachabilityErrorO -PostHog | ReachabilityError.failedToCreateWithAddress(_:_:) | enum.case | case failedToCreateWithAddress(sockaddr, Int32) | s:7PostHog17ReachabilityErrorO25failedToCreateWithAddressyACSo8sockaddrV_s5Int32VtcACmF -PostHog | ReachabilityError.failedToCreateWithHostname(_:_:) | enum.case | case failedToCreateWithHostname(String, Int32) | s:7PostHog17ReachabilityErrorO26failedToCreateWithHostnameyACSS_s5Int32VtcACmF -PostHog | ReachabilityError.unableToGetFlags(_:) | enum.case | case unableToGetFlags(Int32) | s:7PostHog17ReachabilityErrorO16unableToGetFlagsyACs5Int32VcACmF -PostHog | ReachabilityError.unableToSetCallback(_:) | enum.case | case unableToSetCallback(Int32) | s:7PostHog17ReachabilityErrorO19unableToSetCallbackyACs5Int32VcACmF -PostHog | ReachabilityError.unableToSetDispatchQueue(_:) | enum.case | case unableToSetDispatchQueue(Int32) | s:7PostHog17ReachabilityErrorO24unableToSetDispatchQueueyACs5Int32VcACmF -PostHog | UIColor.hexDescription(_:) | method | func hexDescription(_ includeAlpha: Bool = false) -> String | s:So7UIColorC7PostHogE14hexDescriptionySSSbF -PostHog | UIColor.init(hex:) | init | convenience init(hex: String?) | s:So7UIColorC7PostHogE3hexABSSSg_tcfc PostHog | UIView.postHogLabel | property | @MainActor var postHogLabel: String? { get set } | s:So6UIViewC7PostHogE04postC5LabelSSSgvp PostHog | URLSession.postHogData(for:delegate:postHog:) | method | func postHogData(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data3for8delegate0dC010Foundation0E0V_So13NSURLResponseCtAH10URLRequestV_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF PostHog | URLSession.postHogData(for:postHog:) | method | func postHogData(for request: URLRequest, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data3for0dC010Foundation0E0V_So13NSURLResponseCtAG10URLRequestV_AC0bC3SDKCSgtYaKF @@ -459,12 +400,3 @@ PostHog | View.postHogScreenView(_:_:postHog:) | method | @MainActor func postHo | s:7SwiftUI4ViewP7PostHogE04poste6ScreenC0__0fE0QrSSSg_SDySSypGSgAD0dE3SDKCSgtF PostHog | View.postHogViewSeen(_:_:postHog:) | method | @MainActor func postHogViewSeen(_ event: String, _ properties: [String : Any]? = nil, postHog: PostHogSDK? = nil) -> some View | s:7SwiftUI4ViewP7PostHogE04posteC4Seen__0fE0QrSS_SDySSypGSgAD0dE3SDKCSgtF -PostHog | dateToMillis(_:) | func | func dateToMillis(_ date: Date) -> Int64 | s:7PostHog12dateToMillisys5Int64V10Foundation4DateVF -PostHog | deleteSafely(_:) | func | func deleteSafely(_ file: URL) | s:7PostHog12deleteSafelyyy10Foundation3URLVF -PostHog | imageToBase64(_:_:) | func | func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? | s:7PostHog13imageToBase64ySSSgSo7UIImageC_14CoreFoundation7CGFloatVtF -PostHog | postHogSdkName | var | var postHogSdkName: String | s:7PostHog04postB7SdkNameSSvp -PostHog | postHogVersion | var | var postHogVersion: String | s:7PostHog04postB7VersionSSvp -PostHog | postHogiOSSdkName | var | let postHogiOSSdkName: String | s:7PostHog17postHogiOSSdkNameSSvp -PostHog | sanitizeDictionary(_:) | func | func sanitizeDictionary(_ dict: [String : Any]?) -> [String : Any]? | s:7PostHog18sanitizeDictionaryySDySSypGSgADF -PostHog | toISO8601Date(_:) | func | func toISO8601Date(_ date: String) -> Date? | s:7PostHog13toISO8601Datey10Foundation0E0VSgSSF -PostHog | toISO8601String(_:) | func | func toISO8601String(_ date: Date) -> String | s:7PostHog15toISO8601StringySS10Foundation4DateVF diff --git a/scripts/check-public-api.sh b/scripts/check-public-api.sh index 89b1d74fb1..e32c17088c 100755 --- a/scripts/check-public-api.sh +++ b/scripts/check-public-api.sh @@ -52,7 +52,7 @@ fi xcrun swift-symbolgraph-extract \ -module-name PostHog \ - -target arm64-apple-ios13.0 \ + -target arm64-apple-ios15.0 \ -sdk "$(xcrun --sdk iphoneos --show-sdk-path)" \ -I "$PRODUCTS_DIR" \ -F "$PRODUCTS_DIR" \ diff --git a/scripts/downgrade-compatibility-smoke/Package.swift b/scripts/downgrade-compatibility-smoke/Package.swift index dc6d46841c..84b66b1f3f 100644 --- a/scripts/downgrade-compatibility-smoke/Package.swift +++ b/scripts/downgrade-compatibility-smoke/Package.swift @@ -14,7 +14,7 @@ let currentWriterSwiftSettings: [SwiftSetting] = environment["POSTHOG_DOWNGRADE_ let package = Package( name: "DowngradeCompatibilitySmoke", - platforms: [.macOS(.v10_15)], + platforms: [.macOS(.v11)], products: [ .executable(name: "DowngradeCompatibilitySmoke", targets: ["DowngradeCompatibilitySmoke"]), ], diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index 30b4051976..343bb83c7f 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -113,7 +113,7 @@ Reset the SDK and adapter state. 1. **macOS environment** (local Mac or GitHub Actions `macos-latest`) 2. **Docker Desktop** installed and running -3. **Swift 5.9+** installed +3. **Swift 6.2+** installed ### Steps diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index b74766ad1a..adfdea49b8 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -1,5 +1,5 @@ import Foundation -import PostHog +@_spi(PostHogInternal) import PostHog import Vapor // Redirect all SDK on-disk storage into a private sandbox this adapter fully owns, so a @@ -50,7 +50,7 @@ app.middleware.use(RouteLoggingMiddleware()) // Health endpoint app.get("health") { req async throws -> Response in let health: [String: Any] = [ - "sdk_name": postHogiOSSdkName, + "sdk_name": postHogSdkName, "sdk_version": postHogVersion, "adapter_version": "1.0.0", // Declares which test suites apply. The iOS SDK posts events to /batch @@ -126,9 +126,7 @@ app.post("init") { req async throws -> Response in #if os(iOS) config.sessionReplay = false - if #available(iOS 15.0, *) { - config.surveys = false - } + config.surveys = false #endif // Configure custom URLSession with our RequestInterceptor diff --git a/sdk_compliance_adapter/run_tests.sh b/sdk_compliance_adapter/run_tests.sh index 5a245aa6d2..fde1667fe2 100755 --- a/sdk_compliance_adapter/run_tests.sh +++ b/sdk_compliance_adapter/run_tests.sh @@ -10,7 +10,7 @@ echo "" # Check prerequisites if ! command -v swift &> /dev/null; then - echo "❌ Error: Swift not found. Please install Swift 5.9+" + echo "❌ Error: Swift not found. Please install Swift 6.2+" exit 1 fi diff --git a/test-audit/replay.md b/test-audit/replay.md index d4b230ebcd..cdbf18c236 100644 --- a/test-audit/replay.md +++ b/test-audit/replay.md @@ -383,14 +383,6 @@ Production owner: presentation-aware mask collection. The harness requires visib | 450 | `emptyArraysStill` | R — legitimate no-mask geometry settles. | | 457 | `driftInflationCoversSweepInAfterOrder` | R — swept coverage and owner ordering; catches wrong pairing/under-inflation. | | 486 | `driftInflationCoversTrailBehindBefore` | R — displayed-pixel lag requires trailing inflation. | -| 502 | `sweptRectsCoversBothSamplesInAfterOrder` | R — union and owner ordering independently specified. | -| 526 | `sweptRectsNilBefore` | R — missing sample cannot manufacture a safe union. | -| 533 | `sweptRectsNilAfter` | R — same later-sample guard. | -| 540 | `sweptRectsCountMismatch` | R — newly appearing owner remains covered. | -| 558 | `sweptRectsDuplicateOwnerInBefore` | R — ambiguous duplicate pairing returns nil. | -| 573 | `sweptRectsDuplicateOwnerInAfter` | R — symmetric ambiguity guard. | -| 588 | `sweptRectsDisjointOwnerSet` | R — recycled owners cover both samples instead of losing disappeared content. | -| 603 | `sweptRectsStationaryOwnerUnchanged` | R — stationary union does not distort mask. | ### 5. `PostHogMaskSnapshotTest.swift` @@ -883,10 +875,10 @@ These protect separate privacy and availability risks; no consolidation proposed 1. Equal image hashes, no pending metadata → skip. 2. Different hashes → send. 3. Equal hashes with pending snapshot data → send. -4. Nil image hash/wireframe mode → send. +4. Nil image hash → send. 5. No previous hash → send. -Credible regressions: duplicate traffic, lost metadata, wireframe suppression, or missing first frame. +Credible regressions: duplicate traffic, lost metadata, or missing first frame. ### 21. `PostHogSDKPersonProfilesTest.swift`