diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 429a458709..830f88265c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,42 @@ 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 + # 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 strategy: diff --git a/Makefile b/Makefile index 0312d7fce1..e74d271e98 100644 --- a/Makefile +++ b/Makefile @@ -73,10 +73,18 @@ 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. 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: - 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/ \(.*//')"; \ + [ -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 | tee xcodebuild-ios.log | 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..3a64cc4aef 100644 --- a/PostHogTests/ApplicationViewLayoutPublisherTest.swift +++ b/PostHogTests/ApplicationViewLayoutPublisherTest.swift @@ -10,15 +10,27 @@ @testable import PostHog import Testing - @Suite("Application View Publisher Test", .serialized) + @Suite("Application View Publisher Test", .serialized, .resetsGlobalState) 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/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 aa7afd179e..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") @@ -725,7 +722,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 +736,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/PostHogMulticastCallbackTest.swift b/PostHogTests/PostHogMulticastCallbackTest.swift index f787299806..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 { @@ -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..e81019517d 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 { @@ -43,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") @@ -78,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) @@ -107,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) @@ -146,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) @@ -185,20 +197,17 @@ 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() - } - } + 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) @@ -223,20 +232,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 + await featureFlagsLoaded.wait() // check that cached flag was not removed #expect(sut.getFeatureFlag("foo") as? Bool == true) @@ -259,20 +261,18 @@ 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() - } - } + // 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) @@ -295,21 +295,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) #expect(firstDone) #expect(secondDone) @@ -326,23 +323,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) #expect(server.flagsRequests.count == 2) @@ -362,29 +352,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) #expect(server.flagsRequests.count == 2) #expect(secondCallbackFired) @@ -406,21 +387,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) #expect(firstResult != nil) #expect(secondResult != nil) @@ -473,7 +451,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 +465,35 @@ 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()) - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + // /config (returnReplay == false) now reports recording disabled, which must turn the flag off. + await reloadRemoteConfig(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) == nil) #expect(sut.isSessionReplayFlagActive() == false) } @@ -533,14 +507,9 @@ enum PostHogRemoteConfigTest { server.returnReplay = true - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) } @@ -556,14 +525,9 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) } @@ -580,14 +544,9 @@ enum PostHogRemoteConfigTest { server.returnReplayWithVariant = true server.replayVariantValue = false - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) } @@ -606,14 +565,9 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "web"] - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == true) } @@ -632,14 +586,9 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "mobile"] - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) } @@ -656,14 +605,9 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "some-missing-flag" server.flagsSkipReplayVariantName = true - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + await reloadConfigThenFlags(sut) - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) - #expect(config.snapshotEndpoint == "/newS/") + #expect(config.snapshotEndpoint == "/s/") #expect(sut.isSessionReplayFlagActive() == false) storage.reset() @@ -687,11 +631,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true - 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) @@ -719,11 +659,7 @@ enum PostHogRemoteConfigTest { server.replayVariantName = "recording-platform" server.replayVariantValue = ["flag": "recording-platform-check", "variant": "web"] - 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") @@ -746,11 +682,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true server.returnReplayWithVariant = true - 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) @@ -769,11 +701,7 @@ enum PostHogRemoteConfigTest { server.returnReplay = true - 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) @@ -789,31 +717,23 @@ 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. - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } + // /config (the only source of recording config) populates the in-memory recording + // state and persists the whole config to storage under .remoteConfig. + await reloadRemoteConfig(sut) #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. - await withCheckedContinuation { continuation in - sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: { _ in - continuation.resume() - }) - } + // 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 loadFeatureFlags(sut) #expect(sut.isSessionReplayFlagActive() == true) #expect(sut.getRecordingSampleRate() == 0.42) @@ -833,11 +753,9 @@ enum PostHogRemoteConfigTest { let sut = getSut(storage: storage) - // /config caches the recording config (with linkedFlag) under .sessionReplay. - await withCheckedContinuation { continuation in - sut.reloadRemoteConfig { _ in continuation.resume() } - } - #expect(storage.getDictionary(forKey: .sessionReplay) != nil) + // /config caches the recording config (with linkedFlag) under .remoteConfig. + await reloadRemoteConfig(sut) + #expect(storage.getDictionary(forKey: .remoteConfig) != nil) storage.reset() sut.clear() @@ -845,11 +763,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) } @@ -864,9 +778,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) @@ -876,11 +788,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) @@ -894,11 +802,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) } @@ -967,16 +871,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) @@ -996,9 +896,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 @@ -1009,11 +907,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) } @@ -1030,16 +924,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) @@ -1065,16 +955,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) @@ -1094,16 +980,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) @@ -1120,9 +1002,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() @@ -1130,11 +1010,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) } @@ -1149,11 +1025,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) } @@ -1175,9 +1047,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/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..01cd4ab34c 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) @@ -233,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 @@ -260,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/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 26c634995a..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 { @@ -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,16 @@ enum PostHogSessionManagerTest { posthog = PostHogSDK.with(config) } + 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 + 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..d1a891b5cb 100644 --- a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift +++ b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift @@ -25,31 +25,29 @@ 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 - return PostHogSDK.with(config) - } - - private func waitForRemoteConfig(_ sut: PostHogSDK) async { - guard let remoteConfig = sut.remoteConfig else { - return + // 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]) - var remoteConfigLoaded = false - let token = remoteConfig.onRemoteConfigLoaded.subscribe { _ in - remoteConfigLoaded = true - } + // 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() - await withCheckedContinuation { continuation in - let timeout = Date().addingTimeInterval(2) - while !remoteConfigLoaded, Date() < timeout {} - continuation.resume() - } - - _ = token + return PostHogSDK.with(config) } // MARK: - isActive() Tests @@ -57,8 +55,6 @@ @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) @@ -69,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) @@ -81,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) @@ -99,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) @@ -115,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) @@ -132,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) @@ -150,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") @@ -170,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) @@ -193,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) @@ -219,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/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..8714adc603 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -6,10 +6,37 @@ // import Foundation -import PostHog +import Nimble +@testable import PostHog +import Quick +import Testing import XCTest -func getBatchedEvents(_ server: MockPostHogServer, timeout: TimeInterval = 15.0, failIfNotCompleted: Bool = true) -> [PostHogEvent] { +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()) + } + } +} + +// 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 { @@ -26,20 +53,36 @@ 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") } } +// 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 { guard let expectation = server.snapshotExpectation else { throw TestError("Server is not properly configured with a snapshot expectation.") } 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: @@ -70,7 +113,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: @@ -87,6 +130,141 @@ 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, +/// 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 +/// `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 + 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, then returns immediately — no polling, no delay. + /// `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)) + 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() + } + + 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