From 0e9df4b0efaca0886c707abd2728e61eb7031c01 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 4 Jun 2026 20:34:31 -0400 Subject: [PATCH 1/9] ci: run the iOS test target on a simulator and fix the rot it surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS test target (#if os(iOS)) was never run in CI: `make test` runs `swift test` on macOS, which compiles out every `#if os(iOS)` block, so the replay, autocapture, tracing, crash-reporting and surveys suites never ran and had silently rotted. Add a `test-ios-simulator` job that runs `make testOniOSSimulator` (now picks an available simulator dynamically) and fix everything that running the full suite surfaced. No SDK source changes. - TestPostHog: define the missing `testAPIKey`... then realized two tests only used the deprecated PostHogConfig(apiKey:) init incidentally (they test the tracingHeaders default / are a tracing makeSut helper, not the deprecated API). Migrated both to projectToken and removed testAPIKey. The deliberate init(apiKey:) regression tests in PostHogConfigTest stay (they use projectToken). - PostHogCrashReportProcessor tests now actually run: the test target takes its own direct dependency on the vendored PHPLCrashReporter module (Package.swift for SPM; SWIFT_INCLUDE_PATHS on the xcodeproj test target so the modulemap resolves) and imports it directly, since the SDK imports it @_implementationOnly and @testable import PostHog doesn't re-export it. 13 tests, previously a no-op TODO that compiled out. - project.pbxproj: add fixture_survey_conditions_event_property_filters.json to the PostHogTests resources (on disk but not bundled, so Bundle.test couldn't find it under xcodebuild -> fatal crash; it passed under SPM). - PostHogSDKTest feature-flag-event tests: deflake on slow CI. waitFlagsRequest only proves the /flags request arrived, not that the SDK stored the response, so the immediate flag asserts could race ahead (read nil flags, or an empty request_id). New waitForFeatureFlagsLoaded polls remoteConfig.lastRequestId, which is set atomically with the flags + v4 metadata once the response lands. - Nimble polling timeout + queue async assertions: shared CI runners run several times slower than local (one job was ~6x: 174s for 166 tests vs ~28s), starving background work so the queue cap-halving / 413-retry toEventually assertions time out. A QuickConfiguration raises PollingDefaults.timeout to 30s for the whole suite (toEventually still returns early on pass), PostHogQueueTest's post-getBatchedEvents depth check polls instead of asserting synchronously, and a too-tight explicit 5s override was dropped to inherit the global ceiling. - PostHogQueueTest "flush respecting flushAt": deflake a real async race. flush() takes the batch asynchronously via peek(), so adding a third event right after the one that crosses flushAt could race into the in-flight peek() and join the batch (count 3 / depth 0 instead of 2 / 1). Drain the flush before adding more. - ApplicationViewLayoutPublisherTest: poll for the async effect instead of a fixed sleep for the should-fire assertions (a fixed sleep can lose to a slow runner); keep a small settle for the throttle-suppression checks, which must let each async block read the mocked clock before the clock advances. - Wipe persisted state before every Quick test. storage.reset() deliberately keeps the on-disk event queue (events are disk-backed from add()), so a prior test's unsent event leaks into the next test's batch and inflates counts (e.g. opt-out expected 0 got 1). The global QuickConfiguration.beforeEach now deletes the app-support dir so each test starts clean. - Close leaked SDK instances. Tests create a PostHogSDK per case but many never call close(), so the SDK's queues, timers, URLSession, reachability notifier and integration observers stay live; across ~40 instances per run they pile up and starve the background thread pool, stalling async work (a flag load timed out at 30s on a loaded CI runner, and the process hung past teardown). PostHogSDKTest tracks every getSut() SDK and closes them in afterEach; PostHogFeatureFlagsTest does the same via its BaseTestClass deinit. close() is idempotent. - Reset the global `now` clock before every test. Several suites mock `now` to a fixed date and never restore it (ApplicationViewLayoutPublisherTest, PostHogMulticastCallbackTest, some PostHogSDKTest cases); a leaked fixed clock makes the timestamp-keyed event queue collide records so batches are lost — surfacing as order-dependent "requests never arrived" / flag-not-loaded failures that retries can't fix (same process keeps the leak). A QuickConfiguration.beforeEach resets it for every Quick test; the Swift Testing leakers restore it in a defer. - PostHogRemoteConfigTest / PostHogSamplingTest: migrate the session-replay tests to the whole-config model (read recording config from .remoteConfig, not the removed .sessionReplay slice; drive it via reloadRemoteConfig; expect the /config endpoint /s/). Clear .remoteConfig in setup since it survives reset(). - MockPostHogServer: /config now emits sessionRecording.linkedFlag (parity /flags). - PostHogSessionManagerTest.ReactNativeTests: was a struct that set the global postHogSdkName = "posthog-react-native" and never restored it, leaking React Native mode into every later serialized suite and no-opping their session managers (no session) — which broke the session-dependent replay event-trigger and tracing-header tests. Made it a class that restores postHogSdkName / now / the lifecycle publisher in deinit. - PostHogTracingHeadersTest: scope the backgrounded-lifecycle mock to the one session-ended test (it was injected for the whole suite, breaking the others). - PostHogSessionReplayEventTriggersTest: seed recording config in .remoteConfig and reset the replay integration's static install flag so each test installs fresh. - testOniOSSimulator: -retry-tests-on-failure -test-iterations 3. After the leak/contamination fixes a small tail remains: autocapture debounce/flush tests assert real-time windows that can't be made deterministic and occasionally slip on slow CI runners. Rerun a failed test up to 3x so transient misses don't fail the job; a broken test fails all 3 and stays red. (The earlier retry-induced post-test hang was the resource leak above — fixed — not the retries.) Full suite passes locally: 564 tests under `swift test` (macOS), 557 + 161 on the simulator. --- .github/workflows/test.yml | 13 +++ Makefile | 9 +- Package.swift | 3 + PostHog.xcodeproj/project.pbxproj | 23 +++-- .../ApplicationViewLayoutPublisherTest.swift | 31 +++++-- PostHogTests/PostHogConfigTest.swift | 2 +- .../PostHogCrashReportProcessorTest.swift | 6 +- PostHogTests/PostHogFeatureFlagsTest.swift | 55 ++++++----- .../PostHogMulticastCallbackTest.swift | 2 + PostHogTests/PostHogQueueTest.swift | 14 +-- PostHogTests/PostHogRemoteConfigTest.swift | 93 ++++++++++++------- PostHogTests/PostHogSDKTest.swift | 29 ++++-- PostHogTests/PostHogSamplingTest.swift | 17 ++-- PostHogTests/PostHogSessionManagerTest.swift | 10 +- ...ostHogSessionReplayEventTriggersTest.swift | 17 ++++ PostHogTests/PostHogTracingHeadersTest.swift | 16 +++- .../TestUtils/MockPostHogServer.swift | 9 ++ PostHogTests/TestUtils/TestPostHog.swift | 30 +++++- 18 files changed, 272 insertions(+), 107 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 429a458709..55bd369380 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,19 @@ jobs: - name: Test SDK run: make test + # `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. + test-ios-simulator: + runs-on: macos-15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: latest-stable + - name: Test SDK on iOS Simulator + run: make testOniOSSimulator + downgrade-compatibility: runs-on: macos-15 strategy: diff --git a/Makefile b/Makefile index 0312d7fce1..dedff13271 100644 --- a/Makefile +++ b/Makefile @@ -73,10 +73,15 @@ swiftLint: swiftFormat: swiftformat . --swiftversion 5.3 -# use -test-iterations 10 if you want to run the tests multiple times # use -only-testing:PostHogTests/PostHogQueueTest to run only a specific test +# -retry-tests-on-failure -test-iterations 3: a few tests assert real-time behaviour (autocapture +# debounce/flush windows) that can't be made deterministic; on slow, load-variable CI runners those +# windows occasionally slip. Rerun a *failed* test up to 3 times so a transient miss doesn't fail the +# job — a genuinely broken test fails all 3 and stays red. testOniOSSimulator: - set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' | xcpretty + @device="$$(xcrun simctl list devices available | grep -E '^[[:space:]]*iPhone' | head -1 | sed -E 's/^[[:space:]]*//; s/ \(.*//')"; \ + echo "Testing on simulator: $$device"; \ + set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination "platform=iOS Simulator,name=$$device" -retry-tests-on-failure -test-iterations 3 | xcpretty testOnMacSimulator: set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination 'platform=macOS' | xcpretty diff --git a/Package.swift b/Package.swift index dfa258d57b..3f971610cd 100644 --- a/Package.swift +++ b/Package.swift @@ -87,6 +87,9 @@ let package = Package( "Nimble", "OHHTTPStubs", .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), + // SDK imports this @_implementationOnly, so @testable doesn't re-export it; + // the crash-report processor tests need it directly to build a PHPLCrashReport. + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .tvOS])), ], path: "PostHogTests", resources: [ diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index fe6e6bbb6d..80473dc5de 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -54,6 +54,8 @@ 4B3E620C85BFCE7C9F3800D2 /* PLCrashReportMachExceptionInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 9DBE5F51C6078BEF346BFDBB /* PLCrashReportMachExceptionInfo.m */; }; 4C2DC69C9F10ED07CFC374C1 /* PLCrashAsyncThread_x86.c in Sources */ = {isa = PBXBuildFile; fileRef = 351BB1903238AF5443032040 /* PLCrashAsyncThread_x86.c */; }; 5BAE9162D28D34E0FECB29B4 /* PLCrashSysctl.c in Sources */ = {isa = PBXBuildFile; fileRef = 1A8704E270B2737239AB8A64 /* PLCrashSysctl.c */; }; + 5CA51F0E2B5C9A0100000001 /* PostHogScreenNameTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */; }; + 5CA51F0E2B5C9A0100000003 /* PostHogScreenNameSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA51F0E2B5C9A0100000004 /* PostHogScreenNameSanitizer.swift */; }; 5DEA35A160AF4E7969C4FF52 /* PLCrashProcessInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 97A462C12E6F54A281DE5023 /* PLCrashProcessInfo.m */; }; 653AD77A6406C108F6C3F659 /* PLCrashAsyncMachOString.c in Sources */ = {isa = PBXBuildFile; fileRef = 69F7521A6F2A46D4AC6356F5 /* PLCrashAsyncMachOString.c */; }; 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690B2DF22C205B5600AE3B45 /* TimeBasedEpochGenerator.swift */; }; @@ -72,7 +74,6 @@ 690FF0EF2AEFF23D00A0B06B /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0EE2AEFF23D00A0B06B /* OHHTTPStubsSwift */; }; 690FF0F12AEFF24200A0B06B /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0F02AEFF24200A0B06B /* OHHTTPStubs */; }; 690FF0F52AF0F06100A0B06B /* PostHogSDKTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */; }; - 5CA51F0E2B5C9A0100000001 /* PostHogScreenNameTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */; }; 69261D132AD5685B00232EC7 /* PostHogRemoteConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69261D122AD5685B00232EC7 /* PostHogRemoteConfig.swift */; }; 69261D192AD9673500232EC7 /* PostHogUploadInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69261D182AD9673500232EC7 /* PostHogUploadInfo.swift */; }; 69261D1B2AD9678C00232EC7 /* PostHogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69261D1A2AD9678C00232EC7 /* PostHogEvent.swift */; }; @@ -135,8 +136,10 @@ 7212EDEEF9E1DC37D57219B9 /* PLCrashReporterConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = D647E3BDAF428D331D684092 /* PLCrashReporterConfig.m */; }; 7470607DEDB656D74BC1B487 /* PLCrashMachExceptionPortSet.m in Sources */ = {isa = PBXBuildFile; fileRef = 47D619C6488AA0E8B84944FF /* PLCrashMachExceptionPortSet.m */; }; 77187C93372EC6973C4386D1 /* PLCrashAsyncDwarfCIE.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4794C39420D22C8B91124E48 /* PLCrashAsyncDwarfCIE.cpp */; }; + 7EA17C34A2E653068088F212 /* fixture_survey_conditions_event_property_filters.json in Resources */ = {isa = PBXBuildFile; fileRef = 8D1477A6BA86EB4E28DF64ED /* fixture_survey_conditions_event_property_filters.json */; }; 7FC8AD34A109E7F53B252F65 /* PLCrashAsyncSignalInfo.c in Sources */ = {isa = PBXBuildFile; fileRef = 45DA778D797BB8B389FD4AEB /* PLCrashAsyncSignalInfo.c */; }; 82A213327F32C797CB815DDD /* PLCrashHostInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = A38F6D8BE8A49299433A915A /* PLCrashHostInfo.m */; }; + 867168FF2DB362371B6ACBA2 /* fixture_surveys_array_with_malformed.json in Resources */ = {isa = PBXBuildFile; fileRef = 0C73719F6590EB6B34D3EF2C /* fixture_surveys_array_with_malformed.json */; }; 86E43EA3744BB24D27051588 /* PLCrashUncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 39511B75AEBE275A433E3298 /* PLCrashUncaughtExceptionHandler.m */; }; 88D516A0861C2F4F6D57CA7A /* PostHogLogsOTLP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7639E3BB936A53A6ED17AC /* PostHogLogsOTLP.swift */; }; 8A85D64A2CA4FB9728087065 /* PLCrashReportSymbolInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 61B25EED847481E0988A65E3 /* PLCrashReportSymbolInfo.m */; }; @@ -262,7 +265,6 @@ DA5063C92EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063C82EF585AB00C51DA0 /* PostHogErrorTrackingUtils.swift */; }; DA5063D22EF5918200C51DA0 /* PostHogCrashReportProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5063D12EF5918200C51DA0 /* PostHogCrashReportProcessor.swift */; }; DA5064412EF5E40300C51DA0 /* ApplicationScreenViewPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */; }; - 5CA51F0E2B5C9A0100000003 /* PostHogScreenNameSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA51F0E2B5C9A0100000004 /* PostHogScreenNameSanitizer.swift */; }; DA5064432EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064422EF5E58D00C51DA0 /* PostHogErrorTrackingAutoCaptureIntegration.swift */; }; DA5064572EF6171900C51DA0 /* SwiftCrashTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5064562EF6171900C51DA0 /* SwiftCrashTriggers.swift */; }; DA5175B32F6C4CEA00F0E00A /* PostHogReplayBufferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5175B12F6C4CEA00F0E00A /* PostHogReplayBufferDelegate.swift */; }; @@ -526,8 +528,6 @@ DAA5EE362D4368A900D437E0 /* fixture_survey_question_multiple_choice.json in Resources */ = {isa = PBXBuildFile; fileRef = DAA5EE312D4368A900D437E0 /* fixture_survey_question_multiple_choice.json */; }; DAA5EE372D4368A900D437E0 /* fixture_survey_question_rating.json in Resources */ = {isa = PBXBuildFile; fileRef = DAA5EE322D4368A900D437E0 /* fixture_survey_question_rating.json */; }; DAA5EE382D4368A900D437E0 /* fixture_survey_question_single_choice.json in Resources */ = {isa = PBXBuildFile; fileRef = DAA5EE332D4368A900D437E0 /* fixture_survey_question_single_choice.json */; }; - F22413BB1C82340F203271A4 /* fixture_survey_question_rating_no_bounds.json in Resources */ = {isa = PBXBuildFile; fileRef = 45D65956292974AA707263FA /* fixture_survey_question_rating_no_bounds.json */; }; - 867168FF2DB362371B6ACBA2 /* fixture_surveys_array_with_malformed.json in Resources */ = {isa = PBXBuildFile; fileRef = 0C73719F6590EB6B34D3EF2C /* fixture_surveys_array_with_malformed.json */; }; DAAD96982F7EF3DC002A8379 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DAAD96992F7EF3DC002A8379 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */; }; @@ -573,6 +573,7 @@ EE47DB8C906D409AB6C2A23F /* RageClickDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26E974D4AD3C438FAE6FE0C5 /* RageClickDetector.swift */; }; F01DFEA6844945E7A794FCC9 /* QueueEndpoint+Factories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C0C245B05D4C29890560B8 /* QueueEndpoint+Factories.swift */; }; F0FBEDFF37952921D60EF57C /* PostHogSurveyTranslation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E41CC6ED20D266FB2357B81 /* PostHogSurveyTranslation.swift */; }; + F22413BB1C82340F203271A4 /* fixture_survey_question_rating_no_bounds.json in Resources */ = {isa = PBXBuildFile; fileRef = 45D65956292974AA707263FA /* fixture_survey_question_rating_no_bounds.json */; }; F6DADC57034D5B70B502F251 /* PostHogLogsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDAA5E5DBE9861837899D553 /* PostHogLogsConfig.swift */; }; FAAA4C305C550B22060A9905 /* PLCrashReportMachineInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 42CE9EE391A5AE700A1D688F /* PLCrashReportMachineInfo.m */; }; /* End PBXBuildFile section */ @@ -747,6 +748,7 @@ 05FCF0F4BD9EEC66BF95699E /* PLCrashFrameDWARFUnwind.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = PLCrashFrameDWARFUnwind.cpp; path = vendor/PHPLCrashReporter/Source/PLCrashFrameDWARFUnwind.cpp; sourceTree = ""; }; 09F0CB0EF4DFD9469CE01CDD /* PLCrashAsyncThread_arm.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashAsyncThread_arm.c; path = vendor/PHPLCrashReporter/Source/PLCrashAsyncThread_arm.c; sourceTree = ""; }; 0B24F86E2D8348C00685A2F9 /* PLCrashReportApplicationInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportApplicationInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportApplicationInfo.m; sourceTree = ""; }; + 0C73719F6590EB6B34D3EF2C /* fixture_surveys_array_with_malformed.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_surveys_array_with_malformed.json; sourceTree = ""; }; 0D784BEF45D7E472FBE56B84 /* PLCrashMachExceptionPort.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashMachExceptionPort.m; path = vendor/PHPLCrashReporter/Source/PLCrashMachExceptionPort.m; sourceTree = ""; }; 0DD06A7329293F85392FD46C /* fixture_survey_translations.json */ = {isa = PBXFileReference; includeInIndex = 1; path = fixture_survey_translations.json; sourceTree = ""; }; 12DC9426FD779BD7FE34A29F /* PLCrashReportSystemInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportSystemInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportSystemInfo.m; sourceTree = ""; }; @@ -792,6 +794,7 @@ 42AF538C180FBEEACEBAB53F /* PLCrashMachExceptionServer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashMachExceptionServer.m; path = vendor/PHPLCrashReporter/Source/PLCrashMachExceptionServer.m; sourceTree = ""; }; 42CE9EE391A5AE700A1D688F /* PLCrashReportMachineInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportMachineInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportMachineInfo.m; sourceTree = ""; }; 4360B9439C3B5722C115465A /* PLCrashReportProcessorInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportProcessorInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportProcessorInfo.m; sourceTree = ""; }; + 45D65956292974AA707263FA /* fixture_survey_question_rating_no_bounds.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_survey_question_rating_no_bounds.json; sourceTree = ""; }; 45DA778D797BB8B389FD4AEB /* PLCrashAsyncSignalInfo.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashAsyncSignalInfo.c; path = vendor/PHPLCrashReporter/Source/PLCrashAsyncSignalInfo.c; sourceTree = ""; }; 4794C39420D22C8B91124E48 /* PLCrashAsyncDwarfCIE.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = PLCrashAsyncDwarfCIE.cpp; path = vendor/PHPLCrashReporter/Source/PLCrashAsyncDwarfCIE.cpp; sourceTree = ""; }; 47D619C6488AA0E8B84944FF /* PLCrashMachExceptionPortSet.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashMachExceptionPortSet.m; path = vendor/PHPLCrashReporter/Source/PLCrashMachExceptionPortSet.m; sourceTree = ""; }; @@ -800,6 +803,8 @@ 57E2779AF27A014DB1B40BCB /* PLCrashAsyncThread_current.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashAsyncThread_current.c; path = vendor/PHPLCrashReporter/Source/PLCrashAsyncThread_current.c; sourceTree = ""; }; 5A1B2C3D4E5F60718293A4B6 /* PostHogLogSeverity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogLogSeverity.swift; sourceTree = ""; }; 5B4E325C19D4C707C5FA9255 /* PLCrashAsyncDwarfFDE.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = PLCrashAsyncDwarfFDE.cpp; path = vendor/PHPLCrashReporter/Source/PLCrashAsyncDwarfFDE.cpp; sourceTree = ""; }; + 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenNameTest.swift; sourceTree = ""; }; + 5CA51F0E2B5C9A0100000004 /* PostHogScreenNameSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenNameSanitizer.swift; sourceTree = ""; }; 60CCBF99708406E47F683455 /* dwarf_opstream.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = dwarf_opstream.cpp; path = vendor/PHPLCrashReporter/Source/dwarf_opstream.cpp; sourceTree = ""; }; 61B25EED847481E0988A65E3 /* PLCrashReportSymbolInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportSymbolInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportSymbolInfo.m; sourceTree = ""; }; 61CBAB800ACCB63AB4491646 /* PLCrashFrameCompactUnwind.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashFrameCompactUnwind.c; path = vendor/PHPLCrashReporter/Source/PLCrashFrameCompactUnwind.c; sourceTree = ""; }; @@ -822,7 +827,6 @@ 690FF0E22AEFD12900A0B06B /* PostHogConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConfigTest.swift; sourceTree = ""; }; 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogQueueTest.swift; sourceTree = ""; }; 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSDKTest.swift; sourceTree = ""; }; - 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenNameTest.swift; sourceTree = ""; }; 690FF1732AF3CE8A00A0B06B /* PostHogExampleWatchOS.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleWatchOS.xcodeproj; path = PostHogExampleWatchOS/PostHogExampleWatchOS.xcodeproj; sourceTree = ""; }; 69261D122AD5685B00232EC7 /* PostHogRemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfig.swift; sourceTree = ""; }; 69261D182AD9673500232EC7 /* PostHogUploadInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogUploadInfo.swift; sourceTree = ""; }; @@ -901,6 +905,7 @@ 826D9ED566BF6FEFBA430555 /* BundleUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleUtils.swift; sourceTree = ""; }; 8A7639E3BB936A53A6ED17AC /* PostHogLogsOTLP.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogLogsOTLP.swift; sourceTree = ""; }; 8AC7B7E5D1441557C48E5951 /* CrashReporterFramework.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = CrashReporterFramework.m; path = vendor/PHPLCrashReporter/Source/CrashReporterFramework.m; sourceTree = ""; }; + 8D1477A6BA86EB4E28DF64ED /* fixture_survey_conditions_event_property_filters.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = fixture_survey_conditions_event_property_filters.json; sourceTree = ""; }; 91792EADDC2A0458982154FC /* protobuf-c.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = "protobuf-c.c"; path = "vendor/PHPLCrashReporter/Dependencies/protobuf-c/protobuf-c/protobuf-c.c"; sourceTree = ""; }; 9473A633747872EA43FDCA3A /* SurveyTranslationResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurveyTranslationResolver.swift; sourceTree = ""; }; 97A462C12E6F54A281DE5023 /* PLCrashProcessInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashProcessInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashProcessInfo.m; sourceTree = ""; }; @@ -1019,7 +1024,6 @@ DA53DE7D2D3E66A300C38DCA /* PostHogRemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfigTest.swift; sourceTree = ""; }; DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegration.swift; sourceTree = ""; }; DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationScreenViewPublisher.swift; sourceTree = ""; }; - 5CA51F0E2B5C9A0100000004 /* PostHogScreenNameSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenNameSanitizer.swift; sourceTree = ""; }; DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockApplicationLifecyclePublisher.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 = ""; }; @@ -1329,8 +1333,6 @@ DAA5EE312D4368A900D437E0 /* fixture_survey_question_multiple_choice.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_survey_question_multiple_choice.json; sourceTree = ""; }; DAA5EE322D4368A900D437E0 /* fixture_survey_question_rating.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_survey_question_rating.json; sourceTree = ""; }; DAA5EE332D4368A900D437E0 /* fixture_survey_question_single_choice.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_survey_question_single_choice.json; sourceTree = ""; }; - 45D65956292974AA707263FA /* fixture_survey_question_rating_no_bounds.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_survey_question_rating_no_bounds.json; sourceTree = ""; }; - 0C73719F6590EB6B34D3EF2C /* fixture_surveys_array_with_malformed.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = fixture_surveys_array_with_malformed.json; sourceTree = ""; }; DAB06F9C2D09A744005B1C9B /* PostHog.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = PostHog.modulemap; sourceTree = ""; }; DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostHogNoMaskViewModifier.swift; sourceTree = ""; }; DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogTagViewModifier.swift; sourceTree = ""; }; @@ -2472,6 +2474,7 @@ DA7185472D07E11200396388 /* output_3.webp */, 0DD06A7329293F85392FD46C /* fixture_survey_translations.json */, F4F3132F8AB899CDD425012C /* fixture_survey_translation_noop.json */, + 8D1477A6BA86EB4E28DF64ED /* fixture_survey_conditions_event_property_filters.json */, ); path = Resources; sourceTree = ""; @@ -2960,6 +2963,7 @@ DA71854D2D07E11200396388 /* input_1.png in Resources */, E89B5DEB2E380EF6D86E66A8 /* fixture_survey_translations.json in Resources */, BB04FDE3FEF9ED62590825AC /* fixture_survey_translation_noop.json in Resources */, + 7EA17C34A2E653068088F212 /* fixture_survey_conditions_event_property_filters.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3889,6 +3893,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/PostHog/PrivateModules/PHPLCrashReporter"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -3922,6 +3927,7 @@ SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/PostHog/PrivateModules/PHPLCrashReporter"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; @@ -4380,6 +4386,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "TESTING DEBUG"; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/PostHog/PrivateModules/PHPLCrashReporter"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; diff --git a/PostHogTests/ApplicationViewLayoutPublisherTest.swift b/PostHogTests/ApplicationViewLayoutPublisherTest.swift index e4e5ba0d48..9f63512509 100644 --- a/PostHogTests/ApplicationViewLayoutPublisherTest.swift +++ b/PostHogTests/ApplicationViewLayoutPublisherTest.swift @@ -14,11 +14,23 @@ final class ApplicationViewLayoutPublisherTest { var registrationToken: RegistrationToken? + // invoke() hops to a background throttle queue then back to main, so effects are async. + private func waitUntil(timeoutNanoseconds: UInt64 = 1_000_000_000, + pollNanoseconds: UInt64 = 5_000_000, + _ condition: () -> Bool) async + { + let start = DispatchTime.now().uptimeNanoseconds + while !condition(), DispatchTime.now().uptimeNanoseconds - start < timeoutNanoseconds { + try? await Task.sleep(nanoseconds: pollNanoseconds) + } + } + @MainActor @Test("throttles layout views correctly") func throttleLayoutViews() async throws { let mockNow = MockDate() now = { mockNow.date } + defer { now = { Date() } } var timesCalled = 0 var lastCallTime: Date? @@ -29,27 +41,28 @@ lastCallTime = mockNow.date } - // First call should trigger immediately sut.simulateLayoutSubviews() + await waitUntil { timesCalled == 1 } let firstCallDate = mockNow.date #expect(timesCalled == 1) #expect(lastCallTime == firstCallDate) - // These calls should be throttled (all within 2s) - mockNow.date.addTimeInterval(0.6) - sut.simulateLayoutSubviews() - mockNow.date.addTimeInterval(0.6) - sut.simulateLayoutSubviews() - mockNow.date.addTimeInterval(0.6) - sut.simulateLayoutSubviews() + // Within the 2s throttle window, so each must be ignored. invokeIfReady reads the + // mocked clock when its async block runs, so let it settle before advancing the clock. + for _ in 0 ..< 3 { + mockNow.date.addTimeInterval(0.6) + sut.simulateLayoutSubviews() + try? await Task.sleep(nanoseconds: 20 * NSEC_PER_MSEC) + } #expect(timesCalled == 1, "Calls within throttle interval should be ignored") #expect(lastCallTime == firstCallDate) - // This call should trigger (>2s since last trigger) + // >2s since last trigger, so this one fires. mockNow.date.addTimeInterval(0.4) // Total: 2.2s sut.simulateLayoutSubviews() + await waitUntil { timesCalled == 2 } #expect(timesCalled == 2) #expect(lastCallTime == mockNow.date) diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index a43b3262ae..fd17836a7e 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -96,7 +96,7 @@ class PostHogConfigTest: QuickSpec { context("when initialized with default tracing headers configuration") { it("should disable tracing headers by default") { - let sut = PostHogConfig(apiKey: testAPIKey) + let sut = PostHogConfig(projectToken: testProjectToken) expect(sut.tracingHeaders).to(beNil()) } } diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift index 8f2800702f..736998f85d 100644 --- a/PostHogTests/PostHogCrashReportProcessorTest.swift +++ b/PostHogTests/PostHogCrashReportProcessorTest.swift @@ -10,9 +10,9 @@ import Foundation import Testing #if os(iOS) || os(macOS) || os(tvOS) - #if canImport(PHPLCrashReporter) - @_implementationOnly import PHPLCrashReporter - #endif + // SDK imports this @_implementationOnly, so @testable doesn't re-export it; imported + // directly here via the test target's own dependency (Package.swift / SWIFT_INCLUDE_PATHS). + import PHPLCrashReporter @Suite("PostHogCrashReportProcessor Tests") struct PostHogCrashReportProcessorTest { diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index bbacdba03f..3d47b2e53d 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -29,6 +29,14 @@ enum PostHogFeatureFlagsTest { var server: MockPostHogServer! + // SDKs created per test, closed in deinit so their queues/timers/observers don't leak + // across the run and starve the background thread pool. close() is idempotent. + var trackedSuts: [PostHogSDK] = [] + func track(_ sut: PostHogSDK) -> PostHogSDK { + trackedSuts.append(sut) + return sut + } + init() { server = MockPostHogServer(version: 4) server.start() @@ -38,6 +46,7 @@ enum PostHogFeatureFlagsTest { } deinit { + trackedSuts.forEach { $0.close() } server.stop() server = nil } @@ -280,7 +289,7 @@ enum PostHogFeatureFlagsTest { class TestPersonAndGroupPropertiesForFlags: BaseTestClass { @Test("Person properties are stored and retrieved correctly") func storeAndRetrievePersonProperties() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing by identifying sut.identify("test_user") @@ -327,7 +336,7 @@ enum PostHogFeatureFlagsTest { @Test("Person properties are additive") func personPropertiesAreAdditive() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Set first batch of properties sut.setPersonPropertiesForFlags(["property1": "value1", "shared": "original"]) @@ -365,7 +374,7 @@ enum PostHogFeatureFlagsTest { @Test("Reset person properties clears all properties") func resetPersonPropertiesClearsAll() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Set some properties sut.setPersonPropertiesForFlags(["property1": "value1", "property2": "value2"]) @@ -401,7 +410,7 @@ enum PostHogFeatureFlagsTest { @Test("Group properties are stored and retrieved correctly") func storeAndRetrieveGroupProperties() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) let properties = [ "plan": "enterprise", @@ -447,7 +456,7 @@ enum PostHogFeatureFlagsTest { @Test("Multiple group types are handled correctly") func multipleGroupTypesHandled() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Set properties for different group types sut.setGroupPropertiesForFlags("organization", properties: ["plan": "enterprise"]) @@ -482,7 +491,7 @@ enum PostHogFeatureFlagsTest { @Test("Reset group properties for specific type") func resetGroupPropertiesSpecificType() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Set properties for multiple group types sut.setGroupPropertiesForFlags("organization", properties: ["plan": "enterprise"]) @@ -520,7 +529,7 @@ enum PostHogFeatureFlagsTest { @Test("Reset all group properties") func resetAllGroupProperties() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Set properties for multiple group types sut.setGroupPropertiesForFlags("organization", properties: ["plan": "enterprise"]) @@ -552,7 +561,7 @@ enum PostHogFeatureFlagsTest { @Test("Both person and group properties sent together") func bothPersonAndGroupPropertiesSent() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Set both types of properties sut.setPersonPropertiesForFlags(["user_plan": "premium"]) @@ -595,7 +604,7 @@ enum PostHogFeatureFlagsTest { @Test("Capture with userProperties automatically sets person properties for flags") func captureWithUserPropertiesAutomaticallySetsPersonPropertiesForFlags() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing sut.identify("test_user") @@ -632,7 +641,7 @@ enum PostHogFeatureFlagsTest { @Test("Group with groupProperties automatically sets group properties for flags") func groupWithGroupPropertiesAutomaticallySetsGroupPropertiesForFlags() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing sut.identify("test_user") @@ -672,7 +681,7 @@ enum PostHogFeatureFlagsTest { class TestGetFeatureFlagResult: BaseTestClass { @Test("returns result for enabled boolean flag") func returnsResultForEnabledBoolFlag() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -690,7 +699,7 @@ enum PostHogFeatureFlagsTest { @Test("returns result for string variant flag") func returnsResultForVariantFlag() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -708,7 +717,7 @@ enum PostHogFeatureFlagsTest { @Test("returns result for disabled flag") func returnsResultForDisabledFlag() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -726,7 +735,7 @@ enum PostHogFeatureFlagsTest { @Test("returns nil for non-existent flag") func returnsNilForNonExistentFlag() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -741,7 +750,7 @@ enum PostHogFeatureFlagsTest { @Test("includes payload in result") func includesPayloadInResult() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -764,7 +773,7 @@ enum PostHogFeatureFlagsTest { func sendsEventByDefault() async throws { config.sendFeatureFlagEvent = true config.flushAt = 1 - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -791,7 +800,7 @@ enum PostHogFeatureFlagsTest { func respectsSendEventParameterFalse() async { config.sendFeatureFlagEvent = true config.flushAt = 1 - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -818,7 +827,7 @@ enum PostHogFeatureFlagsTest { @Test("getFeatureFlag returns consistent values with getFeatureFlagResult") func getFeatureFlagReturnsSameValue() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in sut.reloadFeatureFlags { @@ -918,7 +927,7 @@ enum PostHogFeatureFlagsTest { func evaluationContextsIncludedInRequest() async { // Configure evaluation contexts config.evaluationContexts = ["production", "web", "checkout"] - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing sut.identify("test_user") @@ -958,7 +967,7 @@ enum PostHogFeatureFlagsTest { func emptyEvaluationContextsNotIncluded() async { // Configure with empty evaluation contexts config.evaluationContexts = [] - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing sut.identify("test_user") @@ -989,7 +998,7 @@ enum PostHogFeatureFlagsTest { @Test("Nil evaluation contexts not included in request") func nilEvaluationContextsNotIncluded() async { // Don't set evaluation contexts (leave as nil) - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing sut.identify("test_user") @@ -1019,7 +1028,7 @@ enum PostHogFeatureFlagsTest { @Test("Can update evaluation contexts after initialization") func canUpdateEvaluationContexts() async { - let sut = PostHogSDK.with(config) + let sut = track(PostHogSDK.with(config)) // Enable person processing sut.identify("test_user") @@ -1071,7 +1080,7 @@ enum PostHogFeatureFlagsTest { func deprecatedEvaluationEnvironmentsStillWorks() async { // Use the deprecated property config.evaluationEnvironments = ["production", "api"] - let sut = PostHogSDK.with(config) + 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") diff --git a/PostHogTests/PostHogMulticastCallbackTest.swift b/PostHogTests/PostHogMulticastCallbackTest.swift index f787299806..031435cbc7 100644 --- a/PostHogTests/PostHogMulticastCallbackTest.swift +++ b/PostHogTests/PostHogMulticastCallbackTest.swift @@ -184,6 +184,7 @@ class PostHogThrottledMulticastCallbackTests { func throttlePreventsRapidInvocations() async { let mockNow = MockDate() now = { mockNow.date } + defer { now = { Date() } } let callback = PostHogThrottledMulticastCallback() var receivedValues: [Int] = [] @@ -217,6 +218,7 @@ class PostHogThrottledMulticastCallbackTests { func differentThrottleIntervals() async { let mockNow = MockDate() now = { mockNow.date } + defer { now = { Date() } } let callback = PostHogThrottledMulticastCallback() var fastValues: [Int] = [] diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index 19c1ec63c6..09541661a1 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -48,7 +48,9 @@ class PostHogQueueTest: QuickSpec { let events = getBatchedEvents(server) expect(events.count) == 1 - expect(sut.depth) == 0 + // getBatchedEvents only waits for the request to arrive; the queue pops the batch after + // the response is processed, so poll rather than assert synchronously. + expect(sut.depth).toEventually(equal(0)) sut.clear() } @@ -60,18 +62,18 @@ class PostHogQueueTest: QuickSpec { let event2 = PostHogEvent(event: "event2", distinctId: "distinctId2") let event3 = PostHogEvent(event: "event3", distinctId: "distinctId3") - // First event should not trigger flush (below flushAt threshold) sut.add(event) expect(sut.depth) == 1 - // Adding two more events: second event reaches flushAt=2 and triggers flush, - // third event stays in queue + // flush() takes the batch asynchronously, so let it drain before adding more — + // otherwise a later add races into the in-flight peek() and joins the batch. sut.add(event2) - sut.add(event3) let events = getBatchedEvents(server) expect(events.count) == 2 + expect(sut.depth).toEventually(equal(0)) + sut.add(event3) expect(sut.depth) == 1 sut.clear() @@ -212,7 +214,7 @@ class PostHogQueueTest: QuickSpec { sut.flush() expect(sut.currentBatchCapForTesting).toEventually(equal(5)) sut.flush() - expect(sut.depth).toEventually(equal(0), timeout: .seconds(5)) + expect(sut.depth).toEventually(equal(0)) sut.clear() } diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index f60c74662c..8dccbc9c40 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -26,6 +26,9 @@ enum PostHogRemoteConfigTest { // important! let storage = PostHogStorage(config) storage.reset() + // reset() intentionally KEEPS .remoteConfig (project-level config survives an identity + // change). These suites share on-disk storage, so clear it explicitly to isolate tests. + storage.remove(key: .remoteConfig) } deinit { @@ -473,7 +476,7 @@ enum PostHogRemoteConfigTest { defer { storage.reset() } let recording: [String: Any] = ["test": 1] - storage.setDictionary(forKey: .sessionReplay, contents: recording) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": recording]) let sut = getSut(storage: storage) @@ -487,39 +490,37 @@ enum PostHogRemoteConfigTest { #expect(sut.isSessionReplayFlagActive() == false) } - @Test("session replay config survives reset (project-level config)") + @Test("remote config survives reset (project-level config)") func sessionReplayConfigSurvivesReset() { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["endpoint": "/s/"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["endpoint": "/s/"]]) - // reset() clears user-scoped state but must keep the project-level recording config, - // so replay can re-arm after an in-session identity change without an app restart. + // reset() clears user-scoped state but must keep the project-level remote config, so + // replay can re-arm after an in-session identity change without an app restart. storage.reset() - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) + #expect(storage.getDictionary(forKey: .remoteConfig) != nil) } - @Test("returns isSessionReplayFlagActive false if feature flag disabled") + @Test("returns isSessionReplayFlagActive false if recording is disabled remotely") func returnIsSessionReplayFlagActiveFalseIfFeatureFlagDisabled() async { let storage = PostHogStorage(config) defer { storage.reset() } let recording: [String: Any] = ["test": 1] - storage.setDictionary(forKey: .sessionReplay, contents: recording) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": recording]) let sut = getSut(storage: storage) #expect(sut.isSessionReplayFlagActive()) + // /config (returnReplay == false) now reports recording disabled, which must turn the flag off. await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) + sut.reloadRemoteConfig { _ in continuation.resume() } } - #expect(storage.getDictionary(forKey: .sessionReplay) == nil) #expect(sut.isSessionReplayFlagActive() == false) } @@ -533,14 +534,16 @@ enum PostHogRemoteConfigTest { server.returnReplay = true + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() }) } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) } @@ -556,14 +559,16 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() }) } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) } @@ -580,14 +585,16 @@ enum PostHogRemoteConfigTest { server.returnReplayWithVariant = true server.replayVariantValue = false + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() }) } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) } @@ -606,14 +613,16 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "web"] + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() }) } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) } @@ -632,14 +641,16 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "mobile"] + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() }) } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) } @@ -656,14 +667,16 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "some-missing-flag" server.flagsSkipReplayVariantName = true + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() }) } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) storage.reset() @@ -687,6 +700,9 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() @@ -719,6 +735,9 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "web"] + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() @@ -746,6 +765,9 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() @@ -769,6 +791,9 @@ enum PostHogRemoteConfigTest { server.returnReplay = true + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() @@ -789,26 +814,24 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage) - // /config (the only source of recording config) populates the in-memory - // recording state and persists it to storage under .sessionReplay. + // /config (the only source of recording config) populates the in-memory recording + // state and persists the whole config to storage under .remoteConfig. await withCheckedContinuation { continuation in sut.reloadRemoteConfig { _ in continuation.resume() } } #expect(sut.isSessionReplayFlagActive() == true) #expect(sut.getRecordingSampleRate() == 0.42) - // Mimic reset(): storage.reset() wipes the persisted remote config but KEEPS the - // persisted .sessionReplay config (project-level, not user data); clear() drops the - // in-memory remote config and resets the replay flags. The persisted .sessionReplay - // is retained so the following /flags reload can re-evaluate replay. + // Mimic reset(): storage.reset() KEEPS the persisted .remoteConfig (project-level, not + // user data) while clearing user state; clear() resets the in-memory replay flags but + // keeps the cached remote config, so the following /flags reload can re-evaluate replay. storage.reset() sut.clear() #expect(sut.isSessionReplayFlagActive() == false) #expect(sut.getRecordingSampleRate() == nil) - // The post-reset /flags reload carries no sessionRecording and the cached remote config - // is gone, so replay must re-arm from the persisted .sessionReplay config (the else - // branch), without waiting for an app restart. + // The post-reset /flags reload carries no sessionRecording, so replay must re-arm from + // the retained .remoteConfig (the else branch), without waiting for an app restart. await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in continuation.resume() @@ -833,11 +856,11 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage) - // /config caches the recording config (with linkedFlag) under .sessionReplay. + // /config caches the recording config (with linkedFlag) under .remoteConfig. await withCheckedContinuation { continuation in sut.reloadRemoteConfig { _ in continuation.resume() } } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) + #expect(storage.getDictionary(forKey: .remoteConfig) != nil) storage.reset() sut.clear() diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index ab94c74d12..59ed8ca0df 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -11,6 +11,11 @@ import Nimble import Quick class PostHogSDKTest: QuickSpec { + // Every SDK getSut creates is tracked and closed in afterEach. An unclosed SDK leaks its + // queues, timers, URLSession and observers; across ~40 instances per run those pile up and + // starve the background thread pool, stalling async work (flag loads, flushes) on CI. + private var trackedSuts: [PostHogSDK] = [] + func getSut(preloadFeatureFlags: Bool = false, sendFeatureFlagEvent: Bool = false, captureApplicationLifecycleEvents: Bool = false, @@ -41,7 +46,9 @@ class PostHogSDKTest: QuickSpec { let storage = PostHogStorage(config) storage.reset() - return PostHogSDK.with(config) + let sut = PostHogSDK.with(config) + trackedSuts.append(sut) + return sut } func getBeforeSendEventsConfig() -> [BeforeSendTestEventContext] { @@ -108,6 +115,10 @@ class PostHogSDKTest: QuickSpec { } afterEach { now = { Date() } + // Close every SDK created this test so its queues/timers/observers don't leak into the + // next one (close() is idempotent, so tests that already closed their sut are fine). + self.trackedSuts.forEach { $0.close() } + self.trackedSuts.removeAll() server.stop() server = nil } @@ -295,7 +306,7 @@ class PostHogSDKTest: QuickSpec { it("loads feature flags automatically") { let sut = self.getSut(preloadFeatureFlags: true) - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.isFeatureEnabled("bool-value")) == true sut.reset() @@ -305,7 +316,7 @@ class PostHogSDKTest: QuickSpec { it("send feature flag event for isFeatureEnabled when enabled") { let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.isFeatureEnabled("bool-value")) == true let events = getBatchedEvents(server) @@ -328,7 +339,7 @@ class PostHogSDKTest: QuickSpec { it("send feature flag event with variant response for isFeatureEnabled when enabled") { let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.isFeatureEnabled("string-value")) == true let events = getBatchedEvents(server) @@ -351,7 +362,7 @@ class PostHogSDKTest: QuickSpec { it("send feature flag event for getFeatureFlag when enabled") { let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.getFeatureFlag("bool-value") as? Bool) == true let events = getBatchedEvents(server) @@ -370,7 +381,7 @@ class PostHogSDKTest: QuickSpec { it("force send feature flag event for getFeatureFlag when config disabled") { let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: false) - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.getFeatureFlag("bool-value", sendFeatureFlagEvent: true) as? Bool) == true let events = getBatchedEvents(server) @@ -389,7 +400,7 @@ class PostHogSDKTest: QuickSpec { it("don't send feature flag event for getFeatureFlag when config enabled") { let sut = self.getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.getFeatureFlag("bool-value", sendFeatureFlagEvent: false) as? Bool) == true let events = getBatchedEvents(server, failIfNotCompleted: false) @@ -477,7 +488,7 @@ class PostHogSDKTest: QuickSpec { let sut = self.getSut() sut.reloadFeatureFlags() - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) sut.capture("event") @@ -666,7 +677,7 @@ class PostHogSDKTest: QuickSpec { sut.reset() - waitFlagsRequest(server) + waitForFeatureFlagsLoaded(server, sut) expect(sut.isFeatureEnabled("bool-value")) == true sut.close() diff --git a/PostHogTests/PostHogSamplingTest.swift b/PostHogTests/PostHogSamplingTest.swift index bb18f06ac4..16efcc4c63 100644 --- a/PostHogTests/PostHogSamplingTest.swift +++ b/PostHogTests/PostHogSamplingTest.swift @@ -114,6 +114,9 @@ class PostHogSamplingTests { server.start() let storage = PostHogStorage(config) storage.reset() + // reset() keeps .remoteConfig (project-level config); clear it so shared on-disk storage + // doesn't leak recording config between these serialized tests. + storage.remove(key: .remoteConfig) } deinit { @@ -142,7 +145,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": "0.75"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": "0.75"]]) let sut = getSut(storage: storage) @@ -154,7 +157,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": 0.5]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": 0.5]]) let sut = getSut(storage: storage) @@ -166,7 +169,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": "1"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": "1"]]) let sut = getSut(storage: storage) @@ -178,7 +181,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": "0"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": "0"]]) let sut = getSut(storage: storage) @@ -190,7 +193,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": "1.5"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": "1.5"]]) let sut = getSut(storage: storage) @@ -202,7 +205,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": "-0.5"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": "-0.5"]]) let sut = getSut(storage: storage) @@ -214,7 +217,7 @@ class PostHogSamplingTests { let storage = PostHogStorage(config) defer { storage.reset() } - storage.setDictionary(forKey: .sessionReplay, contents: ["sampleRate": "invalid"]) + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["sampleRate": "invalid"]]) let sut = getSut(storage: storage) diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 26c634995a..9498be21bf 100644 --- a/PostHogTests/PostHogSessionManagerTest.swift +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -386,7 +386,7 @@ enum PostHogSessionManagerTest { } @Suite("Test React Native session management") - struct ReactNativeTests { + final class ReactNativeTests { let mockAppLifecycle: MockApplicationLifecyclePublisher let posthog: PostHogSDK @@ -398,6 +398,14 @@ enum PostHogSessionManagerTest { posthog = PostHogSDK.with(config) } + deinit { + // Restore globals this suite mutates so it doesn't leak RN mode / a mocked clock into the + // other serialized suites (a struct can't deinit, hence the class). + postHogSdkName = postHogiOSSdkName + now = { Date() } + DI.main.appLifecyclePublisher = ApplicationLifecyclePublisher.shared + } + @Test("Session id is NOT cleared after 30 min of background time") func sessionNotClearedBackgrounded() throws { let mockNow = MockDate() diff --git a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift index 03f3de01fb..9d79c90f92 100644 --- a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift +++ b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift @@ -25,11 +25,28 @@ config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true config.preloadFeatureFlags = false + // Drive recording config from the seeded .remoteConfig below, not the async /config fetch, + // so the tests stay deterministic and independent of global stub state from other suites. + config.disableRemoteConfigForTesting = true // Configure mock server for remote config server.returnReplay = true server.sessionRecordingEventTriggers = eventTriggers + // Seed the recording config so install() reads the event triggers up front (.remoteConfig + // survives reset(), so clear any value leaked from a previous serialized test first). + let storage = PostHogStorage(config) + storage.remove(key: .remoteConfig) + var sessionRecording: [String: Any] = ["endpoint": "/s/"] + if let eventTriggers { + sessionRecording["eventTriggers"] = eventTriggers + } + storage.setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": sessionRecording]) + + // Reset the static install flag a prior replay suite may have left set, so this SUT installs + // a fresh integration rather than no-opping onto a stale one. + PostHogReplayIntegration.clearInstalls() + return PostHogSDK.with(config) } diff --git a/PostHogTests/PostHogTracingHeadersTest.swift b/PostHogTests/PostHogTracingHeadersTest.swift index b23e42189e..82cfbbad93 100644 --- a/PostHogTests/PostHogTracingHeadersTest.swift +++ b/PostHogTests/PostHogTracingHeadersTest.swift @@ -251,7 +251,13 @@ @Test("keeps distinct id header but omits session tracing headers when the session has ended") func omitsSessionHeadersWhenSessionHasEnded() async throws { - try await withTracingSut(tracingHeaders: [Self.primaryHost]) { sut in + // A foregrounded app rotates a fresh session on endSession(); background it first so the + // session genuinely ends (the "no active session -> omit session header" case this covers). + let backgroundedLifecycle = MockApplicationLifecyclePublisher() + try await withTracingSut(tracingHeaders: [Self.primaryHost], appLifecycle: backgroundedLifecycle) { sut in + backgroundedLifecycle.isInBackground = true + backgroundedLifecycle.simulateAppDidEnterBackground() + sut.endSession() let capture = CapturedRequest() @@ -266,16 +272,22 @@ private func withTracingSut( tracingHeaders: [String], + appLifecycle: AppLifecyclePublishing = ApplicationLifecyclePublisher.shared, _ body: (PostHogSDK) async throws -> T ) async throws -> T { PostHogTracingHeadersIntegration.clearInstalls() HTTPStubs.removeAllStubs() + // Restored in defer so the lifecycle mock can't leak into other serialized suites. + DI.main.appLifecyclePublisher = appLifecycle let sut = makeSut(tracingHeaders: tracingHeaders) defer { sut.close() HTTPStubs.removeAllStubs() PostHogTracingHeadersIntegration.clearInstalls() + // Restore the real lifecycle publisher so the injected mock does + // not leak into other (serialized) suites. + DI.main.appLifecyclePublisher = ApplicationLifecyclePublisher.shared } return try await body(sut) @@ -376,7 +388,7 @@ } private func makeSut(tracingHeaders: [String]) -> PostHogSDK { - let config = PostHogConfig(apiKey: testAPIKey, host: "http://localhost:9001") + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9001") config.tracingHeaders = tracingHeaders config.captureApplicationLifecycleEvents = false config.captureScreenViews = false diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index b896e41643..2ab1a65aed 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -383,6 +383,15 @@ class MockPostHogServer { let sessionRecordingPayload: String = { if self.returnReplay { var sessionRecording: [String: Any] = ["endpoint": "/s/"] + // /config is the source of recording config (incl. the linked flag); mirror what + // /flags emits so linked-flag gating can be evaluated from the cached remote config. + if self.returnReplayWithVariant { + if self.returnReplayWithMultiVariant { + sessionRecording["linkedFlag"] = self.replayVariantValue + } else { + sessionRecording["linkedFlag"] = self.replayVariantName + } + } if let sampleRate = self.sessionRecordingSampleRate { sessionRecording["sampleRate"] = sampleRate } diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index d765f1f819..745783ed68 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -6,9 +6,29 @@ // import Foundation -import PostHog +import Nimble +@testable import PostHog +import Quick import XCTest +final class TestPollingConfiguration: QuickConfiguration { + override class func configure(_ configuration: QCKConfiguration) { + // Shared CI runners run several times slower than local (one job was ~6x), so Nimble's + // default 1s poll timeout makes async assertions flake when background work is starved. + // Raise the ceiling generously — toEventually still returns as soon as it passes. + PollingDefaults.timeout = .seconds(30) + configuration.beforeEach { + // Some suites mock the global `now` clock and don't restore it; a leaked fixed clock + // makes the timestamp-keyed queue collide events (lost batches). Reset before each test. + now = { Date() } + // storage.reset() deliberately keeps the on-disk event queue, so a prior test's unsent + // event can leak into the next test's batch and inflate counts. Wipe persisted state so + // every Quick test starts from a clean slate. + deleteSafely(applicationSupportDirectoryURL()) + } + } +} + func getBatchedEvents(_ server: MockPostHogServer, timeout: TimeInterval = 15.0, failIfNotCompleted: Bool = true) -> [PostHogEvent] { let result = XCTWaiter.wait(for: [server.batchExpectation!], timeout: timeout) @@ -33,6 +53,14 @@ func waitFlagsRequest(_ server: MockPostHogServer) { } } +// waitFlagsRequest only proves the /flags request arrived; the SDK processes it async. lastRequestId +// is set atomically with the flags + v4 metadata under one lock, so wait on it to know the fresh +// response was fully stored (a flag getter can return a stale cached value before that). +func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _ sut: PostHogSDK) { + waitFlagsRequest(server) + expect(sut.remoteConfig?.lastRequestId).toEventuallyNot(beNil(), timeout: .seconds(10)) +} + func waitForSnapshotRequest(_ server: MockPostHogServer) async throws { guard let expectation = server.snapshotExpectation else { throw TestError("Server is not properly configured with a snapshot expectation.") From 26600143d73ad9571fa4c33474e9f7011d4155fc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 18:30:18 -0400 Subject: [PATCH 2/9] test: address review comments - Extract reload/flags bridge helpers in PostHogRemoteConfigTest (dedup ~24 inline sites) - Makefile: fail with a clear message when no iOS simulator is available - Drop the dead waitForRemoteConfig busy-wait (remote config is disabled, seeded synchronously) - Close the suite-held SDK in the RN session-management teardown - Wait on didReceiveFeatureFlags instead of the can-be-stale lastRequestId non-nil check Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 1 + PostHogTests/PostHogRemoteConfigTest.swift | 180 +++++------------- PostHogTests/PostHogSessionManagerTest.swift | 2 + ...ostHogSessionReplayEventTriggersTest.swift | 39 ---- PostHogTests/TestUtils/TestPostHog.swift | 13 +- 5 files changed, 55 insertions(+), 180 deletions(-) diff --git a/Makefile b/Makefile index dedff13271..38b7d9b756 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,7 @@ swiftFormat: # job — a genuinely broken test fails all 3 and stays red. testOniOSSimulator: @device="$$(xcrun simctl list devices available | grep -E '^[[:space:]]*iPhone' | head -1 | sed -E 's/^[[:space:]]*//; s/ \(.*//')"; \ + [ -n "$$device" ] || { echo "No available iPhone simulator found; install one via Xcode or 'xcrun simctl create'."; exit 1; }; \ echo "Testing on simulator: $$device"; \ set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination "platform=iOS Simulator,name=$$device" -retry-tests-on-failure -test-iterations 3 | xcpretty diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index 8dccbc9c40..1d562372e7 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -46,6 +46,26 @@ enum PostHogRemoteConfigTest { let api = PostHogApi(theConfig) return PostHogRemoteConfig(theConfig, theStorage, api, { [:] }, featureFlagCalledCallback) } + + // async bridges over the SDK's callback-based reload APIs. + func reloadRemoteConfig(_ sut: PostHogRemoteConfig) async { + await withCheckedContinuation { continuation in + sut.reloadRemoteConfig { _ in continuation.resume() } + } + } + + func loadFeatureFlags(_ sut: PostHogRemoteConfig) async { + await withCheckedContinuation { continuation in + sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"]) { _ in + continuation.resume() + } + } + } + + func reloadConfigThenFlags(_ sut: PostHogRemoteConfig) async { + await reloadRemoteConfig(sut) + await loadFeatureFlags(sut) + } } @Suite("Test remote config loading") @@ -517,9 +537,7 @@ enum PostHogRemoteConfigTest { #expect(sut.isSessionReplayFlagActive()) // /config (returnReplay == false) now reports recording disabled, which must turn the flag off. - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(sut.isSessionReplayFlagActive() == false) } @@ -534,14 +552,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) @@ -559,14 +570,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) @@ -585,14 +589,7 @@ enum PostHogRemoteConfigTest { server.returnReplayWithVariant = true server.replayVariantValue = false - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) @@ -613,14 +610,7 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "web"] - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) @@ -641,14 +631,7 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "mobile"] - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) @@ -667,14 +650,7 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "some-missing-flag" server.flagsSkipReplayVariantName = true - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) @@ -700,14 +676,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(calledFlagKey != nil) @@ -735,14 +704,7 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "web"] - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(calledFlagKey == "recording-platform-check") @@ -765,14 +727,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(callbackInvoked == false) @@ -791,14 +746,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(callbackInvoked == false) @@ -816,9 +764,7 @@ enum PostHogRemoteConfigTest { // /config (the only source of recording config) populates the in-memory recording // state and persists the whole config to storage under .remoteConfig. - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(sut.getRecordingSampleRate() == 0.42) @@ -832,11 +778,7 @@ enum PostHogRemoteConfigTest { // The post-reset /flags reload carries no sessionRecording, so replay must re-arm from // the retained .remoteConfig (the else branch), without waiting for an app restart. - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(sut.getRecordingSampleRate() == 0.42) @@ -857,9 +799,7 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage) // /config caches the recording config (with linkedFlag) under .remoteConfig. - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(storage.getDictionary(forKey: .remoteConfig) != nil) storage.reset() @@ -868,11 +808,7 @@ enum PostHogRemoteConfigTest { // The post-reset /flags reload hits the else branch and re-evaluates the cached // config against the new user's flags. The linked flag is missing, so replay must // stay off rather than blindly re-arming. - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isSessionReplayFlagActive() == false) } @@ -887,9 +823,7 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage) - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(sut.getRecordingSampleRate() == 0.42) @@ -899,11 +833,7 @@ enum PostHogRemoteConfigTest { #expect(sut.getRecordingSampleRate() == nil) server.quotaLimitFeatureFlags = true - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(sut.getRecordingSampleRate() == 0.42) @@ -917,11 +847,7 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage) #expect(sut.isSessionReplayFlagActive() == false) - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isSessionReplayFlagActive() == false) } @@ -1019,9 +945,7 @@ enum PostHogRemoteConfigTest { // /config (the only source of error-tracking config) enables autocapture and persists // it under .errorTracking. - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(sut.isAutocaptureExceptionsEnabled() == true) // Mimic reset(): storage.reset() wipes the cached remote config but KEEPS the persisted @@ -1032,11 +956,7 @@ enum PostHogRemoteConfigTest { // The post-reset /flags reload carries no errorTracking and the cached remote config is // gone, so autocapture must re-arm from the persisted .errorTracking slice. - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isAutocaptureExceptionsEnabled() == true) } @@ -1143,9 +1063,7 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(sut.isAutocaptureExceptionsEnabled() == true) storage.reset() @@ -1153,11 +1071,7 @@ enum PostHogRemoteConfigTest { #expect(sut.isAutocaptureExceptionsEnabled() == false) server.quotaLimitFeatureFlags = true - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isAutocaptureExceptionsEnabled() == true) } @@ -1172,11 +1086,7 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) #expect(sut.isAutocaptureExceptionsEnabled() == false) - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await loadFeatureFlags(sut) #expect(sut.isAutocaptureExceptionsEnabled() == false) } @@ -1198,9 +1108,7 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + await reloadRemoteConfig(sut) #expect(sut.getRemoteConfig()?["capturePerformance"] != nil) storage.reset() diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 9498be21bf..7d7b49470b 100644 --- a/PostHogTests/PostHogSessionManagerTest.swift +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -399,6 +399,8 @@ enum PostHogSessionManagerTest { } deinit { + // Close the suite-held SDK so its integrations/queues don't linger into other suites. + posthog.close() // Restore globals this suite mutates so it doesn't leak RN mode / a mocked clock into the // other serialized suites (a struct can't deinit, hence the class). postHogSdkName = postHogiOSSdkName diff --git a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift index 9d79c90f92..d1a891b5cb 100644 --- a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift +++ b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift @@ -50,32 +50,11 @@ return PostHogSDK.with(config) } - private func waitForRemoteConfig(_ sut: PostHogSDK) async { - guard let remoteConfig = sut.remoteConfig else { - return - } - - var remoteConfigLoaded = false - let token = remoteConfig.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true - } - - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } - - _ = token - } - // MARK: - isActive() Tests @Test("isActive returns true when no event triggers configured") func isActiveWithoutTriggers() async throws { let sut = getSut(eventTriggers: nil) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration != nil) #expect(integration?.isActive() == true) @@ -86,8 +65,6 @@ @Test("isActive returns false when waiting for event trigger") func isActiveWhileWaitingForTrigger() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration != nil) #expect(integration?.isActive() == false) @@ -98,8 +75,6 @@ @Test("isActive returns true after trigger event is captured") func isActiveAfterTriggerFired() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration != nil) #expect(integration?.isActive() == false) @@ -116,8 +91,6 @@ @Test("Non-matching event does not activate replay") func nonMatchingEventDoesNotActivate() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration?.isActive() == false) @@ -132,8 +105,6 @@ @Test("Any matching trigger activates replay") func anyMatchingTriggerActivates() async throws { let sut = getSut(eventTriggers: ["purchase_completed", "signup_finished", "checkout_started"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration?.isActive() == false) @@ -149,8 +120,6 @@ @Test("New session requires new trigger activation") func newSessionRequiresNewTrigger() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration?.isActive() == false) @@ -167,8 +136,6 @@ @Test("Trigger activation persists within same session") func triggerPersistsInSameSession() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() sut.capture("purchase_completed") @@ -187,8 +154,6 @@ @Test("Manual start respects event triggers") func manualStartRespectsTriggers() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration != nil) #expect(integration?.isActive() == false) @@ -210,8 +175,6 @@ @Test("Trigger event does not restart replay when manually stopped") func triggerDoesNotRestartWhenManuallyStopped() async throws { let sut = getSut(eventTriggers: ["purchase_completed"]) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration != nil) #expect(integration?.isActive() == false) @@ -236,8 +199,6 @@ @Test("Empty triggers array means no waiting") func emptyTriggersNoWaiting() async throws { let sut = getSut(eventTriggers: nil) - await waitForRemoteConfig(sut) - let integration = sut.getReplayIntegration() #expect(integration != nil) #expect(integration?.isActive() == true) diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 745783ed68..61c5f3034a 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -53,12 +53,15 @@ func waitFlagsRequest(_ server: MockPostHogServer) { } } -// waitFlagsRequest only proves the /flags request arrived; the SDK processes it async. lastRequestId -// is set atomically with the flags + v4 metadata under one lock, so wait on it to know the fresh -// response was fully stored (a flag getter can return a stale cached value before that). -func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _ sut: PostHogSDK) { +// lastRequestId can already be non-nil (restored from disk or an earlier load), so waiting on it +// would pass before the fresh response lands. didReceiveFeatureFlags fires only after a /flags +// response is stored; observe it from before the request so the notification can't be missed. +func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _: PostHogSDK) { + let flagsLoaded = XCTNSNotificationExpectation(name: PostHogSDK.didReceiveFeatureFlags) waitFlagsRequest(server) - expect(sut.remoteConfig?.lastRequestId).toEventuallyNot(beNil(), timeout: .seconds(10)) + if XCTWaiter.wait(for: [flagsLoaded], timeout: 10) != .completed { + XCTFail("Feature flags were not loaded in time") + } } func waitForSnapshotRequest(_ server: MockPostHogServer) async throws { From c27b35b0d017a55840fbeda6d1ffa7190a5dc8ea Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 19:09:52 -0400 Subject: [PATCH 3/9] test: raise request-arrival waits to 30s to match the Nimble CI ceiling The iOS simulator job flaked on the feature-flag suite: under a slow/contended CI runner the /flags (and batch) request didn't reach the mock server within the hardcoded 15s XCTWaiter timeout, so waitFlagsRequest/getBatchedEvents failed with "the expected requests never arrived" and the flag getters then read empty state. A re-run of the same commit passed, confirming it's slow-CI flakiness, not a regression. PollingDefaults.timeout was already bumped to 30s for the Nimble poll path; the XCTWaiter request-arrival helpers were left at 15s. Unify them on a single 30s constant so the fast path still returns immediately and only a contended runner pays the longer ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) --- PostHogTests/TestUtils/TestPostHog.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 61c5f3034a..d73be3a2b9 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -29,7 +29,13 @@ final class TestPollingConfiguration: QuickConfiguration { } } -func getBatchedEvents(_ server: MockPostHogServer, timeout: TimeInterval = 15.0, failIfNotCompleted: Bool = true) -> [PostHogEvent] { +// Shared CI runners run several times slower than local, so the request-arrival waits below need the +// same generous ceiling we give Nimble's PollingDefaults above — otherwise a starved background flush +// makes "the expected requests never arrived" flake. The fast path still returns as soon as the +// request lands; the ceiling only matters when the runner is contended. +let testRequestTimeout: TimeInterval = 30.0 + +func getBatchedEvents(_ server: MockPostHogServer, timeout: TimeInterval = testRequestTimeout, failIfNotCompleted: Bool = true) -> [PostHogEvent] { let result = XCTWaiter.wait(for: [server.batchExpectation!], timeout: timeout) if result != XCTWaiter.Result.completed, failIfNotCompleted { @@ -46,7 +52,7 @@ func getBatchedEvents(_ server: MockPostHogServer, timeout: TimeInterval = 15.0, } func waitFlagsRequest(_ server: MockPostHogServer) { - let result = XCTWaiter.wait(for: [server.flagsExpectation!], timeout: 15) + let result = XCTWaiter.wait(for: [server.flagsExpectation!], timeout: testRequestTimeout) if result != XCTWaiter.Result.completed { XCTFail("The expected requests never arrived") @@ -59,7 +65,7 @@ func waitFlagsRequest(_ server: MockPostHogServer) { func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _: PostHogSDK) { let flagsLoaded = XCTNSNotificationExpectation(name: PostHogSDK.didReceiveFeatureFlags) waitFlagsRequest(server) - if XCTWaiter.wait(for: [flagsLoaded], timeout: 10) != .completed { + if XCTWaiter.wait(for: [flagsLoaded], timeout: testRequestTimeout) != .completed { XCTFail("Feature flags were not loaded in time") } } @@ -70,7 +76,7 @@ func waitForSnapshotRequest(_ server: MockPostHogServer) async throws { } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let result = XCTWaiter.wait(for: [expectation], timeout: 15) + let result = XCTWaiter.wait(for: [expectation], timeout: testRequestTimeout) switch result { case .completed: @@ -101,7 +107,7 @@ func getServerEvents(_ server: MockPostHogServer) async throws -> [PostHogEvent] } return try await withCheckedThrowingContinuation { continuation in - let result = XCTWaiter.wait(for: [expectation], timeout: 15) + let result = XCTWaiter.wait(for: [expectation], timeout: testRequestTimeout) switch result { case .completed: From 8a50361706973d3a03ab80b6f840f13149954f2b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 19:48:30 -0400 Subject: [PATCH 4/9] test: replace busy-wait spin loops with an event-driven latch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote-config and sampling suites waited on async load callbacks with `while !flag, Date() < timeout {}` — a CPU-spinning busy-wait that blocks a thread for the full timeout and, on the Swift cooperative pool, can starve the very callback it's waiting on (the same anti-pattern we already removed from the session-replay trigger tests). Replace all 16 with AsyncLatch: a small thread-safe counting latch that resumes the instant the awaited callback(s) fire. The timeout becomes a safety net only (so a regression fails fast instead of hanging), and an optional `settle` preserves the few small post-callback delays those tests relied on. Verified on the iOS simulator (4x, deterministic) and macOS (make test). Co-Authored-By: Claude Opus 4.8 (1M context) --- PostHogTests/PostHogRemoteConfigTest.swift | 172 ++++++--------------- PostHogTests/PostHogSamplingTest.swift | 20 +-- PostHogTests/TestUtils/TestPostHog.swift | 72 +++++++++ 3 files changed, 129 insertions(+), 135 deletions(-) diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index 1d562372e7..5f844976e2 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -101,20 +101,15 @@ enum PostHogRemoteConfigTest { config.storageManager = PostHogStorageManager(config) let sut = getSut(config: config) - var featureFlagsLoaded = false - var remoteConfigLoaded = false + let bothLoaded = AsyncLatch(count: 2) let token1 = sut.onFeatureFlagsLoaded.subscribe { _ in - featureFlagsLoaded = true + bothLoaded.signal() } let token2 = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + bothLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) // 2 second timeout - while !remoteConfigLoaded || !featureFlagsLoaded, Date() < timeout {} - continuation.resume() - } + await bothLoaded.wait() #expect(sut.getRemoteConfig() != nil) #expect(sut.getFeatureFlags() != nil) @@ -130,20 +125,16 @@ enum PostHogRemoteConfigTest { let sut = getSut(config: config) var featureFlagsLoaded = false - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token1 = sut.onFeatureFlagsLoaded.subscribe { _ in featureFlagsLoaded = true } let token2 = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) // 2 second timeout - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(featureFlagsLoaded == false) #expect(sut.getRemoteConfig() != nil) @@ -169,18 +160,16 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) var featureFlagsLoaded = false + let flagsLoaded = AsyncLatch() let token = sut.onFeatureFlagsLoaded.subscribe { _ in featureFlagsLoaded = true + flagsLoaded.signal() } #expect(sut.getFeatureFlag("some-flag") as? Bool == true) // wait for flags to be loaded - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) // 2 second timeout - while !featureFlagsLoaded, Date() < timeout {} - continuation.resume() - } + await flagsLoaded.wait() // test for new value #expect(featureFlagsLoaded == true) @@ -208,20 +197,13 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - // wait for flags to be loaded - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) // 2 second timeout - while !remoteConfigLoaded, Date() < timeout {} - // need a small delay because of the timing of the check above - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - continuation.resume() - } - } + // wait for the config to load, then let the post-callback writes settle + await remoteConfigLoaded.wait(settle: 0.1) // test for empty cache #expect(storage.getDictionary(forKey: .flags).isNilOrEmpty == true) @@ -246,20 +228,13 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - var featureFlagsLoaded = false + let featureFlagsLoaded = AsyncLatch() let token = sut.onFeatureFlagsLoaded.subscribe { _ in - featureFlagsLoaded = true + featureFlagsLoaded.signal() } - // wait for flags to be loaded - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) // 2 second timeout - while !featureFlagsLoaded, Date() < timeout {} - // need a small delay because of the timing of the check above - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - continuation.resume() - } - } + // wait for flags to load, then let the post-callback writes settle + await featureFlagsLoaded.wait(settle: 0.1) // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true) @@ -282,20 +257,13 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - var featureFlagsLoaded = false + let featureFlagsLoaded = AsyncLatch() let token = sut.onFeatureFlagsLoaded.subscribe { _ in - featureFlagsLoaded = true + featureFlagsLoaded.signal() } - // wait for flags to be loaded - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) // 2 second timeout - while !featureFlagsLoaded, Date() < timeout {} - // need a small delay because of the timing of the check above - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - continuation.resume() - } - } + // wait for flags to load, then let the post-callback writes settle + await featureFlagsLoaded.wait(settle: 0.1) // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true) @@ -318,21 +286,18 @@ enum PostHogRemoteConfigTest { var firstDone = false var secondDone = false + let bothDone = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "first", anonymousId: nil, groups: [:]) { _ in firstDone = true + bothDone.signal() } sut.loadFeatureFlags(distinctId: "second", anonymousId: nil, groups: [:]) { _ in secondDone = true + bothDone.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(10) - while !firstDone || !secondDone, Date() < timeout {} - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - continuation.resume() - } - } + await bothDone.wait(timeout: 10, settle: 0.2) #expect(firstDone) #expect(secondDone) @@ -349,23 +314,16 @@ enum PostHogRemoteConfigTest { server.flagsResponseDelay = 1.0 - var firstDone = false - var secondDone = false + let bothDone = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "anon_uuid", anonymousId: nil, groups: [:]) { _ in - firstDone = true + bothDone.signal() } sut.loadFeatureFlags(distinctId: "real_user_id", anonymousId: "anon_uuid", groups: [:]) { _ in - secondDone = true + bothDone.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(10) - while !firstDone || !secondDone, Date() < timeout {} - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - continuation.resume() - } - } + await bothDone.wait(timeout: 10, settle: 0.2) #expect(server.flagsRequests.count == 2) @@ -385,29 +343,20 @@ enum PostHogRemoteConfigTest { server.flagsResponseDelay = 1.0 - var firstDone = false var secondCallbackFired = false - var secondCallbackValue: [String: Any]? - var thirdDone = false + let firstAndThirdDone = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "first_id", anonymousId: nil, groups: [:]) { _ in - firstDone = true + firstAndThirdDone.signal() } - sut.loadFeatureFlags(distinctId: "second_id", anonymousId: nil, groups: [:]) { flags in + sut.loadFeatureFlags(distinctId: "second_id", anonymousId: nil, groups: [:]) { _ in secondCallbackFired = true - secondCallbackValue = flags } sut.loadFeatureFlags(distinctId: "third_id", anonymousId: nil, groups: [:]) { _ in - thirdDone = true + firstAndThirdDone.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(10) - while !firstDone || !thirdDone, Date() < timeout {} - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - continuation.resume() - } - } + await firstAndThirdDone.wait(timeout: 10, settle: 0.2) #expect(server.flagsRequests.count == 2) #expect(secondCallbackFired) @@ -429,21 +378,18 @@ enum PostHogRemoteConfigTest { var firstResult: [String: Any]? var secondResult: [String: Any]? + let bothResults = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "user1", anonymousId: nil, groups: [:]) { flags in firstResult = flags + bothResults.signal() } sut.loadFeatureFlags(distinctId: "user2", anonymousId: nil, groups: [:]) { flags in secondResult = flags + bothResults.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(10) - while firstResult == nil || secondResult == nil, Date() < timeout {} - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - continuation.resume() - } - } + await bothResults.wait(timeout: 10, settle: 0.2) #expect(firstResult != nil) #expect(secondResult != nil) @@ -916,16 +862,12 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(sut.isAutocaptureExceptionsEnabled() == true) #expect(storage.getDictionary(forKey: .remoteConfig) != nil) @@ -973,16 +915,12 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(sut.isAutocaptureExceptionsEnabled() == false) @@ -1008,16 +946,12 @@ enum PostHogRemoteConfigTest { // Should initially be true from cache #expect(sut.isAutocaptureExceptionsEnabled() == true) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(sut.isAutocaptureExceptionsEnabled() == false) @@ -1037,16 +971,12 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(sut.isAutocaptureExceptionsEnabled() == false) diff --git a/PostHogTests/PostHogSamplingTest.swift b/PostHogTests/PostHogSamplingTest.swift index 16efcc4c63..01cd4ab34c 100644 --- a/PostHogTests/PostHogSamplingTest.swift +++ b/PostHogTests/PostHogSamplingTest.swift @@ -236,16 +236,12 @@ class PostHogSamplingTests { let sut = getSut(config: config) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(sut.getRecordingSampleRate() == 0.5) _ = token @@ -263,16 +259,12 @@ class PostHogSamplingTests { let sut = getSut(config: config) - var remoteConfigLoaded = false + let remoteConfigLoaded = AsyncLatch() let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true + remoteConfigLoaded.signal() } - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } + await remoteConfigLoaded.wait() #expect(sut.getRecordingSampleRate() == nil) _ = token diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index d73be3a2b9..108c75fb89 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -124,6 +124,78 @@ final class MockDate { var date = Date() } +/// Event-driven replacement for the `while !flag, Date() < timeout {}` busy-waits that used to peg a +/// thread for up to N seconds — and, on the cooperative pool, could starve the very callback they were +/// waiting on. Create it with the number of callbacks to await, call `signal()` from each, then +/// `await wait()`. It resumes the instant the last signal lands; the timeout is only a safety net so a +/// regression fails fast instead of hanging the whole run. +final class AsyncLatch: @unchecked Sendable { + private let lock = NSLock() + private var remaining: Int + private var continuation: CheckedContinuation? + private var opened = false + + init(count: Int = 1) { + remaining = count + } + + /// Records one awaited callback; opens the latch once all of them have arrived. Thread-safe and + /// idempotent — extra calls after the latch opens are ignored. + func signal() { + lock.lock() + if opened { + lock.unlock() + return + } + remaining -= 1 + let shouldOpen = remaining <= 0 + let waiter = shouldOpen ? takeWaiterLocked() : nil + lock.unlock() + waiter?.resume() + } + + /// Suspends until every `signal()` has landed or `timeout` seconds elapse. `settle` lets trailing + /// async work finish before returning, preserving the small post-callback delays a few of the old + /// waits relied on. + func wait(timeout: TimeInterval = 10, settle: TimeInterval = 0) async { + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + self.forceOpen() + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + let alreadyOpen = opened + if !alreadyOpen { + self.continuation = continuation + } + lock.unlock() + if alreadyOpen { + continuation.resume() + } + } + timeoutTask.cancel() + if settle > 0 { + try? await Task.sleep(nanoseconds: UInt64(settle * 1_000_000_000)) + } + } + + private func forceOpen() { + lock.lock() + let waiter = opened ? nil : takeWaiterLocked() + lock.unlock() + waiter?.resume() + } + + /// Marks the latch open and hands back the pending waiter (if any) to resume outside the lock. + /// Must be called with `lock` held. + private func takeWaiterLocked() -> CheckedContinuation? { + opened = true + let waiter = continuation + continuation = nil + return waiter + } +} + extension Bundle { static var test: Bundle { #if SWIFT_PACKAGE From 2699b6922dab7ee163d2151923638b33f4daf302 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 20:33:47 -0400 Subject: [PATCH 5/9] test: drop fixed settle delays and deflake the reachability test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the latch refactor and the logs-queue flake: - AsyncLatch: 6 of the 7 preserved settle delays were unnecessary — the awaited callback already runs after the asserted state lands. Removed them and the settle param entirely, so the happy path is now purely event-driven (the only remaining Task.sleep is the timeout watchdog, which is cancelled the instant the latch opens and never completes in a passing run). - The one site that genuinely needed it (cache cleared asynchronously after onRemoteConfigLoaded, with no signal to await) now polls the real end state via a shared waitUntil helper — returns the instant it clears instead of a blind delay. - PostHogLogsQueueTest reachability test: construct Reachability with notificationQueue: nil so the initial check notifies synchronously during start(), instead of dispatching an async onReachable to main that lands after our manual onUnreachable and wipes the paused flag. Deflakes it deterministically. Verified: RemoteConfig 5x, LogsQueue 3x (macOS) and all three suites on the iOS simulator. Co-Authored-By: Claude Opus 4.8 (1M context) --- PostHogTests/PostHogLogsQueueTest.swift | 14 ++++++------- PostHogTests/PostHogRemoteConfigTest.swift | 24 +++++++++++++--------- PostHogTests/TestUtils/TestPostHog.swift | 23 ++++++++++++++------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/PostHogTests/PostHogLogsQueueTest.swift b/PostHogTests/PostHogLogsQueueTest.swift index aa7afd179e..0d39012295 100644 --- a/PostHogTests/PostHogLogsQueueTest.swift +++ b/PostHogTests/PostHogLogsQueueTest.swift @@ -725,7 +725,12 @@ final class PostHogLogsQueueTests { @Test("flush is suppressed while reachability reports unreachable, resumes on reconnect") func reachabilityPauseAndResume() async throws { - let reachability = try Reachability() + // `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) let (queue, _) = makeQueue( reachability: reachability, disableReachabilityForTesting: false @@ -734,12 +739,7 @@ final class PostHogLogsQueueTests { queue.stop() } - // The queue's `start()` calls `reachability.startNotifier()`, which - // arms the system SCNetworkReachability callback. On a live CI runner - // that's online, the system fires an async `onReachable` after start - // — racing with our manual events and wiping the paused flag. Stop - // the notifier here so only the manual `invoke(...)` calls below - // drive state. + // 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. diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index 5f844976e2..89a7d9de49 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -202,8 +202,12 @@ enum PostHogRemoteConfigTest { remoteConfigLoaded.signal() } - // wait for the config to load, then let the post-callback writes settle - await remoteConfigLoaded.wait(settle: 0.1) + await remoteConfigLoaded.wait() + + // The cache clearing runs asynchronously after onRemoteConfigLoaded fires and isn't tied to + // any callback we can await, so poll the end state — returns the instant it clears rather + // than blocking on a fixed delay. + await waitUntil { storage.getDictionary(forKey: .enabledFeatureFlags).isNilOrEmpty } // test for empty cache #expect(storage.getDictionary(forKey: .flags).isNilOrEmpty == true) @@ -233,8 +237,8 @@ enum PostHogRemoteConfigTest { featureFlagsLoaded.signal() } - // wait for flags to load, then let the post-callback writes settle - await featureFlagsLoaded.wait(settle: 0.1) + // wait for flags to load + await featureFlagsLoaded.wait() // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true) @@ -262,8 +266,8 @@ enum PostHogRemoteConfigTest { featureFlagsLoaded.signal() } - // wait for flags to load, then let the post-callback writes settle - await featureFlagsLoaded.wait(settle: 0.1) + // wait for flags to load + await featureFlagsLoaded.wait() // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true) @@ -297,7 +301,7 @@ enum PostHogRemoteConfigTest { bothDone.signal() } - await bothDone.wait(timeout: 10, settle: 0.2) + await bothDone.wait(timeout: 10) #expect(firstDone) #expect(secondDone) @@ -323,7 +327,7 @@ enum PostHogRemoteConfigTest { bothDone.signal() } - await bothDone.wait(timeout: 10, settle: 0.2) + await bothDone.wait(timeout: 10) #expect(server.flagsRequests.count == 2) @@ -356,7 +360,7 @@ enum PostHogRemoteConfigTest { firstAndThirdDone.signal() } - await firstAndThirdDone.wait(timeout: 10, settle: 0.2) + await firstAndThirdDone.wait(timeout: 10) #expect(server.flagsRequests.count == 2) #expect(secondCallbackFired) @@ -389,7 +393,7 @@ enum PostHogRemoteConfigTest { bothResults.signal() } - await bothResults.wait(timeout: 10, settle: 0.2) + await bothResults.wait(timeout: 10) #expect(firstResult != nil) #expect(secondResult != nil) diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 108c75fb89..4e84b42757 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -124,6 +124,17 @@ final class MockDate { var date = Date() } +/// Adaptive poll that returns the instant `condition` holds (or after `timeout`). Yields the thread +/// between checks instead of spinning. Use this only when the state you're asserting isn't tied to an +/// awaitable signal (e.g. an async storage write with no completion callback) — when a callback exists, +/// prefer the event-driven `AsyncLatch`, which needs no polling at all. +func waitUntil(timeout: TimeInterval = 5, poll: TimeInterval = 0.005, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: UInt64(poll * 1_000_000_000)) + } +} + /// Event-driven replacement for the `while !flag, Date() < timeout {}` busy-waits that used to peg a /// thread for up to N seconds — and, on the cooperative pool, could starve the very callback they were /// waiting on. Create it with the number of callbacks to await, call `signal()` from each, then @@ -154,10 +165,11 @@ final class AsyncLatch: @unchecked Sendable { waiter?.resume() } - /// Suspends until every `signal()` has landed or `timeout` seconds elapse. `settle` lets trailing - /// async work finish before returning, preserving the small post-callback delays a few of the old - /// waits relied on. - func wait(timeout: TimeInterval = 10, settle: TimeInterval = 0) async { + /// Suspends until every `signal()` has landed, then returns immediately — no polling, no delay. + /// `timeout` is only a safety net: if a callback never fires (a regression), it resumes after the + /// deadline so the test fails fast instead of hanging. In a passing run the timeout Task is + /// cancelled the instant the latch opens, so its sleep never completes. + func wait(timeout: TimeInterval = 10) async { let timeoutTask = Task { try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) self.forceOpen() @@ -174,9 +186,6 @@ final class AsyncLatch: @unchecked Sendable { } } timeoutTask.cancel() - if settle > 0 { - try? await Task.sleep(nanoseconds: UInt64(settle * 1_000_000_000)) - } } private func forceOpen() { From b8b2882af571e923f32843d6e9f94b784bc2dc9d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 21:25:02 -0400 Subject: [PATCH 6/9] test: add Swift Testing state isolation, scoped clock helper, and CI flake visibility Hardening the test system after the flakes this branch surfaced: - #1 Central isolation: Swift Testing has no global beforeEach (only the Quick side did, via TestPollingConfiguration). Add a ResetGlobalStateTrait (.resetsGlobalState) that resets the mocked 'now' clock and the RN 'postHogSdkName' before and after every test, and apply it to the suites that mutate those globals (session manager, logs capture/queue, multicast, app-view-layout). Stops a value one test leaves behind from bleeding into the next. - #4 Scoped clock: add withMockedNow / withMockedClock so a 'now' override auto-restores via the call scope instead of a hand-written defer. Migrated the logs-queue rate-cap test to it as the first adopter. - #2 CI flake visibility: the iOS-sim job retries failed tests 3x, which can mask flakiness. Tee the raw xcodebuild log and add a best-effort step that surfaces any test which only passed after a retry to the job summary (handles both the Swift Testing and XCTest line formats); never fails the job. Documented the intentional macOS-strict / iOS-retry asymmetry. - #3 Reachability: confirmed the remaining real-Reachability tests (context, reachability-multicast) never arm the system notifier, so they have no async-callback race to fix. Verified: full macOS suite (564 tests / 115 suites) green; trait-applied suites green on the iOS simulator; flake-detection parser validated against both output formats. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 23 +++++++++ Makefile | 6 ++- .../ApplicationViewLayoutPublisherTest.swift | 2 +- PostHogTests/PostHogLogsCaptureTest.swift | 2 +- PostHogTests/PostHogLogsQueueTest.swift | 51 +++++++++---------- .../PostHogMulticastCallbackTest.swift | 4 +- PostHogTests/PostHogSessionManagerTest.swift | 2 +- PostHogTests/TestUtils/TestPostHog.swift | 51 +++++++++++++++++++ 8 files changed, 107 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55bd369380..830f88265c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,30 @@ jobs: with: xcode-version: latest-stable - name: Test SDK on iOS Simulator + # Retries (3 iterations) protect merges from transient simulator flakiness; the macOS `test` + # job runs without retries, so a genuine flake still surfaces as a hard failure there. run: make testOniOSSimulator + - name: Report flaky (retried) tests + # Retries can hide flakiness by turning a transient red into green. Surface any test that only + # passed after a retry so flakes get tracked and fixed instead of silently masked. Best-effort: + # never fails the job. + if: always() + run: | + log=xcodebuild-ios.log + [ -f "$log" ] || { echo "No build log found; skipping flake report."; exit 0; } + # A test that has both a 'failed' and a 'passed' line was retried (flaky). Handle both the + # Swift Testing format (✔ Test "name" passed) and the XCTest format (Test Case '-[…]' passed). + passed=$( { grep -oE 'Test "[^"]+" passed' "$log" | sed -E 's/^Test "//; s/" passed$//'; \ + grep -oE "Test Case '[^']+' passed" "$log" | sed -E "s/^Test Case '//; s/' passed$//"; } | sort -u) + failed=$( { grep -oE 'Test "[^"]+" failed' "$log" | sed -E 's/^Test "//; s/" failed$//'; \ + grep -oE "Test Case '[^']+' failed" "$log" | sed -E "s/^Test Case '//; s/' failed$//"; } | sort -u) + flaky=$(comm -12 <(printf '%s\n' "$failed") <(printf '%s\n' "$passed") | grep -v '^$' || true) + if [ -n "$flaky" ]; then + { echo "### ⚠️ Flaky tests (passed only after a retry)"; echo; echo '```'; echo "$flaky"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + echo "::warning::Flaky tests detected (passed on retry); see the job summary." + else + echo "No flaky (retried) tests detected." >> "$GITHUB_STEP_SUMMARY" + fi downgrade-compatibility: runs-on: macos-15 diff --git a/Makefile b/Makefile index 38b7d9b756..e74d271e98 100644 --- a/Makefile +++ b/Makefile @@ -77,12 +77,14 @@ swiftFormat: # -retry-tests-on-failure -test-iterations 3: a few tests assert real-time behaviour (autocapture # debounce/flush windows) that can't be made deterministic; on slow, load-variable CI runners those # windows occasionally slip. Rerun a *failed* test up to 3 times so a transient miss doesn't fail the -# job — a genuinely broken test fails all 3 and stays red. +# job — a genuinely broken test fails all 3 and stays red. Retries can *mask* flakiness, so we tee the +# raw log to xcodebuild-ios.log; CI reads it back to surface tests that only passed after a retry (the +# macOS `test` job runs without retries, so a genuine flake still hard-fails there). testOniOSSimulator: @device="$$(xcrun simctl list devices available | grep -E '^[[:space:]]*iPhone' | head -1 | sed -E 's/^[[:space:]]*//; s/ \(.*//')"; \ [ -n "$$device" ] || { echo "No available iPhone simulator found; install one via Xcode or 'xcrun simctl create'."; exit 1; }; \ echo "Testing on simulator: $$device"; \ - set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination "platform=iOS Simulator,name=$$device" -retry-tests-on-failure -test-iterations 3 | xcpretty + set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination "platform=iOS Simulator,name=$$device" -retry-tests-on-failure -test-iterations 3 | tee xcodebuild-ios.log | xcpretty testOnMacSimulator: set -o pipefail && xcrun xcodebuild test -scheme PostHog -destination 'platform=macOS' | xcpretty diff --git a/PostHogTests/ApplicationViewLayoutPublisherTest.swift b/PostHogTests/ApplicationViewLayoutPublisherTest.swift index 9f63512509..3a64cc4aef 100644 --- a/PostHogTests/ApplicationViewLayoutPublisherTest.swift +++ b/PostHogTests/ApplicationViewLayoutPublisherTest.swift @@ -10,7 +10,7 @@ @testable import PostHog import Testing - @Suite("Application View Publisher Test", .serialized) + @Suite("Application View Publisher Test", .serialized, .resetsGlobalState) final class ApplicationViewLayoutPublisherTest { var registrationToken: RegistrationToken? diff --git a/PostHogTests/PostHogLogsCaptureTest.swift b/PostHogTests/PostHogLogsCaptureTest.swift index 7e00da4dc1..e519dd5ebd 100644 --- a/PostHogTests/PostHogLogsCaptureTest.swift +++ b/PostHogTests/PostHogLogsCaptureTest.swift @@ -10,7 +10,7 @@ import OHHTTPStubsSwift import Testing import XCTest -@Suite("PostHog logs capture", .serialized) +@Suite("PostHog logs capture", .serialized, .resetsGlobalState) final class PostHogLogsCaptureTests { private var server: MockPostHogServer let mockAppLifecycle: MockApplicationLifecyclePublisher diff --git a/PostHogTests/PostHogLogsQueueTest.swift b/PostHogTests/PostHogLogsQueueTest.swift index 0d39012295..46bd055f22 100644 --- a/PostHogTests/PostHogLogsQueueTest.swift +++ b/PostHogTests/PostHogLogsQueueTest.swift @@ -10,7 +10,7 @@ import OHHTTPStubsSwift import Testing import XCTest -@Suite("PostHog logs queue", .serialized) +@Suite("PostHog logs queue", .serialized, .resetsGlobalState) final class PostHogLogsQueueTests { private var server: MockPostHogServer @@ -480,35 +480,32 @@ final class PostHogLogsQueueTests { @Test("rate cap window resets after the configured interval") func rateCapWindowResets() async throws { - // Drive the rate-cap clock via the `now` seam in DateUtils so the test - // is deterministic. Restore on exit so we don't leak state to other - // serialized tests. - var current = Date() - now = { current } - defer { now = { Date() } } - - let (queue, _) = makeQueue( - maxBufferSize: 100, - maxBatchSize: 100, - rateCapMaxLogs: 2, - rateCapWindowSeconds: 10 - ) - defer { queue.clear() - queue.stop() - } + // Drive the rate-cap clock via the `now` seam so the test is deterministic; withMockedClock + // restores the global clock on exit so we don't leak state to other serialized tests. + try await withMockedClock { clock in + let (queue, _) = makeQueue( + maxBufferSize: 100, + maxBatchSize: 100, + rateCapMaxLogs: 2, + rateCapWindowSeconds: 10 + ) + defer { queue.clear() + queue.stop() + } - queue.add(makeRecord(body: "1")) - queue.add(makeRecord(body: "2")) - queue.add(makeRecord(body: "dropped")) // exceeds cap - await waitUntil { queue.depth == 2 } - #expect(queue.depth == 2) + queue.add(makeRecord(body: "1")) + queue.add(makeRecord(body: "2")) + queue.add(makeRecord(body: "dropped")) // exceeds cap + await waitUntil { queue.depth == 2 } + #expect(queue.depth == 2) - // Advance past the window edge without sleeping. - current = current.addingTimeInterval(11) + // Advance past the window edge without sleeping. + clock.date = clock.date.addingTimeInterval(11) - queue.add(makeRecord(body: "after-window")) - await waitUntil { queue.depth == 3 } - #expect(queue.depth == 3) + queue.add(makeRecord(body: "after-window")) + await waitUntil { queue.depth == 3 } + #expect(queue.depth == 3) + } } @Test("rate cap disabled when rateCapMaxLogs == 0") diff --git a/PostHogTests/PostHogMulticastCallbackTest.swift b/PostHogTests/PostHogMulticastCallbackTest.swift index 031435cbc7..e851ef20df 100644 --- a/PostHogTests/PostHogMulticastCallbackTest.swift +++ b/PostHogTests/PostHogMulticastCallbackTest.swift @@ -2,7 +2,7 @@ import Foundation @testable import PostHog import Testing -@Suite("PostHogMulticastCallback Tests") +@Suite("PostHogMulticastCallback Tests", .resetsGlobalState) class PostHogMulticastCallbackTests { @Test("Single subscriber receives value") func singleSubscriber() { @@ -97,7 +97,7 @@ class PostHogMulticastCallbackTests { } } -@Suite("PostHogThrottledMulticastCallback Tests") +@Suite("PostHogThrottledMulticastCallback Tests", .resetsGlobalState) class PostHogThrottledMulticastCallbackTests { @Test("Single subscriber receives value with throttle") func singleSubscriber() async { diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 7d7b49470b..e75a5fa13b 100644 --- a/PostHogTests/PostHogSessionManagerTest.swift +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -9,7 +9,7 @@ import Foundation @testable import PostHog import Testing -@Suite(.serialized) +@Suite(.serialized, .resetsGlobalState) enum PostHogSessionManagerTest { @Suite("Test session id rotation logic") struct SessionRotation { diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 4e84b42757..771b45b3a9 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -9,6 +9,7 @@ import Foundation import Nimble @testable import PostHog import Quick +import Testing import XCTest final class TestPollingConfiguration: QuickConfiguration { @@ -124,6 +125,56 @@ final class MockDate { var date = Date() } +// MARK: - Global state isolation + +/// Resets the process-global state that tests mutate, so a value one test leaves behind can't bleed +/// into the next. Swift Testing has no global `beforeEach` (unlike the Quick `TestPollingConfiguration` +/// above), so suites that touch these globals carry `.resetsGlobalState`; new code should prefer the +/// scoped `withMockedNow`/`withMockedClock` helpers, which can't forget to restore. +func resetPostHogTestGlobals() { + now = { Date() } + postHogSdkName = postHogiOSSdkName +} + +/// Pins the global `now` clock to `date` for the duration of `body`, then restores whatever was set +/// before — so the override can't leak even if `body` throws. Prefer this over a raw +/// `now = ...; defer { now = { Date() } }`. +func withMockedNow(_ date: @escaping () -> Date, perform body: () async throws -> T) async rethrows -> T { + let previous = now + now = date + defer { now = previous } + return try await body() +} + +/// Like `withMockedNow`, but hands `body` a mutable `MockDate` so it can advance time mid-test. +func withMockedClock(perform body: (MockDate) async throws -> T) async rethrows -> T { + let clock = MockDate() + return try await withMockedNow({ clock.date }) { try await body(clock) } +} + +/// Swift Testing trait that runs `resetPostHogTestGlobals()` before and after every test in the suite — +/// the equivalent of the Quick `beforeEach` reset the XCTest side already has. Attach to any suite that +/// mutates a shared global: `@Suite(..., .resetsGlobalState)`. +struct ResetGlobalStateTrait: TestTrait, SuiteTrait, TestScoping { + var isRecursive: Bool { true } + + func provideScope(for _: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { + // testCase is nil for the suite-level scope; only wrap actual test cases. + guard testCase != nil else { + try await function() + return + } + resetPostHogTestGlobals() + defer { resetPostHogTestGlobals() } + try await function() + } +} + +extension Trait where Self == ResetGlobalStateTrait { + /// Resets `now`/`postHogSdkName` around each test in the suite. See ``ResetGlobalStateTrait``. + static var resetsGlobalState: ResetGlobalStateTrait { ResetGlobalStateTrait() } +} + /// Adaptive poll that returns the instant `condition` holds (or after `timeout`). Yields the thread /// between checks instead of spinning. Use this only when the state you're asserting isn't tied to an /// awaitable signal (e.g. an async storage write with no completion callback) — when a callback exists, From bd230e674a0b4b4e7013c9f988768bcb31bf7ca6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 22:16:17 -0400 Subject: [PATCH 7/9] test: correlate feature-flags wait to the instance (independent review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing an independent testing review: - waitForFeatureFlagsLoaded observed the global didReceiveFeatureFlags notification (posted object: nil for every load), which a prior, still-draining SUT could satisfy. Observe this instance's onFeatureFlagsLoaded instead, subscribed before the request so it can't be missed — ties the wait to this test's /flags response. - Document AsyncLatch's single-waiter contract and that its timeout watchdog deliberately does not fail the test (some callers wait on a signal that may legitimately never arrive, then assert regardless — surfacing those timeouts as failures would require reworking those tests, tracked separately). Verified: full macOS suite (564/115) green; PostHogSDKTest (77) and remote config suites green on the iOS simulator. Co-Authored-By: Claude Opus 4.8 (1M context) --- PostHogTests/TestUtils/TestPostHog.swift | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 771b45b3a9..8714adc603 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -60,15 +60,20 @@ func waitFlagsRequest(_ server: MockPostHogServer) { } } -// lastRequestId can already be non-nil (restored from disk or an earlier load), so waiting on it -// would pass before the fresh response lands. didReceiveFeatureFlags fires only after a /flags -// response is stored; observe it from before the request so the notification can't be missed. -func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _: PostHogSDK) { - let flagsLoaded = XCTNSNotificationExpectation(name: PostHogSDK.didReceiveFeatureFlags) +// Waits for *this* SDK's feature flags to finish loading. `lastRequestId` can already be non-nil +// (restored from disk or an earlier load), so waiting on it would pass before the fresh response lands. +// The global `didReceiveFeatureFlags` notification (posted object: nil for every load) could likewise +// be satisfied by a prior, still-draining SUT. So observe this instance's `onFeatureFlagsLoaded` — +// which fires only after the response is stored — subscribed before the request so it can't be missed. +func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _ sut: PostHogSDK) { + let flagsLoaded = XCTestExpectation(description: "feature flags loaded") + flagsLoaded.assertForOverFulfill = false // the callback fires once per load; don't fail on extra loads + let token = sut.remoteConfig?.onFeatureFlagsLoaded.subscribe { _ in flagsLoaded.fulfill() } waitFlagsRequest(server) if XCTWaiter.wait(for: [flagsLoaded], timeout: testRequestTimeout) != .completed { XCTFail("Feature flags were not loaded in time") } + _ = token // hold the subscription for the duration of the wait } func waitForSnapshotRequest(_ server: MockPostHogServer) async throws { @@ -191,6 +196,8 @@ func waitUntil(timeout: TimeInterval = 5, poll: TimeInterval = 0.005, _ conditio /// waiting on. Create it with the number of callbacks to await, call `signal()` from each, then /// `await wait()`. It resumes the instant the last signal lands; the timeout is only a safety net so a /// regression fails fast instead of hanging the whole run. +/// Single-waiter: call `wait()` exactly once per latch. A second concurrent `wait()` would overwrite +/// the stored continuation and orphan the first (it would never resume). No current caller does this. final class AsyncLatch: @unchecked Sendable { private let lock = NSLock() private var remaining: Int @@ -217,9 +224,11 @@ final class AsyncLatch: @unchecked Sendable { } /// Suspends until every `signal()` has landed, then returns immediately — no polling, no delay. - /// `timeout` is only a safety net: if a callback never fires (a regression), it resumes after the - /// deadline so the test fails fast instead of hanging. In a passing run the timeout Task is - /// cancelled the instant the latch opens, so its sleep never completes. + /// `timeout` is only a safety net: if a callback never fires, the watchdog resumes the waiter after + /// the deadline so the test fails fast (on its own assertions) instead of hanging the whole run. In + /// a passing run the watchdog is cancelled the instant the latch opens (its `forceOpen` then runs + /// but is a no-op — already open). NOTE: the watchdog deliberately does NOT fail the test on its own + /// — some callers wait on a signal that may legitimately never arrive and then assert regardless. func wait(timeout: TimeInterval = 10) async { let timeoutTask = Task { try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) From fbd2fbb5f719d244418aca476e5ed7d8acbee24a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 22:44:29 -0400 Subject: [PATCH 8/9] test: wait on the signal that actually fires for hasFeatureFlags-missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'should not clear flags if hasFeatureFlags key is missing' test waited on onFeatureFlagsLoaded, but when the key is absent the SDK takes the 'leave flags alone' branch and never fires it (PostHogRemoteConfig.swift:118-126) — so the latch silently ran to its 10s timeout every run and the test passed only because its assertion holds regardless. Wait on onRemoteConfigLoaded, which fires after the clear-or-keep decision. Test now completes in ~5ms instead of 10s (and the suite drops from ~19s to ~9s). Co-Authored-By: Claude Opus 4.8 (1M context) --- PostHogTests/PostHogRemoteConfigTest.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index 89a7d9de49..e06483f989 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -261,13 +261,16 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - let featureFlagsLoaded = AsyncLatch() - let token = sut.onFeatureFlagsLoaded.subscribe { _ in - featureFlagsLoaded.signal() + // With `hasFeatureFlags` absent from the response, the SDK takes the "leave flags alone" + // branch and never fires onFeatureFlagsLoaded (it would only fire from a subsequent + // preload). The clear-or-keep decision is made before onRemoteConfigLoaded, so wait on that + // — otherwise the latch just sleeps to its timeout. + let remoteConfigLoaded = AsyncLatch() + let token = sut.onRemoteConfigLoaded.subscribe { _ in + remoteConfigLoaded.signal() } - // wait for flags to load - await featureFlagsLoaded.wait() + await remoteConfigLoaded.wait() // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true) From d9d0fd026ed0e6d126377e6caf474bca5d230a06 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 8 Jun 2026 22:51:44 -0400 Subject: [PATCH 9/9] test: revert hasFeatureFlags-missing to onFeatureFlagsLoaded, cap settle at 2s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 'wait on onRemoteConfigLoaded' change was faster but not provably equivalent: onRemoteConfigLoaded is posted to main (PostHogRemoteConfig.swift:180) *before* the clear-or-keep decision runs in the reload callback (line 190) on the API queue, so a regression that wrongly cleared flags on a missing key could slip past the assertion. Restore the original onFeatureFlagsLoaded signal. That signal never actually fires for this path (reloadFeatureFlags early-returns because canReloadFlagsForTesting is false), so the wait is inherently a bounded settle — documented as such, and capped at 2s (the pre-existing value; the latch conversion had inflated it to the 10s default). Test asserts the cache survives after the async decision has run. Co-Authored-By: Claude Opus 4.8 (1M context) --- PostHogTests/PostHogRemoteConfigTest.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index e06483f989..e81019517d 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -261,16 +261,18 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage, config: config) - // With `hasFeatureFlags` absent from the response, the SDK takes the "leave flags alone" - // branch and never fires onFeatureFlagsLoaded (it would only fire from a subsequent - // preload). The clear-or-keep decision is made before onRemoteConfigLoaded, so wait on that - // — otherwise the latch just sleeps to its timeout. - let remoteConfigLoaded = AsyncLatch() - let token = sut.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded.signal() + let featureFlagsLoaded = AsyncLatch() + let token = sut.onFeatureFlagsLoaded.subscribe { _ in + featureFlagsLoaded.signal() } - await remoteConfigLoaded.wait() + // No completion signal fires for this path: with the key absent the SDK takes the + // "leave flags alone" branch, and reloadFeatureFlags early-returns (canReloadFlagsForTesting + // is false), so onFeatureFlagsLoaded never fires. This is therefore a bounded settle — wait + // out the timeout so the async clear-or-keep decision has definitely run, *then* assert the + // cache wasn't wiped. (A regression that wrongly cleared on a missing key would have done so + // by now.) Don't wait on onRemoteConfigLoaded: it's posted to main before that decision runs. + await featureFlagsLoaded.wait(timeout: 2) // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true)