From e700f2434c3b945382798f8225afd6646a6b495d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:28:46 -0700 Subject: [PATCH 01/24] ci(SDK-5040): parallelize CI jobs with KMP cache --- .github/workflows/ci.yml | 151 ++++++++++++++---- iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh | 11 +- 2 files changed, 126 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82c46101..338e9af01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,28 +8,138 @@ on: - '.github/**' - 'README.md' +env: + KMP_XCFRAMEWORK_PATH: OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework + XCODE_DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + jobs: - build: - name: Build and Test using any available iPhone simulator + lint: + name: Swift Lint + runs-on: macos-15-large + + steps: + - name: Checkout OneSignal-iOS-SDK + uses: actions/checkout@v4 + - name: Run Swift Lint + run: swiftlint + + kmp-xcframework: + name: Build OneSignalKMP XCFramework runs-on: macos-15-large steps: - name: Select Xcode Version - run: | - sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + run: sudo xcode-select -s "$XCODE_DEVELOPER_DIR" - name: Checkout OneSignal-iOS-SDK uses: actions/checkout@v4 with: submodules: recursive + - name: Create KMP cache key + id: kmp + run: | + kmp_sha="$(git rev-parse HEAD:OneSignal-KMP-SDK)" + xcode_hash="$(xcodebuild -version | shasum -a 256 | awk '{print $1}')" + echo "cache-key=kmp-xcframework-${{ runner.os }}-${{ runner.arch }}-${xcode_hash}-${kmp_sha}" >> "$GITHUB_OUTPUT" + - name: Restore OneSignalKMP XCFramework + id: kmp-cache + uses: actions/cache/restore@v4 + with: + path: ${{ env.KMP_XCFRAMEWORK_PATH }} + key: ${{ steps.kmp.outputs.cache-key }} - name: Setup JDK 17 + if: steps.kmp-cache.outputs.cache-hit != 'true' uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Setup Gradle + if: steps.kmp-cache.outputs.cache-hit != 'true' uses: gradle/actions/setup-gradle@v4 - - name: Build OneSignalKMP XCFramework - run: iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh + - name: Assemble OneSignalKMP XCFramework + if: steps.kmp-cache.outputs.cache-hit != 'true' + run: | + iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh \ + :kmp:assembleOneSignalKMPReleaseXCFramework + - name: Save OneSignalKMP XCFramework + if: steps.kmp-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ env.KMP_XCFRAMEWORK_PATH }} + key: ${{ steps.kmp.outputs.cache-key }} + - name: Package OneSignalKMP XCFramework + run: | + tar -czf "$RUNNER_TEMP/OneSignalKMP.xcframework.tar.gz" \ + -C "$(dirname "$KMP_XCFRAMEWORK_PATH")" \ + "$(basename "$KMP_XCFRAMEWORK_PATH")" + - name: Upload OneSignalKMP XCFramework + uses: actions/upload-artifact@v4 + with: + name: OneSignalKMP-XCFramework + path: ${{ runner.temp }}/OneSignalKMP.xcframework.tar.gz + retention-days: 1 + + ios-simulator: + name: Build and Test using any available iPhone simulator + runs-on: macos-15-large + needs: kmp-xcframework + + steps: + - name: Select Xcode Version + run: sudo xcode-select -s "$XCODE_DEVELOPER_DIR" + - name: Checkout OneSignal-iOS-SDK + uses: actions/checkout@v4 + - name: Download OneSignalKMP XCFramework + uses: actions/download-artifact@v4 + with: + name: OneSignalKMP-XCFramework + path: ${{ runner.temp }} + - name: Extract OneSignalKMP XCFramework + run: | + mkdir -p "$(dirname "$KMP_XCFRAMEWORK_PATH")" + tar -xzf "$RUNNER_TEMP/OneSignalKMP.xcframework.tar.gz" \ + -C "$(dirname "$KMP_XCFRAMEWORK_PATH")" + - name: Build + env: + scheme: ${{ 'UnitTestApp' }} + platform: ${{ 'iOS Simulator' }} + file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} + filetype_parameter: ${{ 'project' }} + run: | + # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) + device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` + xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" + - name: Test + env: + scheme: ${{ 'UnitTestApp' }} + test_plan: ${{ 'UnitTestApp_TestPlan_Reduced' }} + platform: ${{ 'iOS Simulator' }} + file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} + filetype_parameter: ${{ 'project' }} + run: | + # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) + device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` + xcodebuild test-without-building -scheme "$scheme" -testPlan "$test_plan" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" + + catalyst: + name: KMP logger Mac Catalyst integration + runs-on: macos-15-large + needs: kmp-xcframework + + steps: + - name: Select Xcode Version + run: sudo xcode-select -s "$XCODE_DEVELOPER_DIR" + - name: Checkout OneSignal-iOS-SDK + uses: actions/checkout@v4 + - name: Download OneSignalKMP XCFramework + uses: actions/download-artifact@v4 + with: + name: OneSignalKMP-XCFramework + path: ${{ runner.temp }} + - name: Extract OneSignalKMP XCFramework + run: | + mkdir -p "$(dirname "$KMP_XCFRAMEWORK_PATH")" + tar -xzf "$RUNNER_TEMP/OneSignalKMP.xcframework.tar.gz" \ + -C "$(dirname "$KMP_XCFRAMEWORK_PATH")" - name: Archive OneSignalFramework for Catalyst run: | xcodebuild archive \ @@ -57,32 +167,3 @@ jobs: iOS_SDK/OneSignalSDK/CatalystLoggerHost/main.swift \ -o "$host" DYLD_FRAMEWORK_PATH="$frameworks" "$host" - - name: Set Default Scheme - run: | - default="UnitTestApp" - echo $default | cat >default - echo Using default scheme: $default - - name: Run Swift Lint - run: | - swiftlint - - name: Build - env: - scheme: ${{ 'UnitTestApp' }} - platform: ${{ 'iOS Simulator' }} - file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} - filetype_parameter: ${{ 'project' }} - run: | - # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) - device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` - xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" - - name: Test - env: - scheme: ${{ 'UnitTestApp' }} - test_plan: ${{ 'UnitTestApp_TestPlan_Reduced' }} - platform: ${{ 'iOS Simulator' }} - file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} - filetype_parameter: ${{ 'project' }} - run: | - # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) - device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` - xcodebuild test-without-building -scheme "$scheme" -testPlan "$test_plan" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" diff --git a/iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh b/iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh index c7e6da90b..3706c4df3 100755 --- a/iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh +++ b/iOS_SDK/OneSignalSDK/build_kmp_xcframework.sh @@ -4,13 +4,22 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" KMP_REPO="$SCRIPT_DIR/../../OneSignal-KMP-SDK" +KMP_TASK="${1:-:kmp:verifyOneSignalKMPXCFramework}" if [[ ! -f "$KMP_REPO/gradlew" ]]; then echo "OneSignal-KMP-SDK is missing. Run: git submodule update --init --recursive" >&2 exit 1 fi +case "$KMP_TASK" in + :kmp:assembleOneSignalKMPReleaseXCFramework|:kmp:verifyOneSignalKMPXCFramework) ;; + *) + echo "Unsupported KMP XCFramework task: $KMP_TASK" >&2 + exit 1 + ;; +esac + "$KMP_REPO/gradlew" \ -p "$KMP_REPO" \ - :kmp:verifyOneSignalKMPXCFramework \ + "$KMP_TASK" \ --console=plain From 69df65f0f47b7a35e3c345bd37ef53466bbd3524 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:30:30 -0700 Subject: [PATCH 02/24] ci(SDK-5040): use standard macOS runners Co-authored-by: Cursor --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338e9af01..cc93a80ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ env: jobs: lint: name: Swift Lint - runs-on: macos-15-large + runs-on: macos-latest steps: - name: Checkout OneSignal-iOS-SDK @@ -25,7 +25,7 @@ jobs: kmp-xcframework: name: Build OneSignalKMP XCFramework - runs-on: macos-15-large + runs-on: macos-latest steps: - name: Select Xcode Version @@ -80,7 +80,7 @@ jobs: ios-simulator: name: Build and Test using any available iPhone simulator - runs-on: macos-15-large + runs-on: macos-latest needs: kmp-xcframework steps: @@ -122,7 +122,7 @@ jobs: catalyst: name: KMP logger Mac Catalyst integration - runs-on: macos-15-large + runs-on: macos-latest needs: kmp-xcframework steps: From cbb746084194ee76cb918858b5396b25b4e7996a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:37:37 -0700 Subject: [PATCH 03/24] ci(SDK-5040): remove explicit Xcode version selection --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc93a80ff..034711eda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ on: env: KMP_XCFRAMEWORK_PATH: OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework - XCODE_DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer jobs: lint: @@ -28,8 +27,6 @@ jobs: runs-on: macos-latest steps: - - name: Select Xcode Version - run: sudo xcode-select -s "$XCODE_DEVELOPER_DIR" - name: Checkout OneSignal-iOS-SDK uses: actions/checkout@v4 with: @@ -84,8 +81,6 @@ jobs: needs: kmp-xcframework steps: - - name: Select Xcode Version - run: sudo xcode-select -s "$XCODE_DEVELOPER_DIR" - name: Checkout OneSignal-iOS-SDK uses: actions/checkout@v4 - name: Download OneSignalKMP XCFramework @@ -126,8 +121,6 @@ jobs: needs: kmp-xcframework steps: - - name: Select Xcode Version - run: sudo xcode-select -s "$XCODE_DEVELOPER_DIR" - name: Checkout OneSignal-iOS-SDK uses: actions/checkout@v4 - name: Download OneSignalKMP XCFramework From 811b72430145c049f5b3f068f8a41fcadeacfef0 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:38:58 -0700 Subject: [PATCH 04/24] ci(SDK-5040): update workflow actions Co-authored-by: Cursor --- .github/workflows/ci.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 034711eda..a99de17a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout OneSignal-iOS-SDK - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Run Swift Lint run: swiftlint @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout OneSignal-iOS-SDK - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: recursive - name: Create KMP cache key @@ -39,19 +39,19 @@ jobs: echo "cache-key=kmp-xcframework-${{ runner.os }}-${{ runner.arch }}-${xcode_hash}-${kmp_sha}" >> "$GITHUB_OUTPUT" - name: Restore OneSignalKMP XCFramework id: kmp-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v6 with: path: ${{ env.KMP_XCFRAMEWORK_PATH }} key: ${{ steps.kmp.outputs.cache-key }} - name: Setup JDK 17 if: steps.kmp-cache.outputs.cache-hit != 'true' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Setup Gradle if: steps.kmp-cache.outputs.cache-hit != 'true' - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v6 - name: Assemble OneSignalKMP XCFramework if: steps.kmp-cache.outputs.cache-hit != 'true' run: | @@ -59,7 +59,7 @@ jobs: :kmp:assembleOneSignalKMPReleaseXCFramework - name: Save OneSignalKMP XCFramework if: steps.kmp-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v6 with: path: ${{ env.KMP_XCFRAMEWORK_PATH }} key: ${{ steps.kmp.outputs.cache-key }} @@ -69,7 +69,7 @@ jobs: -C "$(dirname "$KMP_XCFRAMEWORK_PATH")" \ "$(basename "$KMP_XCFRAMEWORK_PATH")" - name: Upload OneSignalKMP XCFramework - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: OneSignalKMP-XCFramework path: ${{ runner.temp }}/OneSignalKMP.xcframework.tar.gz @@ -82,9 +82,9 @@ jobs: steps: - name: Checkout OneSignal-iOS-SDK - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download OneSignalKMP XCFramework - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: OneSignalKMP-XCFramework path: ${{ runner.temp }} @@ -122,9 +122,9 @@ jobs: steps: - name: Checkout OneSignal-iOS-SDK - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download OneSignalKMP XCFramework - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: OneSignalKMP-XCFramework path: ${{ runner.temp }} From 5ff61faa17bd43c527dd20260b2a0e059db47c54 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:41:26 -0700 Subject: [PATCH 05/24] ci(SDK-5040): use swiftlint-action --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a99de17a5..fb070a38c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,9 @@ jobs: - name: Checkout OneSignal-iOS-SDK uses: actions/checkout@v7 - name: Run Swift Lint - run: swiftlint + uses: cirruslabs/swiftlint-action@v1 + with: + version: latest kmp-xcframework: name: Build OneSignalKMP XCFramework From 8516e78c4f4d6d1aebb5d7a7201bce0e8a31d8be Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:45:01 -0700 Subject: [PATCH 06/24] ci(SDK-5040): add concurrency cancellation --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb070a38c..40c20e80e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: - '.github/**' - 'README.md' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: KMP_XCFRAMEWORK_PATH: OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework From 0e844272db3c31d7a0ec0ce4beafc97d42d5a534 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:56:58 -0700 Subject: [PATCH 07/24] ci(SDK-5040): use simulator UDID for xcodebuild --- .github/workflows/ci.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40c20e80e..73edcd333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,16 +99,27 @@ jobs: mkdir -p "$(dirname "$KMP_XCFRAMEWORK_PATH")" tar -xzf "$RUNNER_TEMP/OneSignalKMP.xcframework.tar.gz" \ -C "$(dirname "$KMP_XCFRAMEWORK_PATH")" + - name: Select iPhone simulator + id: simulator + run: | + runtime_id="$(xcrun simctl list runtimes available --json | jq -r \ + '[.runtimes[] | select(.platform == "iOS")] | sort_by(.version | split(".") | map(tonumber)) | last.identifier')" + device_id="$(xcrun simctl list devices available --json | jq -r --arg runtime "$runtime_id" \ + '.devices[$runtime] | map(select(.name | startswith("iPhone"))) | first.udid')" + if [[ -z "$device_id" || "$device_id" == "null" ]]; then + echo "No available iPhone simulator found" >&2 + exit 1 + fi + echo "device-id=$device_id" >> "$GITHUB_OUTPUT" - name: Build env: scheme: ${{ 'UnitTestApp' }} platform: ${{ 'iOS Simulator' }} file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} filetype_parameter: ${{ 'project' }} + device_id: ${{ steps.simulator.outputs.device-id }} run: | - # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) - device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` - xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" + xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,id=$device_id" - name: Test env: scheme: ${{ 'UnitTestApp' }} @@ -116,10 +127,9 @@ jobs: platform: ${{ 'iOS Simulator' }} file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} filetype_parameter: ${{ 'project' }} + device_id: ${{ steps.simulator.outputs.device-id }} run: | - # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) - device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` - xcodebuild test-without-building -scheme "$scheme" -testPlan "$test_plan" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device" + xcodebuild test-without-building -scheme "$scheme" -testPlan "$test_plan" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,id=$device_id" catalyst: name: KMP logger Mac Catalyst integration From 65d81cd9ba8569c2a70eb25db7b5b05e171d888c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 11:58:50 -0700 Subject: [PATCH 08/24] ci(SDK-5040): update codeql actions --- .github/workflows/codeql.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6159106de..e97756f8b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,6 +20,10 @@ on: schedule: - cron: '35 6 * * 2' +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze @@ -46,11 +50,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -64,7 +68,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -77,6 +81,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" From a48d67bf60f7f3a5b03ab77dac954e7e0b150065 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 10:45:31 -0700 Subject: [PATCH 09/24] fix(tests): reset resilient storage in mocks --- .../OneSignalUserMocks/OneSignalUserMocks.swift | 7 +++++++ .../OneSignalUserTests/Executors/UserExecutorTests.swift | 7 +++---- .../OneSignalUserTests/OneSignalUserObjcTests.m | 8 +++----- .../OneSignalUserTests/OneSignalUserTests.swift | 3 ++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index da94af841..1e0f513c2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -37,6 +37,13 @@ public class OneSignalUserMocks: NSObject { // TODO: create mocked server responses to user requests @objc public static func reset() { + OSResilientStorage.setStrings([ + OSResilientStorage.keyAppId: "", + OSResilientStorage.keySubscriptionId: "", + OSResilientStorage.keyReceiveReceiptsEnabled: "", + OSResilientStorage.keyHasPriorSession: "" + ]) + _ = OSResilientStorage.snapshot() OSCoreMocks.resetOperationRepo() OneSignalUserManagerImpl.sharedInstance.reset() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 703079453..20802cd12 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -107,10 +107,9 @@ final class UserExecutorTests: XCTestCase { /* When */ let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) - let newIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) - - // The current user needs to be the same, set it in the user manager - OneSignalUserManagerImpl.sharedInstance.identityModelStore.add(id: OS_IDENTITY_MODEL_KEY, model: newIdentityModel, hydrating: false) + let newIdentityModel = OneSignalUserMocks + .setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + .identityModel mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: newIdentityModel) OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m index 9348f639e..a17ee3132 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m @@ -32,14 +32,12 @@ - (void)testSendPurchases { /* Setup */ MockOneSignalClient* client = [MockOneSignalClient new]; - - // 0. Purchases will be dropped if there is no user instance. - [OneSignalUserManagerImpl.sharedInstance start]; - - // 1. Set up mock responses for the anonymous user [MockUserRequests setDefaultCreateAnonUserResponsesWith:client onesignalId:nil subscriptionId:nil]; [OneSignalCoreImpl setSharedClient:client]; + // Purchases will be dropped if there is no user instance. + [OneSignalUserManagerImpl.sharedInstance start]; + /* When */ NSMutableArray* arrayOfPurchases = [NSMutableArray new]; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index a66d10394..46f66d39c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -275,12 +275,13 @@ final class OneSignalUserTests: XCTestCase { let checkedUser = manager.currentUser(matching: userA.identityModel.modelId) // A concurrent login switches the current user before the response is applied let userB = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: userB_OSID) + let userBLanguage = userB.propertiesModel.language checkedUser?.propertiesModel.hydrate(["language": "language-for-user-a"]) /* Then */ // The response's data went to the user it was for, and the new current user is untouched XCTAssertEqual(userA.propertiesModel.language, "language-for-user-a") - XCTAssertNil(userB.propertiesModel.language) + XCTAssertEqual(userB.propertiesModel.language, userBLanguage) XCTAssertEqual(manager._user?.identityModel.externalId, userB_EUID) } From 4a794fc47bcdc43ea783b91fcfa0547b009849b8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 13:30:35 -0700 Subject: [PATCH 10/24] fix(tests): remove async setup races Co-authored-by: Cursor --- .../EarlyTriggerTrackingTests.swift | 6 +++++- .../OSLiveActivitiesExecutorTests.swift | 8 ++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index bedfe909e..516b348bc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -92,7 +92,11 @@ final class EarlyTriggerTrackingTests: XCTestCase { /* Execute */ OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + let fetchCompleted = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in controller.hasCompletedFirstFetch }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [fetchCompleted], timeout: 2), .completed) /* Verify */ XCTAssertTrue(controller.hasCompletedFirstFetch) diff --git a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift index dc02f994c..43c13db6e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalLiveActivitiesTests/OSLiveActivitiesExecutorTests.swift @@ -49,15 +49,11 @@ final class OSLiveActivitiesExecutorTests: XCTestCase { override func tearDownWithError() throws { } - // Subscribes a user, then resets the client so tests assert only on the requests they make. private func setUpSubscribedUser() -> MockOneSignalClient { let mockClient = MockOneSignalClient() + mockClient.executeInstantaneously = true OneSignalCoreImpl.setSharedClient(mockClient) - OneSignalUserDefaults.initShared().saveString(forKey: OSUD_LEGACY_PLAYER_ID, withValue: "my-subscription-id") - OneSignalUserManagerImpl.sharedInstance.start() - // Wait for any user setup requests to complete - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) - mockClient.reset() + OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: "my-subscription-id") return mockClient } From d937a307ea74de48d68996f3eb1c11f2222ea2b5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 14:11:56 -0700 Subject: [PATCH 11/24] fix(tests): replace sleeps with predicate expectations --- .../EarlyTriggerTrackingTests.swift | 3 +- .../OneSignalNotificationsTests.swift | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index 516b348bc..04ad37561 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -74,6 +74,7 @@ final class EarlyTriggerTrackingTests: XCTestCase { func testHasCompletedFirstFetch_isSetAfterFirstFetch() throws { /* Setup */ let client = MockOneSignalClient() + client.executeInstantaneously = true OneSignalCoreImpl.setSharedClient(client) OSMessagingController.start() let controller = OSMessagingController.sharedInstance() @@ -96,7 +97,7 @@ final class EarlyTriggerTrackingTests: XCTestCase { predicate: NSPredicate { _, _ in controller.hasCompletedFirstFetch }, object: nil ) - XCTAssertEqual(XCTWaiter.wait(for: [fetchCompleted], timeout: 2), .completed) + XCTAssertEqual(XCTWaiter.wait(for: [fetchCompleted], timeout: 5), .completed) /* Verify */ XCTAssertTrue(controller.hasCompletedFirstFetch) diff --git a/iOS_SDK/OneSignalSDK/OneSignalNotificationsTests/OneSignalNotificationsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalNotificationsTests/OneSignalNotificationsTests.swift index e178a5732..3b60c1d20 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalNotificationsTests/OneSignalNotificationsTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalNotificationsTests/OneSignalNotificationsTests.swift @@ -61,15 +61,27 @@ final class OneSignalNotificationsTests: XCTestCase { } } + private func setBadgeCountAndWait(_ count: Int) { + let badgeSet = expectation(description: "Badge set") + setBadgeCount(count) { + badgeSet.fulfill() + } + wait(for: [badgeSet], timeout: 5) + } + + private func waitForCachedBadgeCount(_ count: Int) { + let badgeUpdated = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in self.getCachedBadgeCount() == count }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [badgeUpdated], timeout: 5), .completed) + } + func testClearBadgesWhenAppEntersForeground() throws { // NotificationManager Start to register lifecycle listener OSNotificationsManager.startSwizzling() // Set badge count > 0 - let expectation = self.expectation(description: "Badge set") - setBadgeCount(1) { - expectation.fulfill() - } - wait(for: [expectation], timeout: 0.5) + setBadgeCountAndWait(1) // Verify badge was set XCTAssertEqual(getCachedBadgeCount(), 1) @@ -79,8 +91,7 @@ final class OneSignalNotificationsTests: XCTestCase { // Foreground the app OneSignalCoreMocks.foregroundApp() - // Wait for async badge clearing on iOS 16+ - Thread.sleep(forTimeInterval: 0.1) + waitForCachedBadgeCount(0) // Ensure that badge count == 0 XCTAssertEqual(getCachedBadgeCount(), 0) @@ -90,11 +101,7 @@ final class OneSignalNotificationsTests: XCTestCase { // NotificationManager Start to register lifecycle listener OSNotificationsManager.startSwizzling() // Set badge count > 0 - let expectation = self.expectation(description: "Badge set") - setBadgeCount(1) { - expectation.fulfill() - } - wait(for: [expectation], timeout: 0.5) + setBadgeCountAndWait(1) // Verify badge was set XCTAssertEqual(getCachedBadgeCount(), 1) From d5f4afe61c49a93eb3b37dd5f3d35509edbc5b15 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 14:15:51 -0700 Subject: [PATCH 12/24] ci(SDK-5040): remove PR trigger from CodeQL --- .github/workflows/codeql.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e97756f8b..f7f85b5c4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,9 +14,6 @@ name: "CodeQL" on: push: branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] schedule: - cron: '35 6 * * 2' From acd0cb51da66c655e673331ccbc077f374c43a3f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 14:33:22 -0700 Subject: [PATCH 13/24] ci(SDK-5040): optimize simulator build and stabilize tests Co-authored-by: Cursor --- .github/workflows/ci.yml | 14 ++++++++++++-- .../OneSignalCoreMocks/MockOneSignalClient.swift | 6 ++++-- .../OneSignalOSCoreMocks/MockNewRecordsState.swift | 12 ++++++++++-- .../Executors/UserExecutorTests.swift | 6 +++++- .../SwitchUserIntegrationTests.swift | 12 ++++++++++-- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73edcd333..fa6dbbee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,12 @@ jobs: filetype_parameter: ${{ 'project' }} device_id: ${{ steps.simulator.outputs.device-id }} run: | - xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,id=$device_id" + xcodebuild build-for-testing \ + -scheme "$scheme" \ + -"$filetype_parameter" "$file_to_build" \ + -destination "platform=$platform,id=$device_id,arch=arm64" \ + -enableCodeCoverage NO \ + ONLY_ACTIVE_ARCH=YES - name: Test env: scheme: ${{ 'UnitTestApp' }} @@ -129,7 +134,12 @@ jobs: filetype_parameter: ${{ 'project' }} device_id: ${{ steps.simulator.outputs.device-id }} run: | - xcodebuild test-without-building -scheme "$scheme" -testPlan "$test_plan" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,id=$device_id" + xcodebuild test-without-building \ + -scheme "$scheme" \ + -testPlan "$test_plan" \ + -"$filetype_parameter" "$file_to_build" \ + -destination "platform=$platform,id=$device_id,arch=arm64" \ + -enableCodeCoverage NO catalyst: name: KMP logger Mac Catalyst integration diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index a388fc6f9..3a206c5b3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -214,9 +214,10 @@ extension MockOneSignalClient { */ @objc public func onlyOneRequest(contains path: String, contains payload: [String: Any]) -> Bool { + let requests = lock.withLock { executedRequests } var found = false - for request in executedRequests { + for request in requests { guard let params = request.parameters as? NSDictionary else { continue } @@ -238,7 +239,8 @@ extension MockOneSignalClient { } public func hasExecutedRequestOfType(_ type: AnyClass, expectedCount: Int? = nil) -> Bool { - let matchingCount = executedRequests.filter { request in + let requests = lock.withLock { executedRequests } + let matchingCount = requests.filter { request in request.isKind(of: type) }.count diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift index 25a6444f7..f76aae04d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift @@ -25,6 +25,7 @@ THE SOFTWARE. */ +import Foundation @testable import OneSignalOSCore public class MockNewRecordsState: OSNewRecordsState { @@ -33,11 +34,18 @@ public class MockNewRecordsState: OSNewRecordsState { let overwrite: Bool } - public var records: [MockNewRecord] = [] + private let lock = NSLock() + private var storedRecords: [MockNewRecord] = [] + + public var records: [MockNewRecord] { + lock.withLock { storedRecords } + } override public func add(_ key: String, _ overwrite: Bool = false) { let record = MockNewRecord(key: key, overwrite: overwrite) - records.append(record) + lock.withLock { + storedRecords.append(record) + } super.add(key, overwrite) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 20802cd12..8e6823aeb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -157,7 +157,11 @@ final class UserExecutorTests: XCTestCase { /* When */ mocks.userExecutor.identifyUser(externalId: userB_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: newIdentityModel) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + let userCreated = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in mocks.newRecordsState.contains(userB_OSID) }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [userCreated], timeout: 5), .completed) /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 2c598b6bb..2df7ec969 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -50,8 +50,16 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) OneSignalUserManagerImpl.sharedInstance.addTag(key: "tag_b", value: "value_b") - // 3. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + let userBTagsSent = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)", + contains: ["properties": ["tags": tagsUserB]] + ) + }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [userBTagsSent], timeout: 5), .completed) /* Then */ From 185de1605e3c1adfbb368054d8528ff779c5e01a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 14:50:40 -0700 Subject: [PATCH 14/24] ci(SDK-5040): boot simulator and parallelize tests --- .github/workflows/ci.yml | 6 +++++ .../MockOSDispatchQueue.swift | 22 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa6dbbee0..c74ec4ea6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,11 @@ jobs: echo "No available iPhone simulator found" >&2 exit 1 fi + device_state="$(xcrun simctl list devices --json | jq -r --arg runtime "$runtime_id" --arg device "$device_id" \ + '.devices[$runtime] | map(select(.udid == $device)) | first.state')" + if [[ "$device_state" != "Booted" ]]; then + xcrun simctl boot "$device_id" + fi echo "device-id=$device_id" >> "$GITHUB_OUTPUT" - name: Build env: @@ -134,6 +139,7 @@ jobs: filetype_parameter: ${{ 'project' }} device_id: ${{ steps.simulator.outputs.device-id }} run: | + xcrun simctl bootstatus "$device_id" -b xcodebuild test-without-building \ -scheme "$scheme" \ -testPlan "$test_plan" \ diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOSDispatchQueue.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOSDispatchQueue.swift index 842dd1be8..1e911b30d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOSDispatchQueue.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOSDispatchQueue.swift @@ -25,33 +25,43 @@ THE SOFTWARE. */ +import Foundation import OneSignalOSCore public class MockDispatchQueue: OSDispatchQueue { let requestDispatch = DispatchQueue(label: "MockDispatchQueue") - var numDispatches = 0 + private let dispatchCondition = NSCondition() + private var numDispatches = 0 public init() {} public func async(execute work: @escaping @convention(block) () -> Void) { requestDispatch.async { work() - self.numDispatches += 1 + self.recordDispatch() } } public func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { requestDispatch.asyncAfterTime(deadline: deadline) { work() - self.numDispatches += 1 + self.recordDispatch() } } public func waitForDispatches(_ numDispatches: Int) { + dispatchCondition.lock() + defer { dispatchCondition.unlock() } + while self.numDispatches < numDispatches { - requestDispatch.sync { - Thread.sleep(forTimeInterval: TimeInterval(1)) - } + dispatchCondition.wait() } } + + private func recordDispatch() { + dispatchCondition.lock() + numDispatches += 1 + dispatchCondition.broadcast() + dispatchCondition.unlock() + } } From f33da0acc6c83e7841a5a6c5047bbaf6d5d51fd3 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 14:52:42 -0700 Subject: [PATCH 15/24] test(consistency): refactor concurrent token update test --- .../OSConsistencyManagerTests.swift | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index 9a686d644..b48125850 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -249,48 +249,38 @@ class OSConsistencyManagerTests: XCTestCase { } func testConcurrentUpdatesToTokens() { - let expectation = self.expectation(description: "Concurrent updates handled correctly") - let id = "test_id" - let key = OSIamFetchOffsetKey.userUpdate - let rywToken1 = "123" - let rywToken2 = "456" let rywDelay = 0 as NSNumber - let value1 = OSReadYourWriteData(rywToken: rywToken1, rywDelay: rywDelay) - let value2 = OSReadYourWriteData(rywToken: rywToken2, rywDelay: rywDelay) - - // Set up concurrent queues - let queue1 = DispatchQueue(label: "com.test.queue1", attributes: .concurrent) - let queue2 = DispatchQueue(label: "com.test.queue2", attributes: .concurrent) - - // Perform concurrent token updates - queue1.async { - self.consistencyManager.setRywTokenAndDelay( - id: id, - key: key, - value: OSReadYourWriteData(rywToken: rywToken1, rywDelay: rywDelay) - ) + let value1 = OSReadYourWriteData(rywToken: "123", rywDelay: rywDelay) + let value2 = OSReadYourWriteData(rywToken: "456", rywDelay: rywDelay) + let updates: [(OSIamFetchOffsetKey, OSReadYourWriteData)] = [ + (.userUpdate, value1), + (.subscriptionUpdate, value2) + ] + let updateGroup = DispatchGroup() + + for (key, value) in updates { + updateGroup.enter() + DispatchQueue.global().async { + self.consistencyManager.setRywTokenAndDelay(id: id, key: key, value: value) + updateGroup.leave() + } } - queue2.async { - self.consistencyManager.setRywTokenAndDelay( - id: id, - key: key, - value: OSReadYourWriteData(rywToken: rywToken2, rywDelay: rywDelay) - ) + guard updateGroup.wait(timeout: .now() + 2) == .success else { + XCTFail("Concurrent token updates timed out") + return } - // Allow some time for the updates to happen - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - // Check that the most recent value was correctly set - let condition = TestMetCondition(expectedTokens: [id: [NSNumber(value: key.rawValue): value2]]) - let rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) - - XCTAssertEqual(rywData?.rywToken, "456") - expectation.fulfill() - } + let condition = TestMetCondition(expectedTokens: [ + id: [ + NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue): value1, + NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue): value2 + ] + ]) + let rywData = consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) - waitForExpectations(timeout: 2.0, handler: nil) + XCTAssertEqual(rywData?.rywToken, "456") } } From a47fe658affd9f06a73354d377149e2b30bda77c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:04:24 -0700 Subject: [PATCH 16/24] ci(SDK-5040): parallelize simulator boot and build --- .github/workflows/ci.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74ec4ea6..bfc2873de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,12 +110,16 @@ jobs: echo "No available iPhone simulator found" >&2 exit 1 fi - device_state="$(xcrun simctl list devices --json | jq -r --arg runtime "$runtime_id" --arg device "$device_id" \ - '.devices[$runtime] | map(select(.udid == $device)) | first.state')" + echo "device-id=$device_id" >> "$GITHUB_OUTPUT" + - name: Start simulator boot + env: + device_id: ${{ steps.simulator.outputs.device-id }} + run: | + device_state="$(xcrun simctl list devices --json | jq -r --arg device "$device_id" \ + '[.devices[][] | select(.udid == $device)] | first.state')" if [[ "$device_state" != "Booted" ]]; then xcrun simctl boot "$device_id" fi - echo "device-id=$device_id" >> "$GITHUB_OUTPUT" - name: Build env: scheme: ${{ 'UnitTestApp' }} @@ -130,6 +134,10 @@ jobs: -destination "platform=$platform,id=$device_id,arch=arm64" \ -enableCodeCoverage NO \ ONLY_ACTIVE_ARCH=YES + - name: Wait for simulator boot + env: + device_id: ${{ steps.simulator.outputs.device-id }} + run: xcrun simctl bootstatus "$device_id" -b - name: Test env: scheme: ${{ 'UnitTestApp' }} @@ -139,7 +147,6 @@ jobs: filetype_parameter: ${{ 'project' }} device_id: ${{ steps.simulator.outputs.device-id }} run: | - xcrun simctl bootstatus "$device_id" -b xcodebuild test-without-building \ -scheme "$scheme" \ -testPlan "$test_plan" \ From 70ed8cbbd6ad6a5d1e8ae569b6bc532252501f78 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:21:21 -0700 Subject: [PATCH 17/24] test(SDK-5040): replace fixed waits with condition-based polling --- .../MockOneSignalClient.swift | 29 +++++++++++ .../OneSignalCoreMocks.swift | 14 ++++++ .../EarlyTriggerTrackingTests.swift | 16 ++++-- .../IAMIntegrationTests.swift | 28 +++++++---- .../OSMessagingControllerUserStateTests.swift | 22 +++++++-- .../Source/OSOperationRepo.swift | 6 +++ .../CustomEventsIntegrationTests.swift | 21 +++++--- .../OSCustomEventsExecutorTests.swift | 44 +++++++++++++---- .../SubscriptionUpdateRaceTests.swift | 36 +++++++++++--- .../Executors/UserExecutorTests.swift | 30 +++++++++--- .../OneSignalUserTests.swift | 15 ++++-- .../SwitchUserIntegrationTests.swift | 49 +++++++++++++++---- .../UserConcurrencyTests.swift | 21 +++++--- 13 files changed, 260 insertions(+), 71 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index 3a206c5b3..88265f72c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -38,6 +38,7 @@ public class MockOneSignalClient: NSObject, IOneSignalClient { public var executedRequests: [OneSignalRequest] = [] /// Requests that have entered `execute` (including those still held / delayed). public private(set) var startedRequests: [OneSignalRequest] = [] + public private(set) var completedRequests: [OneSignalRequest] = [] public var executeInstantaneously = false /// Set to true to make it unnecessary to setup mock responses for every request possible public var fireSuccessForAllRequests = false @@ -91,6 +92,7 @@ public class MockOneSignalClient: NSObject, IOneSignalClient { networkRequestCount = 0 executedRequests.removeAll() startedRequests.removeAll() + completedRequests.removeAll() heldExecutions.removeAll() holdResponses = false executeInstantaneously = true @@ -181,6 +183,10 @@ public class MockOneSignalClient: NSObject, IOneSignalClient { allRequestsHandled = false print("🧪 cannot find a mock response for request: \(stringifiedRequest)") } + + lock.withLock { + completedRequests.append(request) + } } func didCompleteRequest(_ request: OneSignalRequest) { @@ -250,4 +256,27 @@ extension MockOneSignalClient { return matchingCount > 0 } } + + public func hasCompletedRequestOfType(_ type: AnyClass, expectedCount: Int? = nil) -> Bool { + let matchingCount = completedRequestCount(ofType: type) + + if let expectedCount { + return matchingCount == expectedCount + } + return matchingCount > 0 + } + + public func completedRequestCount(ofType type: AnyClass) -> Int { + let requests = lock.withLock { completedRequests } + return requests.filter { request in + request.isKind(of: type) + }.count + } + + public func startedRequestCount(ofType type: AnyClass) -> Int { + let requests = lock.withLock { startedRequests } + return requests.filter { request in + request.isKind(of: type) + }.count + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift index 20272b36e..ebdc43fc7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift @@ -50,6 +50,20 @@ public class OneSignalCoreMocks: NSObject { _ = XCTWaiter.wait(for: [expectation], timeout: seconds) } + public static func waitUntil( + _ description: String, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line, + condition: @escaping () -> Bool + ) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: min(deadline, Date().addingTimeInterval(0.01))) + } + XCTAssertTrue(condition(), description, file: file, line: line) + } + @objc public static func backgroundApp() { if OSBundleUtils.isAppUsingUIScene() { if #available(iOS 13.0, *) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index 04ad37561..fda6367cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -196,7 +196,9 @@ final class EarlyTriggerTrackingTests: XCTestCase { // Start the SDK and trigger first fetch OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2.0) + OneSignalCoreMocks.waitUntil("Initial IAM fetch did not complete") { + controller.hasCompletedFirstFetch + } // Verify first fetch completed XCTAssertTrue(controller.hasCompletedFirstFetch) @@ -298,7 +300,9 @@ final class EarlyTriggerTrackingTests: XCTestCase { /* Execute */ // Start the SDK and trigger first fetch OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2.0) + OneSignalCoreMocks.waitUntil("IAM messages were not loaded") { + controller.hasCompletedFirstFetch && controller.messages.count == 3 + } /* Verify */ // First fetch should have completed @@ -374,7 +378,9 @@ final class EarlyTriggerTrackingTests: XCTestCase { /* Execute */ // Start the SDK and trigger first fetch OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1.0) + OneSignalCoreMocks.waitUntil("IAM message was not loaded") { + controller.hasCompletedFirstFetch && controller.messages.count == 1 + } /* Verify */ XCTAssertTrue(controller.hasCompletedFirstFetch) @@ -444,7 +450,9 @@ final class EarlyTriggerTrackingTests: XCTestCase { /* Execute */ OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1.0) + OneSignalCoreMocks.waitUntil("IAM messages were not loaded") { + controller.hasCompletedFirstFetch && controller.messages.count == 2 + } /* Verify */ let messages = controller.messages as! [OSInAppMessageInternal] diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 9efcb6f51..43f128826 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -27,8 +27,8 @@ with services provided by OneSignal. import XCTest @testable import OneSignalInAppMessages +@testable import OneSignalUser import OneSignalOSCore -import OneSignalUser import OneSignalCoreMocks import OneSignalOSCoreMocks import OneSignalUserMocks @@ -38,6 +38,8 @@ import OneSignalInAppMessagesMocks These tests can include some Obj-C InAppMessagingIntegrationTests migrations. */ final class IAMIntegrationTests: XCTestCase { + private let testOneSignalId = "test-onesignal-id-12345" + override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() @@ -85,7 +87,11 @@ final class IAMIntegrationTests: XCTestCase { OneSignalIdentifiers.currentAppId = "test-app-id" // 2. Set up mock responses for the anonymous user, as the user needs an OSID - MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateAnonUserResponses( + with: client, + onesignalId: testOneSignalId, + subscriptionId: testPushSubId + ) // 3. Set up mock responses for fetching IAMs let message = IAMTestHelpers.testMessageJsonWithTrigger(kind: OS_DYNAMIC_TRIGGER_KIND_CUSTOM, property: "session_time", triggerId: "test_id1", type: 1, value: 10.0) @@ -94,19 +100,21 @@ final class IAMIntegrationTests: XCTestCase { request: "", response: response) - // 4. Unblock the Consistency Manager to allow fetching of IAMs - ConsistencyManagerTestHelpers.setDefaultRywToken(id: anonUserOSID) - - // 5. Pausing should prevent messages from being evaluated and shown + // 4. Pausing should prevent messages from being evaluated and shown OneSignalInAppMessages.__paused(true) - // 6. Start the user manager to generate a user instance + // 5. Start the user manager to generate a user instance OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } - // 7. Fetch IAMs + // 6. Unblock the Consistency Manager and fetch IAMs + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) OneSignalInAppMessages.getFromServer(testPushSubId) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("IAM fetch did not complete") { + client.hasCompletedRequestOfType(OSRequestGetInAppMessages.self) + } // Make sure no IAM is showing, and the queue has no IAMs XCTAssertFalse(OSMessagingController.sharedInstance().isInAppMessageShowing) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 8d7ada7cb..1facda710 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -83,7 +83,10 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Execute */ OneSignalInAppMessages.getFromServer(testSubscriptionId) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Deferred IAM subscription ID was not stored") { + OSMessagingController.sharedInstance() + .value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as? String == self.testSubscriptionId + } /* Verify */ // The controller should have stored the subscription ID for retry @@ -126,7 +129,9 @@ final class OSMessagingControllerUserStateTests: XCTestCase { // First attempt: Try to fetch IAMs without OneSignal ID (should be deferred) OneSignalInAppMessages.getFromServer(testSubscriptionId) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Deferred IAM subscription ID was not stored") { + controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as? String == self.testSubscriptionId + } // Verify the subscription ID was stored and no IAM fetch occurred XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId) @@ -136,7 +141,10 @@ final class OSMessagingControllerUserStateTests: XCTestCase { MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue.first?.sentToClient = false OneSignalUserManagerImpl.sharedInstance.userExecutor?.executePendingRequests() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Deferred IAM fetch was not retried") { + client.hasCompletedRequestOfType(OSRequestGetInAppMessages.self) + && controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") == nil + } /* Verify */ // The fetch should have been retried now that OneSignal ID is available @@ -172,7 +180,9 @@ final class OSMessagingControllerUserStateTests: XCTestCase { ) ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) OneSignalUserManagerImpl.sharedInstance.start() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Initial IAM fetch did not complete") { + client.hasCompletedRequestOfType(OSRequestGetInAppMessages.self) + } /* Verify */ // IAM is fetched and no retry is pending @@ -183,7 +193,9 @@ final class OSMessagingControllerUserStateTests: XCTestCase { // Trigger a normal user state change by login MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Identify user request did not complete") { + client.hasCompletedRequestOfType(OSRequestIdentifyUser.self) + } /* Verify */ // Does not fetch IAMs again diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 223080697..394dc5798 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -131,6 +131,12 @@ public class OSOperationRepo: NSObject { } } + func flushAndWait() { + dispatchQueue.sync { + flushDeltaQueue() + } + } + private func flushDeltaQueue(inBackground: Bool = false) { guard !paused else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift index e64b057dc..dfd03fda9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift @@ -63,7 +63,9 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: properties) OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -81,7 +83,9 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: nil) OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -99,7 +103,9 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: [:]) OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -120,7 +126,6 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: invalidProperties) OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then - No request should be made */ XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -155,7 +160,9 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "complex_event", properties: complexProperties) OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -221,7 +228,9 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "array_event", properties: properties) OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index 3ea47ff03..dedab9808 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -85,7 +85,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { /* When */ mocks.customEventsExecutor.enqueueDelta(delta) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -151,7 +153,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { /* When */ mocks.customEventsExecutor.enqueueDelta(delta) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -190,7 +194,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { /* When */ mocks.customEventsExecutor.enqueueDelta(delta) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -229,7 +235,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { mocks.customEventsExecutor.enqueueDelta(delta2) mocks.customEventsExecutor.enqueueDelta(delta3) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event requests did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self, expectedCount: 3) + } /* Then */ // Should have 3 separate requests, one per event (no batching) @@ -276,7 +284,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { mocks.customEventsExecutor.enqueueDelta(deltaUserA2) mocks.customEventsExecutor.enqueueDelta(deltaUserB1) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event requests did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self, expectedCount: 3) + } /* Then */ // Should have 3 separate requests, one per event (no batching) @@ -322,7 +332,13 @@ final class OSCustomEventsExecutorTests: XCTestCase { /* When */ mocks.customEventsExecutor.enqueueDelta(delta) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Blocked custom event delta was not cached") { + let deltas = OneSignalUserDefaults.initShared().getSavedCodeableData( + forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, + defaultValue: [] + ) as? [OSDelta] + return deltas?.count == 1 + } /* Then */ // No request should be made @@ -342,7 +358,13 @@ final class OSCustomEventsExecutorTests: XCTestCase { /* When */ mocks.customEventsExecutor.enqueueDelta(delta) mocks.customEventsExecutor.cacheDeltaQueue() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) + OneSignalCoreMocks.waitUntil("Custom event delta was not cached") { + let deltas = OneSignalUserDefaults.initShared().getSavedCodeableData( + forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, + defaultValue: [] + ) as? [OSDelta] + return deltas?.count == 1 + } /* Then - Verify delta is cached */ let cachedDeltas = OneSignalUserDefaults.initShared().getSavedCodeableData( @@ -369,7 +391,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { mocks.client.fireSuccessForAllRequests = true mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Uncached custom event request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCustomEvents.self)) @@ -398,7 +422,9 @@ final class OSCustomEventsExecutorTests: XCTestCase { /* When */ mocks.customEventsExecutor.enqueueDelta(delta) mocks.customEventsExecutor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Custom event request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCustomEvents.self) + } /* Then */ guard let request = mocks.client.executedRequests.first as? OSRequestCustomEvents, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 43c1746e9..6726ff1d8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -98,7 +98,13 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: promptedNeverAnswered )) executor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) + OneSignalCoreMocks.waitUntil("Blocked subscription update was not cached") { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData( + forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + defaultValue: [] + ) as? [OSRequestUpdateSubscription] + return requests?.count == 1 + } XCTAssertTrue(client.executedRequests.isEmpty, "Update should still be pending without subscriptionId") @@ -115,7 +121,9 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: subscribedNotificationTypes )) executor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Subscription update did not complete") { + client.hasCompletedRequestOfType(OSRequestUpdateSubscription.self) + } let updateRequests = client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription } XCTAssertFalse(updateRequests.isEmpty, "Expected at least one UpdateSubscription after id hydration") @@ -155,7 +163,9 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: promptedNeverAnswered )) executor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) + OneSignalCoreMocks.waitUntil("First subscription update did not start") { + client.startedRequestCount(ofType: OSRequestUpdateSubscription.self) == 1 + } XCTAssertEqual(client.startedRequests.count, 1, "First UpdateSubscription should be in flight") let firstPayload = try XCTUnwrap( @@ -176,12 +186,20 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: subscribedNotificationTypes )) executor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2) + OneSignalCoreMocks.waitUntil("Follow-up subscription update was not queued") { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData( + forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + defaultValue: [] + ) as? [OSRequestUpdateSubscription] + return requests?.count == 2 + } XCTAssertEqual(client.startedRequests.count, 1, "Follow-up must wait for in-flight UpdateSubscription") client.releaseHeldResponses() - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Follow-up subscription update did not start") { + client.startedRequestCount(ofType: OSRequestUpdateSubscription.self) == 2 + } XCTAssertEqual(client.startedRequests.count, 2, "Pending follow-up should send after in-flight completes") let secondPayload = try XCTUnwrap( @@ -219,7 +237,9 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: promptedNeverAnswered )) executor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Retryable subscription update did not complete") { + client.hasCompletedRequestOfType(OSRequestUpdateSubscription.self) + } XCTAssertEqual(client.executedRequests.count, 1, "First update should have been attempted and failed retryably") @@ -236,7 +256,9 @@ final class SubscriptionUpdateRaceTests: XCTestCase { value: subscribedNotificationTypes )) executor.processDeltaQueue(inBackground: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Follow-up subscription update did not complete") { + client.hasCompletedRequestOfType(OSRequestUpdateSubscription.self, expectedCount: 2) + } let updateRequests = client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription } XCTAssertEqual(updateRequests.count, 2, "Follow-up update must still send after a retryable failure") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 8e6823aeb..36a2c712d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -72,7 +72,10 @@ final class UserExecutorTests: XCTestCase { /* When */ mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Create user response was not applied") { + mocks.newRecordsState.contains(userA_OSID) + && mocks.newRecordsState.contains("push-sub-id") + } /* Then */ XCTAssertTrue(mocks.newRecordsState.contains(userA_OSID)) @@ -88,7 +91,9 @@ final class UserExecutorTests: XCTestCase { let identityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) mocks.userExecutor.createUser(aliasLabel: OS_EXTERNAL_ID, aliasId: userA_EUID, identityModel: identityModel) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Create user request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) @@ -112,7 +117,9 @@ final class UserExecutorTests: XCTestCase { .identityModel mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: newIdentityModel) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Identify user response was not applied") { + mocks.newRecordsState.wasOverwritten(userA_OSID) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) @@ -134,7 +141,9 @@ final class UserExecutorTests: XCTestCase { let newIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: newIdentityModel) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Identify user request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestIdentifyUser.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) @@ -183,7 +192,9 @@ final class UserExecutorTests: XCTestCase { /* When */ mocks.userExecutor.identifyUser(externalId: userB_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: newIdentityModel) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Conflict create user request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) @@ -215,7 +226,9 @@ final class UserExecutorTests: XCTestCase { /* When */ mocks.userExecutor.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: userA_OSID, identityModel: staleIdentityModel, onNewSession: true) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Stale fetch user request did not complete") { + mocks.client.hasCompletedRequestOfType(OSRequestFetchUser.self) + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) @@ -239,7 +252,10 @@ final class UserExecutorTests: XCTestCase { /* When */ mocks.userExecutor.fetchUser(aliasLabel: OS_ONESIGNAL_ID, aliasId: userA_OSID, identityModel: currentUser.identityModel, onNewSession: false) - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Current user fetch response was not applied") { + currentUser.identityModel.aliases["stale_label"] == nil + && currentUser.identityModel.externalId == userA_EUID + } /* Then */ XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestFetchUser.self)) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 46f66d39c..e130af24b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -129,8 +129,7 @@ final class OneSignalUserTests: XCTestCase { // Increase flush interval to allow all the updates to batch OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 - // Wait to let any pending flushes in the Operation Repo to run - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.1) + OSOperationRepo.sharedInstance.flushAndWait() /* When */ @@ -169,7 +168,9 @@ final class OneSignalUserTests: XCTestCase { /* Then */ - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1) + OneSignalCoreMocks.waitUntil("Combined property update did not complete") { + client.hasCompletedRequestOfType(OSRequestUpdateProperties.self) + } let expectedPayload: [String: Any] = [ "deltas": [ @@ -239,7 +240,9 @@ final class OneSignalUserTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.start() // Let the anonymous user be created so it has a OneSignal ID for the update request - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } /* When */ // Tags are applied optimistically to the local model and queued as an update request @@ -251,7 +254,9 @@ final class OneSignalUserTests: XCTestCase { XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.getTags().isEmpty) // Let the queued UpdateProperties request flush and its 202 echo be processed - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1) + OneSignalCoreMocks.waitUntil("Confirmed tags were not restored") { + OneSignalUserManagerImpl.sharedInstance.getTags() == tags + } /* Then */ // The confirmed tags from the 202 response are merged back into the local model diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 2df7ec969..ffd4900fd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -131,8 +131,10 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_a", id: "id_a") OneSignalUserManagerImpl.sharedInstance.addEmail("email_a@example.com") - // 3. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("User hydration did not complete") { + OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore + .getModel(key: "remote_email@example.com") != nil + } /* Then */ @@ -198,8 +200,6 @@ final class SwitchUserIntegrationTests: XCTestCase { */ func testAnonUser_thenIdentifyUserWithConflict_thenLogout_sendsCorrectUpdatesWithNoFetch() throws { /* Setup */ - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) - let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) @@ -245,8 +245,12 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_b", id: "id_b") OneSignalUserManagerImpl.sharedInstance.addEmail("email_b@example.com") - // 4. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 1) + OneSignalCoreMocks.waitUntil("Logged-out user updates were not sent") { + client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(anonUserOSID)/subscriptions", + contains: ["subscription": ["token": "email_b@example.com"]] + ) + } /* Then */ @@ -328,8 +332,7 @@ final class SwitchUserIntegrationTests: XCTestCase { // Increase flush interval to allow all the updates to batch OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 - // Wait to let any pending flushes in the Operation Repo to run - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) + OSOperationRepo.sharedInstance.flushAndWait() // 1. Set up mock responses for the first anonymous user let tagsUserAnon = ["tag_anon": "value_anon"] @@ -376,8 +379,34 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_b", id: "id_b") OneSignalUserManagerImpl.sharedInstance.addEmail("email_b@example.com") - // 3. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 2) + OneSignalCoreMocks.waitUntil("User B updates and hydration did not complete") { + client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)", + contains: ["properties": ["language": "lang_a", "tags": tagsUserA]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/identity", + contains: ["identity": ["alias_a": "id_a"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/subscriptions", + contains: ["subscription": ["token": "email_a@example.com"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)", + contains: ["properties": ["language": "lang_b", "tags": tagsUserB]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/identity", + contains: ["identity": ["alias_b": "id_b"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/subscriptions", + contains: ["subscription": ["token": "email_b@example.com"]] + ) + && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore + .getModel(key: "remote_email@example.com") != nil + } /* Then */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index 94d3f4388..c3835f051 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -110,8 +110,9 @@ final class UserConcurrencyTests: XCTestCase { executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) } - // 4. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Concurrent subscription requests did not complete") { + client.completedRequestCount(ofType: OSRequestDeleteSubscription.self) >= 100 + } /* Then */ // Previously caused crash: signal SIGABRT - malloc: double free for ptr @@ -149,8 +150,9 @@ final class UserConcurrencyTests: XCTestCase { executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) } - // 4. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Concurrent identity requests did not complete") { + client.completedRequestCount(ofType: OSRequestAddAliases.self) >= 100 + } /* Then */ // Previously caused crash: signal SIGABRT - malloc: double free for ptr @@ -189,8 +191,9 @@ final class UserConcurrencyTests: XCTestCase { executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel), inBackground: false) } - // 4. Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Concurrent property requests did not complete") { + client.completedRequestCount(ofType: OSRequestUpdateProperties.self) >= 50 + } /* Then */ // No crash @@ -228,8 +231,10 @@ final class UserConcurrencyTests: XCTestCase { userExecutor.executeFetchUserRequest(fetchRequest) } - // Run background threads - OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + OneSignalCoreMocks.waitUntil("Concurrent user requests did not complete") { + client.completedRequestCount(ofType: OSRequestIdentifyUser.self) >= 50 + && client.completedRequestCount(ofType: OSRequestFetchUser.self) >= 50 + } /* Then */ // No crash From 2f57fc03b65e122687125ff5af699bcd0e570bca Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:27:28 -0700 Subject: [PATCH 18/24] test(SDK-5040): refactor waitForCondition and ObjC API --- .../OneSignalCoreMocks.swift | 21 ++++--- .../OneSignalUserObjcTests.m | 6 +- .../SwitchUserIntegrationTests.swift | 60 +++++++++++-------- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift index ebdc43fc7..20cddf0cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/OneSignalCoreMocks.swift @@ -43,13 +43,6 @@ public class OneSignalCoreMocks: NSObject { } } - /** Wait specified number of seconds for any async methods to run */ - @objc - public static func waitForBackgroundThreads(seconds: Double) { - let expectation = XCTestExpectation(description: "Wait for \(seconds) seconds") - _ = XCTWaiter.wait(for: [expectation], timeout: seconds) - } - public static func waitUntil( _ description: String, timeout: TimeInterval = 5, @@ -57,11 +50,23 @@ public class OneSignalCoreMocks: NSObject { line: UInt = #line, condition: @escaping () -> Bool ) { + XCTAssertTrue(waitForCondition(timeout: timeout, condition), description, file: file, line: line) + } + + @objc(waitUntilWithTimeout:condition:) + public static func waitUntilForObjC( + timeout: TimeInterval, + condition: @escaping @convention(block) () -> Bool + ) -> Bool { + waitForCondition(timeout: timeout, condition) + } + + private static func waitForCondition(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { let deadline = Date().addingTimeInterval(timeout) while !condition() && Date() < deadline { RunLoop.current.run(until: min(deadline, Date().addingTimeInterval(0.01))) } - XCTAssertTrue(condition(), description, file: file, line: line) + return condition() } @objc public static func backgroundApp() { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m index a17ee3132..1693e2ab7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserObjcTests.m @@ -64,14 +64,14 @@ - (void)testSendPurchases { [OneSignalUserManagerImpl.sharedInstance sendPurchases:arrayOfPurchases]; - // Run background threads - [OneSignalCoreMocks waitForBackgroundThreadsWithSeconds:0.5]; - /* Then */ NSString* path = [NSString stringWithFormat:@"apps/test-app-id/users/by/onesignal_id/%@", @"test_anon_user_onesignal_id"]; NSDictionary *payload = [NSDictionary dictionaryWithObject:[NSDictionary dictionaryWithObject:arrayOfPurchases forKey:@"purchases"] forKey:@"deltas"]; + XCTAssertTrue([OneSignalCoreMocks waitUntilWithTimeout:5 condition:^BOOL{ + return [client onlyOneRequestWithContains:path contains:payload]; + }]); XCTAssertTrue([client onlyOneRequestWithContains:path contains:payload]); } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index ffd4900fd..22da1d560 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -380,32 +380,7 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addEmail("email_b@example.com") OneSignalCoreMocks.waitUntil("User B updates and hydration did not complete") { - client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)", - contains: ["properties": ["language": "lang_a", "tags": tagsUserA]] - ) - && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/identity", - contains: ["identity": ["alias_a": "id_a"]] - ) - && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/subscriptions", - contains: ["subscription": ["token": "email_a@example.com"]] - ) - && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)", - contains: ["properties": ["language": "lang_b", "tags": tagsUserB]] - ) - && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/identity", - contains: ["identity": ["alias_b": "id_b"]] - ) - && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/subscriptions", - contains: ["subscription": ["token": "email_b@example.com"]] - ) - && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore - .getModel(key: "remote_email@example.com") != nil + self.userUpdatesAndHydrationCompleted(client, tagsUserA, tagsUserB) } /* Then */ @@ -461,4 +436,37 @@ final class SwitchUserIntegrationTests: XCTestCase { XCTAssertNotNil(OneSignalUserManagerImpl.sharedInstance.user.identityModel.aliases["remote_alias"]) XCTAssertNotNil(OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore.getModel(key: "remote_email@example.com")) } + + private func userUpdatesAndHydrationCompleted( + _ client: MockOneSignalClient, + _ tagsUserA: [String: String], + _ tagsUserB: [String: String] + ) -> Bool { + client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)", + contains: ["properties": ["language": "lang_a", "tags": tagsUserA]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/identity", + contains: ["identity": ["alias_a": "id_a"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/subscriptions", + contains: ["subscription": ["token": "email_a@example.com"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)", + contains: ["properties": ["language": "lang_b", "tags": tagsUserB]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/identity", + contains: ["identity": ["alias_b": "id_b"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/subscriptions", + contains: ["subscription": ["token": "email_b@example.com"]] + ) + && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore + .getModel(key: "remote_email@example.com") != nil + } } From 09a1b52339797b748e6efcf50bfacf92c91675cc Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:32:51 -0700 Subject: [PATCH 19/24] ci(SDK-5040): use generic simulator destination --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfc2873de..6989970b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,16 +123,15 @@ jobs: - name: Build env: scheme: ${{ 'UnitTestApp' }} - platform: ${{ 'iOS Simulator' }} file_to_build: ${{ 'iOS_SDK/OneSignalSDK/OneSignal.xcodeproj' }} filetype_parameter: ${{ 'project' }} - device_id: ${{ steps.simulator.outputs.device-id }} run: | xcodebuild build-for-testing \ -scheme "$scheme" \ -"$filetype_parameter" "$file_to_build" \ - -destination "platform=$platform,id=$device_id,arch=arm64" \ + -destination "generic/platform=iOS Simulator" \ -enableCodeCoverage NO \ + ARCHS=arm64 \ ONLY_ACTIVE_ARCH=YES - name: Wait for simulator boot env: From 9282f710e6f61af510e5c2f26fe595bcb043818a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:36:46 -0700 Subject: [PATCH 20/24] test(SDK-5040): improve switch user integration test reliability --- .../SwitchUserIntegrationTests.swift | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 22da1d560..d2424795c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -116,6 +116,12 @@ final class SwitchUserIntegrationTests: XCTestCase { // Returns mocked user data to test hydration MockUserRequests.setDefaultFetchUserResponseForHydration(with: client, externalId: userA_EUID) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + OSOperationRepo.sharedInstance.paused = true + /* When */ // 1. Anonymous user @@ -131,9 +137,11 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_a", id: "id_a") OneSignalUserManagerImpl.sharedInstance.addEmail("email_a@example.com") - OneSignalCoreMocks.waitUntil("User hydration did not complete") { - OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore - .getModel(key: "remote_email@example.com") != nil + OSOperationRepo.sharedInstance.paused = false + OSOperationRepo.sharedInstance.flushAndWait() + + OneSignalCoreMocks.waitUntil("User A updates and hydration did not complete") { + self.userAUpdatesAndHydrationCompleted(client, tagsUserA) } /* Then */ @@ -469,4 +477,25 @@ final class SwitchUserIntegrationTests: XCTestCase { && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore .getModel(key: "remote_email@example.com") != nil } + + private func userAUpdatesAndHydrationCompleted( + _ client: MockOneSignalClient, + _ tagsUserA: [String: String] + ) -> Bool { + client.allRequestsHandled + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)", + contains: ["properties": ["language": "lang_a", "tags": tagsUserA]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/identity", + contains: ["identity": ["alias_a": "id_a"]] + ) + && client.onlyOneRequest( + contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)/subscriptions", + contains: ["subscription": ["token": "email_a@example.com"]] + ) + && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore + .getModel(key: "remote_email@example.com") != nil + } } From 606bfd0378d2959aaca7bb13e23954124b3e9778 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:49:38 -0700 Subject: [PATCH 21/24] ci(SDK-5040): boot simulator after build step --- .github/workflows/ci.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6989970b5..66a49ae66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,15 +111,6 @@ jobs: exit 1 fi echo "device-id=$device_id" >> "$GITHUB_OUTPUT" - - name: Start simulator boot - env: - device_id: ${{ steps.simulator.outputs.device-id }} - run: | - device_state="$(xcrun simctl list devices --json | jq -r --arg device "$device_id" \ - '[.devices[][] | select(.udid == $device)] | first.state')" - if [[ "$device_state" != "Booted" ]]; then - xcrun simctl boot "$device_id" - fi - name: Build env: scheme: ${{ 'UnitTestApp' }} @@ -133,6 +124,15 @@ jobs: -enableCodeCoverage NO \ ARCHS=arm64 \ ONLY_ACTIVE_ARCH=YES + - name: Start simulator boot + env: + device_id: ${{ steps.simulator.outputs.device-id }} + run: | + device_state="$(xcrun simctl list devices --json | jq -r --arg device "$device_id" \ + '[.devices[][] | select(.udid == $device)] | first.state')" + if [[ "$device_state" != "Booted" ]]; then + xcrun simctl boot "$device_id" + fi - name: Wait for simulator boot env: device_id: ${{ steps.simulator.outputs.device-id }} From cc1ef29caca7cb5b7c4a46a26171168c12f9718b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 15:56:58 -0700 Subject: [PATCH 22/24] ci(SDK-5040): add -quiet flag to xcodebuild --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66a49ae66..1b3ceb26a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: filetype_parameter: ${{ 'project' }} run: | xcodebuild build-for-testing \ + -quiet \ -scheme "$scheme" \ -"$filetype_parameter" "$file_to_build" \ -destination "generic/platform=iOS Simulator" \ @@ -147,6 +148,7 @@ jobs: device_id: ${{ steps.simulator.outputs.device-id }} run: | xcodebuild test-without-building \ + -quiet \ -scheme "$scheme" \ -testPlan "$test_plan" \ -"$filetype_parameter" "$file_to_build" \ From fe58a39aaed44a157857b2adf4ae1b6e9f753609 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 16:07:51 -0700 Subject: [PATCH 23/24] test(SDK-5040): fix flaky switch user test --- .../SwitchUserIntegrationTests.swift | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index d2424795c..2c04b9c0a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -231,6 +231,12 @@ final class SwitchUserIntegrationTests: XCTestCase { MockUserRequests.setAddAliasesResponse(with: client, aliases: ["alias_b": "id_b"]) MockUserRequests.setAddEmailResponse(with: client, email: "email_b@example.com") + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitUntil("Anonymous user creation did not complete") { + client.hasCompletedRequestOfType(OSRequestCreateUser.self) + } + OSOperationRepo.sharedInstance.paused = true + /* When */ // 1. Anonymous user starts @@ -253,11 +259,11 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalUserManagerImpl.sharedInstance.addAlias(label: "alias_b", id: "id_b") OneSignalUserManagerImpl.sharedInstance.addEmail("email_b@example.com") + OSOperationRepo.sharedInstance.paused = false + OSOperationRepo.sharedInstance.flushAndWait() + OneSignalCoreMocks.waitUntil("Logged-out user updates were not sent") { - client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(anonUserOSID)/subscriptions", - contains: ["subscription": ["token": "email_b@example.com"]] - ) + self.userUpdatesCompleted(client, tagsUserA, tagsUserB, anonUserOSID) } /* Then */ @@ -449,6 +455,17 @@ final class SwitchUserIntegrationTests: XCTestCase { _ client: MockOneSignalClient, _ tagsUserA: [String: String], _ tagsUserB: [String: String] + ) -> Bool { + userUpdatesCompleted(client, tagsUserA, tagsUserB, userB_OSID) + && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore + .getModel(key: "remote_email@example.com") != nil + } + + private func userUpdatesCompleted( + _ client: MockOneSignalClient, + _ tagsUserA: [String: String], + _ tagsUserB: [String: String], + _ userBOneSignalId: String ) -> Bool { client.onlyOneRequest( contains: "apps/test-app-id/users/by/onesignal_id/\(userA_OSID)", @@ -463,19 +480,17 @@ final class SwitchUserIntegrationTests: XCTestCase { contains: ["subscription": ["token": "email_a@example.com"]] ) && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)", + contains: "apps/test-app-id/users/by/onesignal_id/\(userBOneSignalId)", contains: ["properties": ["language": "lang_b", "tags": tagsUserB]] ) && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/identity", + contains: "apps/test-app-id/users/by/onesignal_id/\(userBOneSignalId)/identity", contains: ["identity": ["alias_b": "id_b"]] ) && client.onlyOneRequest( - contains: "apps/test-app-id/users/by/onesignal_id/\(userB_OSID)/subscriptions", + contains: "apps/test-app-id/users/by/onesignal_id/\(userBOneSignalId)/subscriptions", contains: ["subscription": ["token": "email_b@example.com"]] ) - && OneSignalUserManagerImpl.sharedInstance.subscriptionModelStore - .getModel(key: "remote_email@example.com") != nil } private func userAUpdatesAndHydrationCompleted( From 2ac992931e1b255a6346401aa6caeb0c2027d1c8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 19 Aug 2026 16:17:48 -0700 Subject: [PATCH 24/24] temp test with 1 worker --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b3ceb26a..0f08eea3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,7 @@ jobs: -testPlan "$test_plan" \ -"$filetype_parameter" "$file_to_build" \ -destination "platform=$platform,id=$device_id,arch=arm64" \ + -maximum-parallel-testing-workers 1 \ -enableCodeCoverage NO catalyst: