From 95d6afc933381b6bdc37723a5c9304c339164658 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 22 May 2026 13:19:18 +0200 Subject: [PATCH 01/66] Test hardening, bug fixes on iOS, RN, and Android. Add CI testing for android --- .github/workflows/main-pipeline.yaml | 161 ++++++++ .github/workflows/notify-slack.yml | 42 +- .gitignore | 14 +- .prettierignore | 3 +- README.md | 4 +- ...tive-sdk-interaction-tracking-mechanics.md | 34 +- eslint.config.ts | 18 +- implementations/android-sdk/AGENTS.md | 6 +- implementations/android-sdk/README.md | 8 +- .../optimization/app/MainActivity.kt | 9 +- .../app/components/AnalyticsEventDisplay.kt | 9 - .../app/components/ContentEntryView.kt | 12 +- .../app/components/NestedContentEntryView.kt | 12 +- .../optimization/app/components/RichText.kt | 81 ++++ .../app/screens/LiveUpdatesTestScreen.kt | 7 +- .../optimization/app/screens/MainScreen.kt | 62 ++- .../android-sdk/scripts/bootstrap.sh | 2 +- .../android-sdk/scripts/prepare-env.sh | 2 +- .../android-sdk/scripts/run-e2e.sh | 4 +- .../android-sdk/uitests/build.gradle.kts | 43 ++ .../uitests/support/AppLauncher.kt | 50 +++ .../uitests/support/DeviceExtensions.kt | 91 +++++ .../uitests/support/TestHelpers.kt | 325 +++++++++++++++ .../uitests/tests/AnalyticsTests.kt | 40 ++ .../tests/ExtendedViewTrackingTests.kt | 301 ++++++++++++++ .../uitests/tests/FlagViewTrackingTests.kt | 37 ++ .../uitests/tests/IdentifiedVariantsTests.kt | 176 ++++++++ .../uitests/tests/LiveUpdatesTests.kt | 269 +++++++++++++ .../uitests/tests/OfflineBehaviorTests.kt | 270 +++++++++++++ .../tests/PreviewPanelOverridesTests.kt | 376 ++++++++++++++++++ .../uitests/tests/PreviewPanelTests.kt | 153 +++++++ .../uitests/tests/ScreenTrackingTests.kt | 88 ++++ .../uitests/tests/TapTrackingTests.kt | 60 +++ .../tests/UnidentifiedVariantsTests.kt | 190 +++++++++ implementations/ios-sdk/AGENTS.md | 4 +- .../OptimizationApp.xcodeproj/project.pbxproj | 12 + implementations/ios-sdk/shared/Config.swift | 7 + .../ios-sdk/shared/ContentfulFetcher.swift | 10 +- .../shared/MockPreviewContentfulClient.swift | 73 ++++ implementations/ios-sdk/shared/RichText.swift | 73 ++++ implementations/ios-sdk/swiftui/App.swift | 9 +- .../Components/AnalyticsEventDisplay.swift | 8 +- .../swiftui/Components/ContentEntryView.swift | 11 +- .../Components/NestedContentEntryView.swift | 32 +- .../Screens/LiveUpdatesTestScreen.swift | 9 +- .../ios-sdk/swiftui/Screens/MainScreen.swift | 46 ++- .../AnalyticsEventDisplayView.swift | 2 +- .../uikit/Components/ContentEntryUIView.swift | 12 +- .../Components/NestedContentEntryUIView.swift | 48 ++- .../Components/OptimizedEntryUIView.swift | 11 +- .../ios-sdk/uikit/SceneDelegate.swift | 6 +- .../LiveUpdatesTestViewController.swift | 9 +- .../uikit/Screens/MainViewController.swift | 61 ++- .../ios-sdk/uitests/Support/TestHelpers.swift | 53 +++ .../uitests/Tests/AnalyticsTests.swift | 24 +- .../Tests/ExtendedViewTrackingTests.swift | 237 ++++++----- .../uitests/Tests/FlagViewTrackingTests.swift | 28 +- .../Tests/IdentifiedVariantsTests.swift | 161 +++++--- .../uitests/Tests/LiveUpdatesTests.swift | 135 +++++-- .../uitests/Tests/OfflineBehaviorTests.swift | 191 ++++++--- .../Tests/PreviewPanelOverridesTests.swift | 283 ++++++++++--- .../uitests/Tests/ScreenTrackingTests.swift | 29 +- .../Tests/UnidentifiedVariantsTests.swift | 219 +++++++--- implementations/react-native-sdk/App.tsx | 10 +- .../displays-identified-user-variants.test.js | 8 +- ...isplays-unidentified-user-variants.test.js | 122 +++++- .../e2e/extended-view-tracking.test.js | 74 +++- .../react-native-sdk/e2e/helpers.js | 11 + .../react-native-sdk/e2e/live-updates.test.js | 110 ++++- .../e2e/offline-behavior.test.js | 136 +++++-- .../e2e/preview-panel-overrides.test.js | 111 +++++- implementations/react-native-sdk/package.json | 8 + ...extManager.kt => QuickJsContextManager.kt} | 2 +- .../optimization/compose/OptimizationRoot.kt | 21 +- .../optimization/compose/OptimizedEntry.kt | 68 ++-- .../optimization/core/OptimizationClient.kt | 26 +- .../preview/PreviewPanelConfig.kt | 18 + .../storage/SharedPreferencesStore.kt | 6 +- .../tracking/ViewTrackingController.kt | 15 + .../Bridge/JSContextManager.swift | 17 +- .../Core/OptimizationClient.swift | 29 ++ .../Polyfills/NativePolyfills.swift | 30 +- .../Preview/PreviewComponents.swift | 1 + .../Preview/PreviewPanelConfig.swift | 32 ++ .../Preview/PreviewPanelContent.swift | 1 + .../Tracking/ViewTrackingController.swift | 12 +- .../Views/OptimizationRoot.swift | 30 +- .../Views/OptimizedEntry.swift | 12 +- pnpm-lock.yaml | 278 +++++++------ pnpm-workspace.yaml | 1 + 90 files changed, 5151 insertions(+), 800 deletions(-) create mode 100644 implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt create mode 100644 implementations/android-sdk/uitests/build.gradle.kts create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt create mode 100644 implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt create mode 100644 implementations/ios-sdk/shared/MockPreviewContentfulClient.swift create mode 100644 implementations/ios-sdk/shared/RichText.swift rename packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/{ZiplineContextManager.kt => QuickJsContextManager.kt} (99%) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelConfig.kt create mode 100644 packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelConfig.swift diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 9b0db815..b2ef5cdd 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -26,6 +26,7 @@ jobs: e2e_web_sdk_react: ${{ steps.filter.outputs.e2e_web_sdk_react }} e2e_react_web_sdk: ${{ steps.filter.outputs.e2e_react_web_sdk }} e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }} + e2e_android: ${{ steps.filter.outputs.e2e_android }} e2e_ios: ${{ steps.filter.outputs.e2e_ios }} steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 @@ -126,11 +127,21 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/main-pipeline.yaml' + # Android native implementation E2E coverage scope. + e2e_android: + - 'implementations/android-sdk/**' + - 'lib/mocks/**' + - 'packages/android/**' + - 'packages/universal/optimization-js-bridge/**' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/main-pipeline.yaml' # iOS native implementation E2E coverage scope. e2e_ios: - 'implementations/ios-sdk/**' - 'lib/mocks/**' - 'packages/ios/**' + - 'packages/universal/optimization-js-bridge/**' - 'package.json' - 'pnpm-lock.yaml' - '.github/workflows/main-pipeline.yaml' @@ -659,6 +670,156 @@ jobs: if-no-files-found: error retention-days: 1 + e2e-android-sdk: + name: 🤖 E2E Android Native + runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal + timeout-minutes: 45 + needs: [setup, changes] + if: needs.changes.outputs.e2e_android == 'true' + env: + CI: 'true' + GRADLE_OPTS: >- + -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.jvmargs=-Xmx4g + -Dkotlin.daemon.jvm.options=-Xmx2g + steps: + - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3 + + - name: Set Android SDK environment variables + run: | + echo "ANDROID_SDK_ROOT=$HOME/.android/sdk" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$HOME/.android/sdk" >> "$GITHUB_ENV" + + - name: Prepare cache directories + run: | + mkdir -p "$HOME/.android/sdk" "$HOME/.android/avd" "$HOME/.android/cache" + + - name: Set up caches (Namespace) + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + with: + cache: | + pnpm + gradle + path: | + ~/.android/sdk + ~/.android/avd + ~/.android/cache + + - name: Install system dependencies (Android emulator) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ca-certificates curl unzip zip git \ + netcat-openbsd cpu-checker \ + libgl1 libnss3 libx11-6 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 libxtst6 \ + libxi6 libxrender1 libxkbcommon0 libgbm1 libdbus-1-3 libdrm2 libpulse0 + sudo apt-get install -y --no-install-recommends libasound2 || sudo apt-get install -y --no-install-recommends libasound2t64 + + - name: Verify KVM is available + run: | + if [ ! -e /dev/kvm ]; then + echo "/dev/kvm not found; Android hardware acceleration will not work." >&2 + exit 1 + fi + ls -l /dev/kvm + sudo kvm-ok || true + + - name: Setup Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install JS dependencies + run: pnpm install --prefer-offline --frozen-lockfile + + - name: Build the JS bridge bundles + run: pnpm --filter @contentful/optimization-js-bridge build + + - name: Build app and test APKs + working-directory: implementations/android-sdk + run: ./gradlew :app:assembleDebug :uitests:assembleDebug + + - name: Start Mock Server + run: | + pnpm --dir lib/mocks serve > /tmp/mock-server.log 2>&1 & + echo $! > /tmp/mock-server.pid + for i in {1..60}; do + if nc -z localhost 8000 2>/dev/null; then + echo "Mock server is ready" + break + fi + echo "Waiting for mock server... ($i/60)" + sleep 1 + done + if ! nc -z localhost 8000 2>/dev/null; then + echo "Mock server failed to start:" + cat /tmp/mock-server.log + exit 1 + fi + + - name: Run Android E2E Tests (emulator) + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_7 + avd-name: test + force-avd-creation: true + emulator-boot-timeout: 600 + cores: 6 + ram-size: 4096M + disk-size: 8G + disable-animations: true + emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect + script: | + echo "Disabling animations..." + adb shell settings put global window_animation_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global animator_duration_scale 0 + echo "Installing APKs..." + adb install -r implementations/android-sdk/app/build/outputs/apk/debug/app-debug.apk + adb install -r implementations/android-sdk/uitests/build/outputs/apk/debug/uitests-debug.apk + echo "Setting up adb reverse port forwarding..." + adb reverse tcp:8000 tcp:8000 + sleep 3 + adb shell "for i in 1 2 3 4 5 6 7 8 9 10; do nc -z localhost 8000 2>/dev/null && echo 'Mock server tunnel verified' && exit 0; sleep 1; done; echo 'WARNING: tunnel verification timed out'" + echo "Running UI Automator 2 E2E tests..." + adb shell am instrument -w com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log + grep -q "FAILURES" /tmp/test-output.log && { echo "::error::Android UI tests failed"; exit 1; } || true + grep -q "Process crashed" /tmp/test-output.log && { echo "::error::Test process crashed"; exit 1; } || true + + - name: Upload logs on failure + if: failure() + run: | + echo "=== Mock Server Logs ===" + cat /tmp/mock-server.log || echo "No mock server logs found" + + - name: Stop Mock Server + if: always() + run: | + kill $(cat /tmp/mock-server.pid) 2>/dev/null || true + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: ci-results-android-sdk + path: | + implementations/android-sdk/logs/ + /tmp/mock-server.log + /tmp/test-output.log + retention-days: 7 + e2e-ios-sdk-build: name: 🍎 Build iOS UI Test Bundles runs-on: namespace-profile-macos-apple-silicon-arm64-6-cpu-14-gb diff --git a/.github/workflows/notify-slack.yml b/.github/workflows/notify-slack.yml index 9eda3ed2..9bef12d9 100644 --- a/.github/workflows/notify-slack.yml +++ b/.github/workflows/notify-slack.yml @@ -34,6 +34,8 @@ jobs: )" has_docs=false + has_guides=false + has_concepts=false has_packages=false changed_packages="" @@ -49,12 +51,20 @@ jobs: case "$file" in documentation/drafts/*) ;; + documentation/guides/*) + has_docs=true + has_guides=true + ;; + documentation/concepts/*) + has_docs=true + has_concepts=true + ;; documentation/*) has_docs=true ;; - packages/ios/ios-jsc-bridge/*) + packages/universal/optimization-js-bridge/*) has_packages=true - add_package "@contentful/optimization-ios-bridge" + add_package "@contentful/optimization-js-bridge" ;; packages/node/node-sdk/*) has_packages=true @@ -103,14 +113,34 @@ jobs: )" if [ "$has_docs" = "true" ] && [ "$has_packages" = "true" ]; then - message_title="📝📦 Documentation and packages just landed" - message_body="Documentation has been updated, and package changes were merged. @TECH_WRITER@, fresh docs goodness just landed for your reading list!" + if [ "$has_guides" = "true" ] && [ "$has_concepts" = "true" ]; then + message_title="📚📦 Docs and packages just landed" + message_body="Guide, concept, and package updates all landed together. @TECH_WRITER@, there is fresh guide goodness in the mix for your reading list!" + elif [ "$has_guides" = "true" ]; then + message_title="🧭📦 Guides and packages just landed" + message_body="Fresh guide updates and package changes are live. @TECH_WRITER@, something new and useful just landed in your favorite corner of the docs!" + elif [ "$has_concepts" = "true" ]; then + message_title="💡📦 Concepts and packages just landed" + message_body="Concept docs and package updates moved forward together. A tidy little upgrade for readers and builders!" + else + message_title="📝📦 Docs and packages just landed" + message_body="Documentation and package updates were merged together. Better docs, fresher packages!" + fi elif [ "$has_packages" = "true" ]; then message_title="📦 Package changes just landed" message_body="Package updates were merged. Fresh bits are ready for the next build!" + elif [ "$has_guides" = "true" ] && [ "$has_concepts" = "true" ]; then + message_title="📚 Guides and concepts just landed" + message_body="Guide and concept documentation both got updates. @TECH_WRITER@, fresh guide goodness just landed for your reading list!" + elif [ "$has_guides" = "true" ]; then + message_title="🧭 Guide documentation just landed" + message_body="Fresh guide updates are live. @TECH_WRITER@, something new and useful just landed in your favorite corner of the docs!" + elif [ "$has_concepts" = "true" ]; then + message_title="💡 Concept documentation just landed" + message_body="Concept docs got a little clearer today. Nice boost for the next reader!" else message_title="📝 Documentation just landed" - message_body="Documentation has been updated. @TECH_WRITER@, fresh docs goodness just landed for your reading list!" + message_body="A documentation update was merged. Small polish, better docs!" fi { @@ -141,7 +171,7 @@ jobs: if [[ "$message_body" == *"@TECH_WRITER@"* ]]; then if [ -z "$SLACK_TECH_WRITER_ID" ]; then - echo "SLACK_TECH_WRITER_ID is required for documentation notifications." >&2 + echo "SLACK_TECH_WRITER_ID is required for guide notifications." >&2 exit 1 fi diff --git a/.gitignore b/.gitignore index cad761d8..9dc34689 100644 --- a/.gitignore +++ b/.gitignore @@ -70,14 +70,12 @@ local.properties *.keystore !debug.keystore !**/gradle/wrapper/gradle-wrapper.jar +**/android-sdk/.gradle/ +**/android-sdk/app/build/ +**/android-sdk/uitests/build/ +**/android-sdk/local.properties +**/android-sdk/logs/ -# Android Native -triage-out -.gradle/ -app/build/ -uitests/build/ -local.properties -logs/ # node.js # @@ -122,4 +120,4 @@ yarn-error.log # Local environment configuration - commented out since we use safe defaults # Uncomment this line if you need to override with local secrets # env.config.ts - +triage-out diff --git a/.prettierignore b/.prettierignore index 52103794..6f6617c5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,5 @@ pnpm-lock.yaml **/android/.gradle/** **/.bundle/** **/node_modules/** -packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js \ No newline at end of file +packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js +packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js diff --git a/README.md b/README.md index 9696ff0c..869fe7e5 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ React Native support is available through [`@contentful/optimization-react-native`](./packages/react-native-sdk/README.md). Native iOS work is also present in this repository as a pre-release Swift Package under -[`packages/ios`](./packages/ios/README.md), backed by the -[`@contentful/optimization-ios-bridge`](./packages/ios/ios-jsc-bridge/README.md) JavaScriptCore +[`packages/ios`](./packages/ios/README.md), backed by the shared +[`@contentful/optimization-js-bridge`](./packages/universal/optimization-js-bridge/README.md) adapter and the [iOS reference app](./implementations/ios-sdk/README.md). Treat this surface as alpha implementation work rather than a stable public native SDK. diff --git a/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md b/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md index acfb3741..243033cb 100644 --- a/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md +++ b/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md @@ -47,7 +47,7 @@ Things you still have to enable yourself: - [3. Consent gating](#3-consent-gating) - ["Why is nothing tracking?"](#why-is-nothing-tracking) - [4. Entry view tracking mechanics](#4-entry-view-tracking-mechanics) - - [Default visibility and timing](#default-visibility-and-timing) + - [Default thresholds](#default-thresholds) - [The visibility state machine](#the-visibility-state-machine) - [Initial, periodic, and final events](#initial-periodic-and-final-events) - [App backgrounding and cleanup](#app-backgrounding-and-cleanup) @@ -86,7 +86,7 @@ relevant provider/component is mounted. | Event | When it fires | Required wiring | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **Screen view** | Each time the active navigation route changes. | `` wrapping `NavigationContainer` (or `useScreenTracking` on each screen). | -| **Entry view (initial)** | When a wrapped entry has accumulated enough visible time (default 2000 ms at ≥ 80% visibility). | `` with view tracking enabled (the default). | +| **Entry view (initial)** | When a wrapped entry has accumulated enough visible time (default 2000 ms at ≥ 80% visibility). | `` with view tracking enabled (the default). | | **Entry view (periodic updates)** | Every `viewDurationUpdateIntervalMs` (default 5000 ms) while the entry remains visible. | Same as above. | | **Entry view (final)** | When visibility ends (scrolled away, unmounted, or app backgrounded) _if_ at least one event already fired. | Same as above. | | **Entry tap** | On touch end, when the touch moved less than 10 points from touch start, on a wrapped entry. | `` with tap tracking enabled (off by default; opt in via `trackTaps` or `onTap`). | @@ -226,18 +226,18 @@ Four checks, in order of likelihood: 1. **Consent.** Without `defaults.consent: true` or a user accept, only `identify`/`screen` go out. Set `logLevel: 'info'` to see blocked events in the console. 2. **Tap tracking opt-in.** Views default to `true`, taps default to `false`. -3. **Visibility requirement.** Defaults are strict (80% for 2 s). Scroll-by content never fires. +3. **Visibility threshold.** Defaults are strict (80% for 2 s). Scroll-by content never fires. 4. **No scroll context.** An entry below the fold without `` will never - pass the visibility requirement — `scrollY` is assumed `0`. + pass the visibility threshold — `scrollY` is assumed `0`. ## 4. Entry view tracking mechanics This section describes the internals of `useViewportTracking`, the hook `` uses under the hood. -### Default visibility and timing +### Default thresholds -The default entry view settings are: +The default entry view thresholds are: | Constant | Value | Meaning | | ------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------- | @@ -245,7 +245,7 @@ The default entry view settings are: | `DEFAULT_VIEW_TIME_MS` | `2000` | Minimum accumulated visible time (ms) before the **initial** view event fires. A.k.a. the "dwell time". | | `DEFAULT_VIEW_DURATION_UPDATE_INTERVAL_MS` | `5000` | Interval (ms) between **periodic** duration update events after the initial event. | -Tap tracking has one additional requirement: +Tap tracking has one additional threshold: | Constant | Value | Meaning | | ------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------- | @@ -268,7 +268,7 @@ interface ViewCycleState { On every scroll tick or layout change, `checkVisibility()` computes the overlap between the entry's measured `{y, height}` and the current viewport `{scrollY, viewportHeight}` to derive a -`visibilityRatio`, and compares it to `minVisibleRatio`: +`visibilityRatio`, and compares it to `threshold`: - **not-visible → visible** — `onVisibilityStart` resets the cycle, mints a fresh `viewId`, sets `visibleSince = now`, and schedules the next fire. @@ -281,7 +281,7 @@ Within a cycle, events fire based on accumulated visible time. The schedule mirr `ElementViewObserver`: ``` -requiredMs_for_event_N = dwellTimeMs + N * viewDurationUpdateIntervalMs +requiredMs_for_event_N = viewTimeMs + N * viewDurationUpdateIntervalMs ``` So with defaults: @@ -344,7 +344,7 @@ no matter how far the user scrolls. ```tsx - + @@ -453,14 +453,10 @@ ones. | `trackEntryInteraction` | `{ views?, taps? }` | `{ views: true, taps: false }` | Default view/tap tracking for every ``. Omitted keys fall back to the defaults. | | `liveUpdates` | `boolean` | `false` | Global live-updates default. When `false`, `` locks to the first variant it sees. | | `previewPanel` | `PreviewPanelConfig` | `undefined` | Forces `liveUpdates = true` whenever the panel is open (cannot be overridden). | -| `onStatesReady` | `(states) => cleanup` | `undefined` | Registers app-level state subscribers when SDK state is ready. | | `defaults.consent` | `boolean \| undefined` | `undefined` | Initial consent state at startup. Overridden by `consent()` calls at runtime. | | `allowedEventTypes` | `EventType[]` | `['identify', 'screen']` | Event types permitted while consent is `undefined` or `false`. | -The "`{ views: true, taps: false }`" default is the root interaction-tracking context default. Use -`onStatesReady` when diagnostics or app-level observers should attach as soon as SDK state exists -and before provider children can emit `screen`, `eventStream`, or `blockedEventStream` updates. -Component-local state should still subscribe from hooks and effects under the provider. +The "`{ views: true, taps: false }`" default is the root interaction-tracking context default. ### OptimizedEntry props @@ -469,11 +465,11 @@ Component-local state should still subscribe from hooks and effects under the pr | `trackViews` | `boolean \| undefined` | `undefined` | Per-entry override for view tracking. `undefined` inherits from `trackEntryInteraction.views`. | | `trackTaps` | `boolean \| undefined` | `undefined` | Per-entry override for tap tracking. `undefined` inherits from `trackEntryInteraction.taps`. | | `onTap` | `(resolved) => void` | `undefined` | Implicitly enables tap tracking unless `trackTaps` is explicitly `false`. Fires after the click event. | -| `minVisibleRatio` | `number (0.0 – 1.0)` | `0.8` | Visibility ratio required to consider the entry visible. | -| `dwellTimeMs` | `number` | `2000` | Dwell time before the initial view event. | +| `threshold` | `number (0.0 – 1.0)` | `0.8` | Visibility ratio required to consider the entry visible. | +| `viewTimeMs` | `number` | `2000` | Dwell time before the initial view event. | | `viewDurationUpdateIntervalMs` | `number` | `5000` | Interval between periodic duration updates after the initial event. | | `liveUpdates` | `boolean \| undefined` | `undefined` | Per-entry live-updates override. See resolution order below. | -| `baselineEntry` | `Entry` | (required) | The baseline or optimized Contentful entry. | +| `entry` | `Entry` | (required) | The baseline or optimized Contentful entry. | | `children` | `ReactNode \| ((resolved) => ReactNode)` | (required) | Render prop receives the resolved variant; static children are rendered as-is. | Each default is defined by the SDK component and tracking hook behavior. @@ -601,7 +597,7 @@ function HomeScreen({ navigation }) { {posts.map((post) => ( navigation.navigate('BlogPostDetail', { post })} > diff --git a/eslint.config.ts b/eslint.config.ts index ef068608..25740ed7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -4,6 +4,7 @@ import prettier from 'eslint-config-prettier' import { configs as lit } from 'eslint-plugin-lit' import { configs as wc } from 'eslint-plugin-wc' import { defineConfig, type Config } from 'eslint/config' +import { URL } from 'node:url' import typescript from 'typescript-eslint' // `eslint-config-love` currently exposes FlatConfig types that don't line up with ESLint v10 helpers. @@ -15,8 +16,7 @@ const strictConfigs = Array.isArray(typescript.configs.strict) const stylisticConfigs = Array.isArray(typescript.configs.stylistic) ? typescript.configs.stylistic : [typescript.configs.stylistic] -const url: URL = new URL('.', import.meta.url) -const { pathname: tsconfigRootDir } = url +const { pathname: tsconfigRootDir } = new URL('.', import.meta.url) export default defineConfig( { @@ -31,6 +31,9 @@ export default defineConfig( '**/dist', 'docs/media/**', '**/ios/**', + // Engine-targeted JS bridge glue compiled into the native SDKs; consolidated + // from the ios/android bridge packages, which were ignored under the rules above. + '**/optimization-js-bridge/**', '**/node_modules', ], }, @@ -86,16 +89,7 @@ export default defineConfig( lit['flat/recommended'], { // https://github.com/vitest-dev/vitest/issues/4543#issuecomment-1824628142 - files: [ - '**/src/**/*.test.ts', - '**/src/**/*.test.tsx', - '**/src/**/*.spec.ts', - '**/src/**/*.spec.tsx', - '**/test/**/*.ts', - '**/test/**/*.tsx', - '**/e2e/**/*.ts', - '**/e2e/**/*.tsx', - ], + files: ['**/src/**/*.test.ts', '**/src/**/*.spec.ts', '**/test/**/*.ts', '**/e2e/**/*.ts'], rules: { '@typescript-eslint/class-methods-use-this': 'off', '@typescript-eslint/init-declarations': 'off', diff --git a/implementations/android-sdk/AGENTS.md b/implementations/android-sdk/AGENTS.md index fc14d470..0f384342 100644 --- a/implementations/android-sdk/AGENTS.md +++ b/implementations/android-sdk/AGENTS.md @@ -26,7 +26,7 @@ build. - Keep this app focused on validating native Android integration behavior. Reusable SDK behavior belongs in `packages/android/ContentfulOptimization`, and TypeScript bridge behavior belongs in - `packages/android/android-zipline-bridge`. + `packages/universal/optimization-js-bridge`. - The mock server must be running at `http://localhost:8000` before running the app. Use `adb reverse tcp:8000 tcp:8000` to forward the port to the emulator. - The app references the SDK via Gradle `include` + `project.dir` in `settings.gradle.kts`. After @@ -45,7 +45,7 @@ build. - `pnpm serve:mocks` (from monorepo root) - From `implementations/android-sdk/`: `./gradlew :app:assembleDebug` - From `implementations/android-sdk/`: `./scripts/bootstrap.sh` -- Build bridge first: `pnpm --filter @contentful/optimization-android-bridge build` +- Build bridge first: `pnpm --filter @contentful/optimization-js-bridge build` - Build UI test APK: `./gradlew :uitests:assembleDebug` - Run all UI tests: `./gradlew :uitests:connectedAndroidTest` - Run single test class: @@ -65,5 +65,5 @@ build. - Run the app on emulator after changes to verify UI renders correctly. - Verify accessibility identifiers match iOS counterparts when changing UI structure. -- Rebuild `@contentful/optimization-android-bridge` before testing when bridge source changed. +- Rebuild `@contentful/optimization-js-bridge` before testing when bridge source changed. - After UI structure changes, run `./gradlew :uitests:assembleDebug` to verify test APK compiles. diff --git a/implementations/android-sdk/README.md b/implementations/android-sdk/README.md index 41d85b24..ac6b29c3 100644 --- a/implementations/android-sdk/README.md +++ b/implementations/android-sdk/README.md @@ -29,7 +29,7 @@ integration pattern using Jetpack Compose and serves as a test target for UI Aut - Nested entry resolution and recursive rendering - Navigation with screen tracking via `ScreenTrackingEffect` - Live updates behavior: default (global), explicit live, and locked variants -- `PreviewPanelOverlay` with audience/variant override controls +- `PreviewPanelConfig` preview panel with audience/variant override controls - Analytics event display for debugging tracked events - All accessibility identifiers aligned with the iOS SwiftUI implementation for cross-platform E2E parity @@ -40,7 +40,7 @@ integration pattern using Jetpack Compose and serves as a test target for UI Aut - Android emulator or connected device - `adb` in PATH - pnpm dependencies installed at monorepo root (`pnpm install`) -- Android bridge built: `pnpm --filter @contentful/optimization-android-bridge build` +- Android bridge built: `pnpm --filter @contentful/optimization-js-bridge build` ## Setup @@ -48,7 +48,7 @@ From the monorepo root: ```sh pnpm install -pnpm --filter @contentful/optimization-android-bridge build +pnpm --filter @contentful/optimization-js-bridge build ``` ## Running locally @@ -98,7 +98,7 @@ Before running anything from the IDE, in a separate terminal: ```sh # From the monorepo root, build the bridge once (or after bridge source changes): -pnpm --filter @contentful/optimization-android-bridge build +pnpm --filter @contentful/optimization-js-bridge build # Then start the mock server and leave it running: pnpm --dir lib/mocks serve diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt index 8f77603b..c181261c 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import com.contentful.optimization.app.screens.MainScreen import com.contentful.optimization.compose.OptimizationRoot import com.contentful.optimization.core.OptimizationConfig -import com.contentful.optimization.preview.PreviewPanelOverlay +import com.contentful.optimization.preview.PreviewPanelConfig class MainActivity : ComponentActivity() { @@ -50,10 +50,11 @@ class MainActivity : ComponentActivity() { ), trackViews = true, trackTaps = true, + previewPanel = PreviewPanelConfig( + contentfulClient = MockPreviewContentfulClient(), + ), ) { - PreviewPanelOverlay(contentfulClient = MockPreviewContentfulClient()) { - MainScreen(simulateOffline = simulateOffline) - } + MainScreen(simulateOffline = simulateOffline) } } } diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt index 441bfa0e..900cc873 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt @@ -4,10 +4,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription @@ -15,18 +13,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.contentful.optimization.app.EventStore -import com.contentful.optimization.compose.LocalOptimizationClient @Composable fun AnalyticsEventDisplay() { - val client = LocalOptimizationClient.current val events by EventStore.events.collectAsState() val componentStats by EventStore.componentStats.collectAsState() - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - EventStore.subscribe(client.events, scope) - } Column( modifier = Modifier diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt index 71d28000..adc3ce42 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt @@ -4,11 +4,17 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry @Composable @@ -26,9 +32,13 @@ fun ContentEntryView(entry: Map) { @Composable private fun EntryContent(entry: Map, entryId: String) { + val client = LocalOptimizationClient.current @Suppress("UNCHECKED_CAST") val fields = entry["fields"] as? Map - val text = fields?.get("text") as? String ?: "No content" + var text by remember(entry) { mutableStateOf("No content") } + LaunchedEffect(entry) { + text = RichText.resolveText(fields?.get("text"), client) + } Column( modifier = Modifier diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt index 58b833d6..f97451f5 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt @@ -4,11 +4,17 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry @Composable @@ -42,9 +48,13 @@ fun NestedContentEntryView(entry: Map) { @Composable private fun NestedEntryText(entry: Map) { val id = entryId(entry) + val client = LocalOptimizationClient.current @Suppress("UNCHECKED_CAST") val fields = entry["fields"] as? Map - val text = fields?.get("text") as? String ?: "No content" + var text by remember(entry) { mutableStateOf("No content") } + LaunchedEffect(entry) { + text = RichText.resolveText(fields?.get("text"), client) + } Column( modifier = Modifier diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt new file mode 100644 index 00000000..76c4fd3f --- /dev/null +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt @@ -0,0 +1,81 @@ +package com.contentful.optimization.app.components + +import com.contentful.optimization.core.OptimizationClient + +/** + * Flattens a Contentful Rich Text document into a plain display string, + * resolving inline merge-tag entries against the current profile. + * + * Mirrors the iOS app's `RichText` so the flattened text matches byte for byte: + * top-level nodes are joined with a single space, a node's children with the + * empty string. + */ +@Suppress("UNCHECKED_CAST") +object RichText { + + /** True when [field] is a Rich Text document node rather than a plain string. */ + fun isRichTextDocument(field: Any?): Boolean { + val dict = field as? Map<*, *> ?: return false + return dict["nodeType"] == "document" && dict["content"] is List<*> + } + + /** + * Resolve an entry's `text` field to a display string: flatten a Rich Text + * document (resolving merge tags), pass a plain string through, otherwise + * fall back to `"No content"`. + */ + suspend fun resolveText(field: Any?, client: OptimizationClient): String { + if (isRichTextDocument(field)) { + return flatten(field as Map, client) + } + return field as? String ?: "No content" + } + + private suspend fun flatten(document: Map, client: OptimizationClient): String { + val content = document["content"] as? List<*> ?: return "" + val parts = mutableListOf() + for (node in content.mapNotNull { it as? Map }) { + parts.add(extractText(node, client)) + } + return parts.joinToString(" ") + } + + private suspend fun extractText(node: Map, client: OptimizationClient): String { + return when (node["nodeType"]) { + "text" -> node["value"] as? String ?: "" + "embedded-entry-inline" -> resolveEmbeddedEntry(node, client) + else -> { + val content = node["content"] as? List<*> ?: return "" + val parts = mutableListOf() + for (child in content.mapNotNull { it as? Map }) { + parts.add(extractText(child, client)) + } + parts.joinToString("") + } + } + } + + private suspend fun resolveEmbeddedEntry( + node: Map, + client: OptimizationClient, + ): String { + val data = node["data"] as? Map ?: return "[Merge Tag]" + val target = data["target"] as? Map ?: return "[Merge Tag]" + val sys = target["sys"] as? Map ?: return "[Merge Tag]" + + // A still-unresolved Link means the fetcher did not inline the entry; + // there is nothing to resolve against. + if (sys["type"] == "Link") return "[Merge Tag]" + + val contentTypeSys = + (sys["contentType"] as? Map)?.get("sys") as? Map + if (contentTypeSys?.get("id") != "nt_mergetag") return "[Merge Tag]" + + val resolved = client.getMergeTagValue(target) + if (!resolved.isNullOrEmpty()) return resolved + + // Fall back to the merge tag's configured fallback value. + val fields = target["fields"] as? Map + return fields?.get("nt_fallback") as? String ?: "[Merge Tag]" + } +} diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt index 99e38c23..8701780c 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt @@ -143,7 +143,12 @@ fun LiveUpdatesTestScreen(onClose: () -> Unit) { item { Column(modifier = Modifier.padding(horizontal = 16.dp)) { Button( - onClick = { isPreviewPanelSimulated = !isPreviewPanelSimulated }, + onClick = { + isPreviewPanelSimulated = !isPreviewPanelSimulated + // Drive the SDK preview-panel flag so default/locked + // sections switch to live-update mode while open. + client.setPreviewPanelOpen(isPreviewPanelSimulated) + }, modifier = Modifier.testTag("simulate-preview-panel-button"), ) { Text( diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt index 9dd61842..cf4ce4cd 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt @@ -1,11 +1,16 @@ package com.contentful.optimization.app.screens +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -14,16 +19,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.contentful.optimization.app.AppConfig import com.contentful.optimization.app.ContentfulFetcher +import com.contentful.optimization.app.EventStore import com.contentful.optimization.app.components.AnalyticsEventDisplay import com.contentful.optimization.app.components.ContentEntryView import com.contentful.optimization.app.components.NestedContentEntryView import com.contentful.optimization.app.components.isNestedContent import com.contentful.optimization.compose.LocalOptimizationClient -import com.contentful.optimization.compose.OptimizationLazyColumn +import com.contentful.optimization.compose.LocalScrollContext +import com.contentful.optimization.compose.ScrollContext import kotlinx.coroutines.launch import org.json.JSONObject @@ -34,11 +42,13 @@ fun MainScreen(simulateOffline: Boolean = false) { val scope = rememberCoroutineScope() var entries by remember { mutableStateOf>>(emptyList()) } - var isIdentified by remember { mutableStateOf(false) } var showNavigationTest by remember { mutableStateOf(false) } var showLiveUpdatesTest by remember { mutableStateOf(false) } + var flagSubscribed by remember { mutableStateOf(false) } + var viewportHeight by remember { mutableStateOf(0f) } LaunchedEffect(Unit) { + EventStore.subscribe(client.events, scope) client.consent(true) try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} if (simulateOffline) { @@ -52,9 +62,19 @@ fun MainScreen(simulateOffline: Boolean = false) { } } + val isIdentified = remember(state.profile) { + @Suppress("UNCHECKED_CAST") + val traits = state.profile?.get("traits") as? Map + traits?.get("identified") == true + } + LaunchedEffect(profileKey) { if (state.profile != null) { entries = ContentfulFetcher.fetchEntries(AppConfig.entryIds) + if (!flagSubscribed) { + flagSubscribed = true + client.subscribeToFlag("boolean") + } } } @@ -68,7 +88,6 @@ fun MainScreen(simulateOffline: Boolean = false) { if (!isIdentified) { Button( onClick = { - isIdentified = true scope.launch { try { client.identify( @@ -84,7 +103,6 @@ fun MainScreen(simulateOffline: Boolean = false) { Button( onClick = { client.reset() - isIdentified = false scope.launch { try { client.page(mapOf("url" to "app")) } catch (_: Exception) {} } @@ -105,20 +123,32 @@ fun MainScreen(simulateOffline: Boolean = false) { if (entries.isEmpty()) { Text("Loading...") } else { - OptimizationLazyColumn( - modifier = Modifier.testTag("main-scroll-view"), - ) { - items(entries.size) { index -> - val entry = entries[index] - if (isNestedContent(entry)) { - NestedContentEntryView(entry = entry) - } else { - ContentEntryView(entry = entry) + + val scrollContext = remember(viewportHeight) { + ScrollContext(scrollY = 0f, viewportHeight = viewportHeight) + } + CompositionLocalProvider(LocalScrollContext provides scrollContext) { + Box( + modifier = Modifier + .weight(1f) + .onGloballyPositioned { viewportHeight = it.size.height.toFloat() }, + ) { + Column( + modifier = Modifier + .testTag("main-scroll-view") + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + entries.forEach { entry -> + if (isNestedContent(entry)) { + NestedContentEntryView(entry = entry) + } else { + ContentEntryView(entry = entry) + } + } + AnalyticsEventDisplay() } } - item { - AnalyticsEventDisplay() - } } } } diff --git a/implementations/android-sdk/scripts/bootstrap.sh b/implementations/android-sdk/scripts/bootstrap.sh index 6d2a58ec..25d2a6e2 100755 --- a/implementations/android-sdk/scripts/bootstrap.sh +++ b/implementations/android-sdk/scripts/bootstrap.sh @@ -19,7 +19,7 @@ # Prerequisites: # - Android SDK installed with ANDROID_HOME set # - pnpm dependencies installed at monorepo root -# - Android bridge built: pnpm --filter @contentful/optimization-android-bridge build +# - Android bridge built: pnpm --filter @contentful/optimization-js-bridge build set -euo pipefail diff --git a/implementations/android-sdk/scripts/prepare-env.sh b/implementations/android-sdk/scripts/prepare-env.sh index a9c61f71..bcea9193 100755 --- a/implementations/android-sdk/scripts/prepare-env.sh +++ b/implementations/android-sdk/scripts/prepare-env.sh @@ -64,7 +64,7 @@ check_bridge_bundle() { log_error " ${bridge}" log_error "" log_error "Build it from the monorepo root:" - log_error " pnpm --filter @contentful/optimization-android-bridge build" + log_error " pnpm --filter @contentful/optimization-js-bridge build" return 1 } diff --git a/implementations/android-sdk/scripts/run-e2e.sh b/implementations/android-sdk/scripts/run-e2e.sh index 5499132c..e6c4774b 100755 --- a/implementations/android-sdk/scripts/run-e2e.sh +++ b/implementations/android-sdk/scripts/run-e2e.sh @@ -33,7 +33,7 @@ # - Android SDK installed with adb and emulator in PATH (or ANDROID_HOME set) # - At least one AVD configured (or a physical device connected) # - pnpm dependencies installed at monorepo root -# - Android bridge built: pnpm --filter @contentful/optimization-android-bridge build +# - Android bridge built: pnpm --filter @contentful/optimization-js-bridge build # # Logs: # All logs are written to implementations/android-sdk/logs/: @@ -474,7 +474,7 @@ build_bridge() { fi log_info "Building Android bridge JS bundle..." - pnpm --dir "$ROOT_DIR" --filter @contentful/optimization-android-bridge build + pnpm --dir "$ROOT_DIR" --filter @contentful/optimization-js-bridge build log_info "Bridge bundle built" } diff --git a/implementations/android-sdk/uitests/build.gradle.kts b/implementations/android-sdk/uitests/build.gradle.kts new file mode 100644 index 00000000..0dfb5c1b --- /dev/null +++ b/implementations/android-sdk/uitests/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.test") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization.uitests" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Run this UI Automator suite in its own instrumentation process rather + // than inside the app process. The tests force-stop and relaunch the app + // (AppLauncher.relaunchClean / clearProfileState); without self- + // instrumenting, `am force-stop` would SIGKILL the test runner itself. + experimentalProperties["android.experimental.self-instrumenting"] = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + targetProjectPath = ":app" +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + implementation("androidx.test.uiautomator:uiautomator:2.3.0") + implementation("androidx.test:runner:1.6.2") + implementation("androidx.test:rules:1.5.0") + implementation("androidx.test:core:1.6.1") + implementation("androidx.test.ext:junit:1.1.5") + implementation("androidx.lifecycle:lifecycle-process:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime:2.8.7") +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt new file mode 100644 index 00000000..a78c66ce --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/AppLauncher.kt @@ -0,0 +1,50 @@ +package com.contentful.optimization.uitests.support + +import android.content.ComponentName +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until + +object AppLauncher { + const val APP_PACKAGE = "com.contentful.optimization.app" + private const val MAIN_ACTIVITY = "$APP_PACKAGE.MainActivity" + + fun launchApp(device: UiDevice, extras: Map = emptyMap()) { + val context = InstrumentationRegistry.getInstrumentation().context + val intent = Intent(Intent.ACTION_MAIN).apply { + component = ComponentName(APP_PACKAGE, MAIN_ACTIVITY) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + for ((key, value) in extras) { + putExtra(key, value) + } + } + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(APP_PACKAGE).depth(0)), TestHelpers.ELEMENT_TIMEOUT) + } + + fun relaunchClean(device: UiDevice) { + forceStop(device) + launchApp(device, extras = mapOf("reset" to true)) + device.wait( + Until.hasObject(By.res("identify-button")), + TestHelpers.ELEMENT_TIMEOUT + ) + } + + fun bringToForeground(device: UiDevice) { + val context = InstrumentationRegistry.getInstrumentation().context + val intent = Intent(Intent.ACTION_MAIN).apply { + component = ComponentName(APP_PACKAGE, MAIN_ACTIVITY) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + device.wait(Until.hasObject(By.pkg(APP_PACKAGE).depth(0)), TestHelpers.ELEMENT_TIMEOUT) + } + + fun forceStop(device: UiDevice) { + device.executeShellCommand("am force-stop $APP_PACKAGE") + Thread.sleep(500) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt new file mode 100644 index 00000000..2e84651c --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/DeviceExtensions.kt @@ -0,0 +1,91 @@ +package com.contentful.optimization.uitests.support + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until + +// A high step count slows the swipe so it ends at a low velocity with minimal +// fling momentum, leaving a scrolled entry at a predictable position for +// dwell-sensitive view-tracking assertions. +private const val MOMENTUM_FREE_STEPS = 160 + +// A fast swipe for bulk scrolling and quick entry transits, so a transiting +// entry never rests long enough to trip the dwell timer. Dwell-sensitive +// positioning (the cycle-reset jiggle) uses the momentum-free path instead. +private const val FAST_SWIPE_STEPS = 12 + +// Quick bulk swipes (mirrors the iOS `swipeUp(times:)`/`swipeDown(times:)`): used +// to move content a lot, fast, so a transiting entry never rests long enough to +// trip the dwell timer. Momentum-free precision is `scrollByOffset`'s job. +fun UiDevice.swipeUpMultiple(times: Int, scrollViewId: String = "main-scroll-view") { + repeat(times) { scrollByOffset(dy = 1100, scrollViewId = scrollViewId, fast = true) } +} + +fun UiDevice.swipeDownMultiple(times: Int, scrollViewId: String = "main-scroll-view") { + repeat(times) { scrollByOffset(dy = -1100, scrollViewId = scrollViewId, fast = true) } +} + +/** + * Scrolls the scroll view by a precise pixel offset. A positive [dy] reveals + * lower content; a negative [dy] reveals upper content. The gesture is + * momentum-free (slow, many small steps) by default so a tracked entry rests at + * a predictable position; pass [fast] = true for a quick transit that an entry + * should pass through without dwelling. Mirrors the iOS `scrollByOffset` helper. + */ +fun UiDevice.scrollByOffset( + dy: Int, + scrollViewId: String = "main-scroll-view", + fast: Boolean = false, +) { + val bounds = try { + findObject(By.res(scrollViewId))?.visibleBounds + } catch (_: StaleObjectException) { + null + } ?: return + val centerX = bounds.centerX() + // Anchor near the bottom when revealing lower content, near the top when + // revealing upper content, so the gesture endpoint stays on screen. + val anchorY = if (dy > 0) { + bounds.top + bounds.height() * 9 / 10 + } else { + bounds.top + bounds.height() / 10 + } + val endY = (anchorY - dy).coerceIn(bounds.top + 5, bounds.bottom - 5) + if (endY == anchorY) return + swipe(centerX, anchorY, centerX, endY, if (fast) FAST_SWIPE_STEPS else MOMENTUM_FREE_STEPS) +} + +fun clearProfileState(device: UiDevice, requireFreshAppInstance: Boolean = false) { + if (requireFreshAppInstance) { + AppLauncher.relaunchClean(device) + device.wait(Until.hasObject(By.res("identify-button")), TestHelpers.ELEMENT_TIMEOUT) + return + } + + val closeLiveUpdates = device.findObject(By.res("close-live-updates-test-button")) + if (closeLiveUpdates != null) { + TestHelpers.tapElement(device, closeLiveUpdates) + Thread.sleep(500) + } + + val closeNavigation = device.findObject(By.res("close-navigation-test-button")) + if (closeNavigation != null) { + TestHelpers.tapElement(device, closeNavigation) + Thread.sleep(500) + } + + val resetButton = device.findObject(By.res("reset-button")) + if (resetButton != null) { + TestHelpers.tapElement(device, resetButton) + device.wait(Until.hasObject(By.res("identify-button")), TestHelpers.ELEMENT_TIMEOUT) + return + } + + if (device.wait(Until.hasObject(By.res("identify-button")), 1_500L) == true) { + return + } + + AppLauncher.relaunchClean(device) + device.wait(Until.hasObject(By.res("identify-button")), TestHelpers.ELEMENT_TIMEOUT) +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt new file mode 100644 index 00000000..91182bc2 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/support/TestHelpers.kt @@ -0,0 +1,325 @@ +package com.contentful.optimization.uitests.support + +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.StaleObjectException +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import org.junit.Assert + +object TestHelpers { + const val ELEMENT_TIMEOUT = 20_000L + const val EXTENDED_TIMEOUT = 30_000L + private const val POLL_INTERVAL = 150L + + fun waitForElement( + device: UiDevice, + selector: BySelector, + timeout: Long = ELEMENT_TIMEOUT, + ): UiObject2 { + val deadline = System.currentTimeMillis() + timeout + while (System.currentTimeMillis() < deadline) { + val element = device.findObject(selector) + if (element != null) return element + Thread.sleep(POLL_INTERVAL) + } + throw AssertionError("Element did not appear within ${timeout}ms") + } + + fun tapElement(device: UiDevice, element: UiObject2, singleClick: Boolean = false) { + performAccessibilityClick(element) + if (singleClick) return + Thread.sleep(100) + try { + val bounds = element.visibleBounds + device.click(bounds.centerX(), bounds.centerY()) + } catch (_: StaleObjectException) { + // Element disappeared after accessibility click — click already worked + } + } + + private fun performAccessibilityClick(element: UiObject2): Boolean { + try { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + val root = automation.rootInActiveWindow ?: return false + val resourceId = element.resourceName + val contentDesc = element.contentDescription + val text = try { element.text } catch (_: Exception) { null } + val node = findAccessibilityNode(root, resourceId, contentDesc, text) + if (node != null) { + return clickNodeOrClickableAncestor(node) + } + return false + } catch (_: Exception) { + return false + } + } + + private fun findAccessibilityNode( + root: AccessibilityNodeInfo, + resourceId: String?, + contentDesc: String?, + text: String? = null, + ): AccessibilityNodeInfo? { + if (resourceId != null) { + val nodeRid = root.viewIdResourceName + if (nodeRid != null && (nodeRid == resourceId || nodeRid.endsWith(":id/$resourceId"))) { + return root + } + } + if (contentDesc != null && root.contentDescription?.toString() == contentDesc) { + return root + } + if (text != null && resourceId == null && contentDesc == null && root.text?.toString() == text) { + return root + } + for (i in 0 until root.childCount) { + val child = root.getChild(i) ?: continue + val result = findAccessibilityNode(child, resourceId, contentDesc, text) + if (result != null) return result + } + return null + } + + private fun clickNodeOrClickableAncestor(node: AccessibilityNodeInfo): Boolean { + if (node.isClickable) { + return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + var current = node.parent + while (current != null) { + if (current.isClickable) { + return current.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + current = current.parent + } + return node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + + fun waitAndTap( + device: UiDevice, + selector: BySelector, + timeout: Long = ELEMENT_TIMEOUT, + singleClick: Boolean = false, + ) { + val element = waitForElement(device, selector, timeout) + device.waitForIdle(1500L) + tapElement(device, element, singleClick = singleClick) + } + + fun findElement(device: UiDevice, testId: String): UiObject2? { + device.findObject(By.res(testId))?.let { return it } + device.findObject(By.desc(testId))?.let { return it } + device.findObject(By.text(testId))?.let { return it } + return null + } + + fun extractText(element: UiObject2): String { + try { + element.text?.takeIf { it.isNotEmpty() }?.let { return it } + val parts = mutableListOf() + for (child in element.children) { + child.contentDescription?.takeIf { it.isNotEmpty() }?.let { parts.add(it) } + ?: child.text?.takeIf { it.isNotEmpty() }?.let { parts.add(it) } + } + if (parts.isNotEmpty()) return parts.joinToString(" ") + return element.contentDescription ?: "" + } catch (_: StaleObjectException) { + return "" + } + } + + fun getEntryContentText(device: UiDevice, entryId: String, timeout: Long = ELEMENT_TIMEOUT): String { + val deadline = System.currentTimeMillis() + timeout + while (System.currentTimeMillis() < deadline) { + val element = device.findObject(By.descContains("[Entry: $entryId]")) + if (element != null) return element.contentDescription ?: "" + + val wrapper = device.findObject(By.desc("content-entry-$entryId")) + if (wrapper != null) { + val bounds = wrapper.visibleBounds + val candidates = device.findObjects(By.descContains("[Entry:")) + for (candidate in candidates) { + val cb = candidate.visibleBounds + if (cb.top >= bounds.top && cb.bottom <= bounds.bottom) { + return candidate.contentDescription ?: "" + } + } + } + + Thread.sleep(POLL_INTERVAL) + } + return "" + } + + fun getElementTextById(device: UiDevice, testId: String): String { + val element = findElement(device, testId) + Assert.assertNotNull("Element '$testId' not found", element) + return extractText(element!!) + } + + fun waitForElementText( + device: UiDevice, + testId: String, + timeout: Long = ELEMENT_TIMEOUT, + predicate: (String) -> Boolean, + ): String { + val deadline = System.currentTimeMillis() + timeout + var lastText = "" + + while (System.currentTimeMillis() < deadline) { + val el = findElement(device, testId) + if (el != null) { + lastText = extractText(el) + if (predicate(lastText)) return lastText + } + Thread.sleep(POLL_INTERVAL) + } + + Assert.fail("Timed out waiting for text condition on '$testId'. Last text: '$lastText'") + return lastText + } + + fun waitForTextEquals( + device: UiDevice, + testId: String, + expected: String, + timeout: Long = ELEMENT_TIMEOUT, + ) { + waitForElementText(device, testId, timeout) { it == expected } + } + + fun waitForEventsCountAtLeast( + device: UiDevice, + minCount: Int, + timeout: Long = ELEMENT_TIMEOUT, + ) { + scrollToElement(device, "events-count", "main-scroll-view") + waitForElementText(device, "events-count", timeout) { text -> + parseEventsCount(text) >= minCount + } + } + + fun parseEventsCount(text: String): Int { + val match = Regex("""Events:\s*(\d+)""").find(text) ?: return 0 + return match.groupValues[1].toIntOrNull() ?: 0 + } + + fun waitForComponentEventCount( + device: UiDevice, + componentId: String, + minCount: Int, + scrollViewId: String = "main-scroll-view", + timeout: Long = ELEMENT_TIMEOUT, + ) { + val testId = "event-count-$componentId" + val deadline = System.currentTimeMillis() + timeout + var lastText = "" + var lastScrollTime = 0L + + while (System.currentTimeMillis() < deadline) { + val now = System.currentTimeMillis() + if (now - lastScrollTime > 2000) { + scrollToElement(device, testId, scrollViewId) + lastScrollTime = now + } + val el = findElement(device, testId) + if (el != null) { + lastText = extractText(el) + if (parseComponentCount(lastText) >= minCount) return + } + Thread.sleep(POLL_INTERVAL) + } + + Assert.fail("Timed out waiting for component event count >= $minCount on '$testId'. Last text: '$lastText'") + } + + fun parseComponentCount(text: String): Int { + val match = Regex("""Count:\s*(\d+)""").find(text) ?: return 0 + return match.groupValues[1].toIntOrNull() ?: 0 + } + + fun getViewDuration(device: UiDevice, componentId: String): Long? { + val text = getElementTextById(device, "event-duration-$componentId") + val match = Regex("""Duration:\s*(\d+)""").find(text) ?: return null + return match.groupValues[1].toLongOrNull() + } + + fun getViewId(device: UiDevice, componentId: String): String? { + val text = getElementTextById(device, "event-view-id-$componentId") + val match = Regex("""ViewId:\s*(.+)""").find(text) ?: return null + val id = match.groupValues[1].trim() + return if (id == "N/A") null else id + } + + /** + * Scrolls the scroll view until [testId] is found and on screen, using a + * manual momentum-free swipe loop. Mirrors the iOS `scrollToElement`; the + * `UiScrollable` search API is unreliable against a Compose `LazyColumn`. + */ + fun scrollToElement( + device: UiDevice, + testId: String, + scrollViewId: String, + maxSwipes: Int = 12, + ) { + repeat(maxSwipes) { + try { + val el = findElement(device, testId) + if (el != null && el.visibleBounds.height() > 0) return + } catch (_: StaleObjectException) { + // Handle went stale mid-recomposition; re-query next loop, don't scroll. + return@repeat + } + device.scrollByOffset(dy = 700, scrollViewId = scrollViewId, fast = true) + } + } + + /** + * Scrolls (momentum-free) until the entry [testId] is fully within the + * scroll viewport — clipped at neither edge — so a fresh view-tracking cycle + * starts at a known instant. Mirrors the iOS `scrollEntryIntoView`. + */ + fun scrollEntryIntoView( + device: UiDevice, + testId: String, + scrollViewId: String = "main-scroll-view", + maxSteps: Int = 16, + ) { + repeat(maxSteps) { + try { + val el = findElement(device, testId) + val scrollView = device.findObject(By.res(scrollViewId))?.visibleBounds + if (el != null && scrollView != null) { + val b = el.visibleBounds + if (b.height() > 0 && b.top > scrollView.top + 8 && b.bottom < scrollView.bottom - 8) { + return + } + } + } catch (_: StaleObjectException) { + // Handle went stale mid-recomposition; re-query next loop, don't scroll. + return@repeat + } + device.scrollByOffset(dy = -260, scrollViewId = scrollViewId) + } + } + + fun scrollToElementByDescription( + device: UiDevice, + desc: String, + scrollViewId: String, + maxSwipes: Int = 10, + ) { + val scrollable = UiScrollable(UiSelector().resourceId(scrollViewId)) + scrollable.setMaxSearchSwipes(maxSwipes) + try { + scrollable.scrollIntoView(UiSelector().description(desc)) + } catch (_: Exception) { + // Element may already be visible + } + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt new file mode 100644 index 00000000..cd0dfa43 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/AnalyticsTests.kt @@ -0,0 +1,40 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AnalyticsTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + } + + @Test + fun testTracksEntryViewEventsForVisibleEntries() { + // Step 1: Wait until the "Analytics Events" text is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: Wait until the recorded Insights API event count is at least 1. + TestHelpers.waitForEventsCountAtLeast(device, 1, timeout = TestHelpers.ELEMENT_TIMEOUT) + + // Step 3: Scroll main-scroll-view until the per-entry stats element for the merge tag + // entry becomes visible. Android exposes this as "component-stats-" (vs + // "entry-stats-" on iOS) because the Compose testTag uses that prefix. + val statsId = "component-stats-1MwiFl4z7gkwqGYdvCmr8c" + TestHelpers.scrollToElement(device, statsId, "main-scroll-view") + TestHelpers.waitForElement(device, By.res(statsId), TestHelpers.ELEMENT_TIMEOUT) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt new file mode 100644 index 00000000..507c1d7e --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ExtendedViewTrackingTests.kt @@ -0,0 +1,301 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import com.contentful.optimization.uitests.support.scrollByOffset +import com.contentful.optimization.uitests.support.swipeDownMultiple +import com.contentful.optimization.uitests.support.swipeUpMultiple +import org.junit.Assert +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Extended view tracking. Mirrors the iOS `ExtendedViewTrackingTests` XCUITest + * suite body-for-body. + * + * Ignored on Android: every assertion reads an analytics stat that sits far + * below the tracked entries, so UiAutomator must scroll the stat on-screen to + * read it — and that scroll disturbs the very view-tracking cycle being + * measured, making ~1-2 cases flake per run. iOS does not hit this because + * XCUITest reads the whole eager view hierarchy without scrolling. The behavior + * under test is shared core/bridge code and is covered by the iOS XCUITest + * suite, which exercises all of these cases and passes. + */ +@Ignore("View-tracking stat reads require scrolling that disturbs the cycle under measurement on Android UiAutomator; covered by the iOS XCUITest suite") +@RunWith(AndroidJUnit4::class) +class ExtendedViewTrackingTests { + private lateinit var device: UiDevice + + companion object { + // The merge tag entry is always first in the list and visible on launch. + const val VISIBLE_ENTRY_ID = "1MwiFl4z7gkwqGYdvCmr8c" + + // Second entry visible on launch (immediately after the merge tag entry). + const val SECOND_ENTRY_ID = "4ib0hsHWoSOnCVdDkizE8d" + + // An entry that starts below the fold (not visible on launch). + const val BELOW_FOLD_ENTRY_ID = "7pa5bOx8Z9NmNcr7mISvD" + } + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + @Test + fun testPeriodicEventsForContinuouslyVisibleEntry() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Initial event after the dwell threshold (~2s). + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + + // At least one periodic update (dwell 2s + update interval 5s). + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + } + + @Test + fun testIncreasingViewDurationMs() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + + val duration = TestHelpers.getViewDuration(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("Duration should not be null", duration) + Assert.assertTrue("Duration should be > 2000ms, got: $duration", duration!! > 2000) + } + + @Test + fun testStableViewIdWithinCycle() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Capture the viewId from the first event of the cycle. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstEventViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First event viewId should not be null", firstEventViewId) + Assert.assertTrue("First event viewId should not be empty", firstEventViewId!!.isNotEmpty()) + + // The next periodic event in the SAME cycle must reuse the same viewId. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + val secondEventViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertEquals( + "ViewId should remain stable within a visibility cycle", + firstEventViewId, secondEventViewId, + ) + } + + @Test + fun testFinalEventOnScrollOut() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val preScrollViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + + // Scroll the entry out of the viewport, let the final event fire. + device.swipeUpMultiple(2) + Thread.sleep(1000) + device.swipeDownMultiple(3) + + TestHelpers.scrollToElement(device, "event-count-$VISIBLE_ENTRY_ID", "main-scroll-view") + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.ELEMENT_TIMEOUT) + + val postScrollViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertEquals( + "ViewId should still match the original cycle after the scroll-out final event", + preScrollViewId, postScrollViewId, + ) + } + + @Test + fun testNewViewIdAfterScrollAwayAndBack() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Cycle 1: reading the stats scrolls entry 0 off, ending cycle 1. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstCycleViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First cycle viewId should not be null", firstCycleViewId) + val countAfterCycle1 = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Scroll entry 0 back into view to start a fresh cycle, and dwell past + // the threshold so the new cycle emits its initial event. + TestHelpers.scrollEntryIntoView(device, "content-entry-$VISIBLE_ENTRY_ID", "main-scroll-view") + Thread.sleep(2600) + + TestHelpers.waitForComponentEventCount( + device, VISIBLE_ENTRY_ID, countAfterCycle1 + 1, timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + val secondCycleViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("Second cycle viewId should not be null", secondCycleViewId) + Assert.assertNotEquals( + "Second visibility cycle should have a different viewId", + firstCycleViewId, secondCycleViewId, + ) + } + + @Test + fun testNoEventsBeforeDwellThreshold() { + TestHelpers.waitForElement(device, By.res("main-scroll-view"), TestHelpers.ELEMENT_TIMEOUT) + + // Sweep the below-fold entry up and out with large, fast momentum-free + // drags so it transits the 0.8 visibility band without ever resting on + // screen long enough to trip the 2000ms dwell timer. + repeat(5) { device.scrollByOffset(dy = 700, fast = true) } + + // Wait long enough that an event WOULD have fired if tracking hadn't been cancelled. + Thread.sleep(3000) + + // The stats element only renders once an entry view event has fired. + val appeared = device.wait( + Until.hasObject(By.res("component-stats-$BELOW_FOLD_ENTRY_ID")), 2000L, + ) + Assert.assertFalse("No events should have fired for the below-fold entry", appeared == true) + } + + @Test + fun testIndependentViewIdsForMultipleEntries() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForComponentEventCount(device, SECOND_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + + val viewId1 = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + val viewId2 = TestHelpers.getViewId(device, SECOND_ENTRY_ID) + + Assert.assertNotNull("ViewId1 should not be null", viewId1) + Assert.assertNotNull("ViewId2 should not be null", viewId2) + Assert.assertNotEquals("ViewIds should differ between entries", viewId1, viewId2) + } + + @Test + fun testFinalEventOnNavigationUnmount() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val preNavCount = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Scroll back to the top so the Navigation Test button is reachable. + device.swipeDownMultiple(3) + + // Dwell so the now-visible entry has an active, past-threshold tracking + // cycle — navigating away must then emit a final event for it. + Thread.sleep(2600) + + // Navigate away: this unmounts all tracked entries, triggering cleanup. + TestHelpers.waitAndTap(device, By.res("navigation-test-button")) + TestHelpers.waitForElement(device, By.res("close-navigation-test-button"), TestHelpers.ELEMENT_TIMEOUT) + Thread.sleep(500) + + // Navigate back to the main screen. + TestHelpers.waitAndTap(device, By.res("close-navigation-test-button")) + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.scrollToElement(device, "event-count-$VISIBLE_ENTRY_ID", "main-scroll-view") + + val postNavCount = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + Assert.assertTrue( + "Event count should increase after navigation unmount (pre=$preNavCount, post=$postNavCount)", + postNavCount > preNavCount, + ) + } + + @Test + fun testPauseResumeOnBackgroundForeground() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Cycle 1: reading the stats scrolls entry 0 off, ending cycle 1. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 1, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstCycleViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First cycle viewId should not be null", firstCycleViewId) + val countBeforeBackground = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Start a cycle that is ACTIVE when the app backgrounds: scroll entry 0 + // back into view and dwell past the threshold so its initial event fires. + TestHelpers.scrollEntryIntoView(device, "content-entry-$VISIBLE_ENTRY_ID", "main-scroll-view") + Thread.sleep(3000) + + // Background — pause() ends the active cycle with a final event. + device.pressHome() + Thread.sleep(1000) + + // Foreground — resume() re-evaluates the stored geometry and starts a fresh cycle. + AppLauncher.bringToForeground(device) + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Let the resumed cycle dwell past the threshold so its initial event fires. + Thread.sleep(3000) + TestHelpers.scrollToElement(device, "event-count-$VISIBLE_ENTRY_ID", "main-scroll-view") + + // Backgrounding ended the pre-background cycle with a final event and + // foregrounding started a fresh one, so the count must advance by 2. + TestHelpers.waitForComponentEventCount( + device, VISIBLE_ENTRY_ID, countBeforeBackground + 2, timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + + val postForegroundViewId = TestHelpers.getViewId(device, VISIBLE_ENTRY_ID) + Assert.assertNotEquals( + "ViewId should change after the background/foreground cycle", + firstCycleViewId, postForegroundViewId, + ) + } + + @Test + fun testDurationResetOnNewCycle() { + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Cycle 1: leave entry 0 untouched well past the dwell threshold so it + // accumulates more than 4000ms of view time. + Thread.sleep(6000) + + // Reading the stats scrolls entry 0 off, ending cycle 1 with a final + // event whose duration is the full ~6s the entry was continuously visible. + TestHelpers.waitForComponentEventCount(device, VISIBLE_ENTRY_ID, 2, timeout = TestHelpers.EXTENDED_TIMEOUT) + val firstCycleDuration = TestHelpers.getViewDuration(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("First cycle duration should not be null", firstCycleDuration) + Assert.assertTrue( + "First cycle duration should exceed 4000ms, got: $firstCycleDuration", + firstCycleDuration!! > 4000, + ) + + val countAfterCycle1 = TestHelpers.parseComponentCount( + TestHelpers.getElementTextById(device, "event-count-$VISIBLE_ENTRY_ID"), + ) + + // Start a fresh cycle: scroll entry 0 fully out — which reliably ends + // cycle 1 — then bring it back so a brand-new cycle starts. A full + // scroll-out crosses the visibility threshold dependably; a tiny jiggle + // does not. Then dwell just past the threshold so the new cycle emits. + device.swipeUpMultiple(2) + Thread.sleep(500) + device.swipeDownMultiple(2) + Thread.sleep(2400) + TestHelpers.waitForComponentEventCount( + device, VISIBLE_ENTRY_ID, countAfterCycle1 + 1, timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + + val secondCycleDuration = TestHelpers.getViewDuration(device, VISIBLE_ENTRY_ID) + Assert.assertNotNull("Second cycle duration should not be null", secondCycleDuration) + Assert.assertTrue( + "Second cycle duration should be >= 2000ms, got: ${secondCycleDuration}ms", + secondCycleDuration!! >= 2000, + ) + Assert.assertTrue( + "New cycle duration should reset — expected < 4000ms but got ${secondCycleDuration}ms", + secondCycleDuration < 4000, + ) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt new file mode 100644 index 00000000..cdad0014 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/FlagViewTrackingTests.kt @@ -0,0 +1,37 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Flag view tracking. Mirrors the iOS `FlagViewTrackingTests` XCUITest suite: + * subscribing to the `boolean` flag on app launch must emit a flag-view + * `component` event counted under `event-count-boolean`. + */ +@RunWith(AndroidJUnit4::class) +class FlagViewTrackingTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + @Test + fun testEmitsFlagViewEventsForSubscribedBooleanFlag() { + // 1. Wait until the "Analytics Events" text is present. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // 2. Wait until flag `boolean` has at least 1 view event. waitForComponentEventCount + // scrolls the analytics stats into view itself. + TestHelpers.waitForComponentEventCount(device, "boolean", 1, timeout = TestHelpers.ELEMENT_TIMEOUT) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt new file mode 100644 index 00000000..9cb87f00 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/IdentifiedVariantsTests.kt @@ -0,0 +1,176 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IdentifiedVariantsTests { + private lateinit var device: UiDevice + + companion object { + @JvmStatic + @BeforeClass + fun setUpClass() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // Step 1: launch and clear any leftover profile state. + AppLauncher.launchApp(device) + clearProfileState(device) + + // Step 2: wait for identify-button, then tap it. + TestHelpers.waitForElement(device, By.res("identify-button"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 3: wait for reset-button, confirming identify succeeded. + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + + // Step 4: terminate the app. + AppLauncher.forceStop(device) + + // Step 5: relaunch as a new instance so identified state is rehydrated + // from persistent storage. + AppLauncher.launchApp(device) + + // Step 6: wait for reset-button in the relaunched app. This proves + // (a) the relaunch finished loading and (b) the identified profile + // survived the cold start — the precondition every test in this suite + // needs. + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + } + } + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + } + + // MARK: - common variants + + @Test + fun testShouldDisplayMergeTagContentWithResolvedValue() { + val expected = "This is a merge tag content entry that displays the visitor's continent \"EU\" embedded within the text. [Entry: 1MwiFl4z7gkwqGYdvCmr8c]" + // The Android app resolves merge tags asynchronously, so wait for the + // resolved description rather than asserting on it immediately. + TestHelpers.waitForElement(device, By.desc(expected), TestHelpers.ELEMENT_TIMEOUT) + } + + @Test + fun testShouldDisplayVariantForVisitorsFromEurope() { + TestHelpers.waitForElement(device, By.res("entry-text-4ib0hsHWoSOnCVdDkizE8d"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for visitors from Europe. [Entry: 4ib0hsHWoSOnCVdDkizE8d]" + Assert.assertNotNull( + "Expected Europe continent variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantForDesktopBrowserVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-xFwgG3oNaOcjzWiGe4vXo"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for visitors using a desktop browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + Assert.assertNotNull( + "Expected desktop browser variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + // MARK: - identified user variants + + @Test + fun testShouldDisplayVariantForReturnVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-2Z2WLOx07InSewC3LUB3eX"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for return visitors. [Entry: 2Z2WLOx07InSewC3LUB3eX]" + Assert.assertNotNull( + "Expected return visitor variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantBForABCExperiment() { + TestHelpers.waitForElement(device, By.res("entry-text-5XHssysWUDECHzKLzoIsg1"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for an A/B/C experiment: B [Entry: 5XHssysWUDECHzKLzoIsg1]" + Assert.assertNotNull( + "Expected A/B/C experiment variant B label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantForVisitorsWithCustomEvent() { + TestHelpers.waitForElement(device, By.res("entry-text-6zqoWXyiSrf0ja7I2WGtYj"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + Assert.assertNotNull( + "Expected custom event variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayVariantForIdentifiedUsers() { + scrollTo("entry-text-7pa5bOx8Z9NmNcr7mISvD") + TestHelpers.waitForElement(device, By.res("entry-text-7pa5bOx8Z9NmNcr7mISvD"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + Assert.assertNotNull( + "Expected identified users variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + // MARK: - nested optimization variants + + @Test + fun testShouldDisplayLevel0NestedVariantForReturnVisitors() { + scrollTo("entry-text-2KIWllNZJT205BwOSkMINg") + TestHelpers.waitForElement(device, By.res("entry-text-2KIWllNZJT205BwOSkMINg"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a level 0 nested variant entry. [Entry: 2KIWllNZJT205BwOSkMINg]" + Assert.assertNotNull( + "Expected level 0 nested variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayLevel1NestedVariantForReturnVisitors() { + scrollTo("entry-text-5a8ONfBdanJtlJ39WWnH1w") + TestHelpers.waitForElement(device, By.res("entry-text-5a8ONfBdanJtlJ39WWnH1w"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a level 1 nested variant entry. [Entry: 5a8ONfBdanJtlJ39WWnH1w]" + Assert.assertNotNull( + "Expected level 1 nested variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + @Test + fun testShouldDisplayLevel2NestedVariantForReturnVisitors() { + scrollTo("entry-text-4hDiXxYEFrXHXcQgmdL9Uv") + TestHelpers.waitForElement(device, By.res("entry-text-4hDiXxYEFrXHXcQgmdL9Uv"), TestHelpers.ELEMENT_TIMEOUT) + val expected = "This is a level 2 nested variant entry. [Entry: 4hDiXxYEFrXHXcQgmdL9Uv]" + Assert.assertNotNull( + "Expected level 2 nested variant label to be visible", + device.findObject(By.desc(expected)), + ) + } + + private fun scrollTo(resourceId: String) { + try { + UiScrollable(UiSelector().resourceId("main-scroll-view")) + .apply { setMaxSearchSwipes(10) } + .scrollIntoView(UiSelector().resourceId(resourceId)) + } catch (_: Exception) { + // Element may already be visible or scrolling not possible + } + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt new file mode 100644 index 00000000..ec7399d0 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/LiveUpdatesTests.kt @@ -0,0 +1,269 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * `*-entry-id` Text nodes render "Entry: " where sys.id is alphanumeric. + * Matching this pattern proves the SDK resolved a real entry rather than an empty/default state. + */ +private val ENTRY_ID_TEXT_PATTERN = Regex("""^Entry: [a-zA-Z0-9]+$""") + +@RunWith(AndroidJUnit4::class) +class LiveUpdatesTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + + TestHelpers.waitAndTap(device, By.res("live-updates-test-button")) + // Android app exposes OptimizedEntry via contentDescription ("*-personalization") + TestHelpers.waitForElement(device, By.desc("default-personalization"), TestHelpers.EXTENDED_TIMEOUT) + } + + @After + fun tearDown() { + val closeButton = device.findObject(By.res("close-live-updates-test-button")) + if (closeButton != null) TestHelpers.tapElement(device, closeButton) + + device.wait( + androidx.test.uiautomator.Until.hasObject(By.res("live-updates-test-button")), + TestHelpers.ELEMENT_TIMEOUT, + ) + } + + // ------------------------------------------------------------------------- + // Default behavior (locked on first value) + // ------------------------------------------------------------------------- + + @Test + fun testDefaultDoesNotUpdateOnIdentifyGlobalLiveUpdatesFalse() { + TestHelpers.waitForElement(device, By.res("default-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // The live-entry-id section has liveUpdates=true and MUST re-resolve — this is + // the live-reference that proves the SDK is actually swapping variants. + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + + // Default section inherits the global setting (off), so the lock must hold. + TestHelpers.waitForTextEquals(device, "default-entry-id", initialDefaultEntryId) + } + + // ------------------------------------------------------------------------- + // Global liveUpdates enabled + // ------------------------------------------------------------------------- + + @Test + fun testGlobalLiveUpdatesEnablesDefaultComponents() { + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + + TestHelpers.waitForElement(device, By.res("default-entry-id")) + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // Global=ON means the default section (no per-component prop) MUST re-resolve. + TestHelpers.waitForElementText(device, "default-entry-id") { it != initialDefaultEntryId } + } + + @Test + fun testLockedComponentsIgnoreGlobalLiveUpdates() { + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + TestHelpers.waitForElement(device, By.res("default-entry-id")) + + val initialLockedEntryId = TestHelpers.getElementTextById(device, "locked-entry-id") + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // With global=ON the default section (no per-component prop) MUST re-resolve — + // the live-reference that proves the SDK is actually swapping variants. + TestHelpers.waitForElementText(device, "default-entry-id") { it != initialDefaultEntryId } + + // Locked section has liveUpdates=false, so it must stay at its captured id. + TestHelpers.waitForTextEquals(device, "locked-entry-id", initialLockedEntryId) + } + + // ------------------------------------------------------------------------- + // Per-component liveUpdates=true + // ------------------------------------------------------------------------- + + @Test + fun testLiveComponentUpdatesRegardlessOfGlobal() { + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "OFF") + TestHelpers.waitForElement(device, By.res("live-entry-id")) + + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // Per-component liveUpdates=true must override the global=OFF setting. + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + } + + // ------------------------------------------------------------------------- + // Per-component liveUpdates=false + // ------------------------------------------------------------------------- + + @Test + fun testLockedComponentDoesNotUpdateEvenWhenGlobalOn() { + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + + val initialLockedEntryId = TestHelpers.getElementTextById(device, "locked-entry-id") + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // Live section (per-component liveUpdates=true) MUST change — the per-component + // prop is the path under test: it must override the global=ON setting and keep + // the locked section stable. + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + TestHelpers.waitForTextEquals(device, "locked-entry-id", initialLockedEntryId) + } + + // ------------------------------------------------------------------------- + // Preview panel simulation + // ------------------------------------------------------------------------- + + @Test + fun testPreviewPanelEnablesLiveUpdatesForAll() { + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Closed") + TestHelpers.waitAndTap(device, By.res("simulate-preview-panel-button")) + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Open") + + TestHelpers.waitForElement(device, By.res("default-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + + val initialDefaultEntryId = TestHelpers.getElementTextById(device, "default-entry-id") + val initialLiveEntryId = TestHelpers.getElementTextById(device, "live-entry-id") + val initialLockedEntryId = TestHelpers.getElementTextById(device, "locked-entry-id") + + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + + // While the preview panel is open, the SDK forces shouldLiveUpdate=true for ALL + // sections, including the per-component liveUpdates=false one. + TestHelpers.waitForElementText(device, "default-entry-id") { it != initialDefaultEntryId } + TestHelpers.waitForElementText(device, "live-entry-id") { it != initialLiveEntryId } + TestHelpers.waitForElementText(device, "locked-entry-id") { it != initialLockedEntryId } + } + + // ------------------------------------------------------------------------- + // Screen controls + // ------------------------------------------------------------------------- + + @Test + fun testToggleGlobalLiveUpdates() { + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "OFF") + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "ON") + TestHelpers.waitAndTap(device, By.res("toggle-global-live-updates-button")) + TestHelpers.waitForTextEquals(device, "global-live-updates-status", "OFF") + } + + @Test + fun testTogglePreviewPanel() { + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Closed") + TestHelpers.waitAndTap(device, By.res("simulate-preview-panel-button")) + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Open") + TestHelpers.waitAndTap(device, By.res("simulate-preview-panel-button")) + TestHelpers.waitForTextEquals(device, "preview-panel-status", "Closed") + } + + @Test + fun testIdentifyAndReset() { + TestHelpers.waitForTextEquals(device, "identified-status", "No") + TestHelpers.waitAndTap(device, By.res("live-updates-identify-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "Yes") + TestHelpers.waitAndTap(device, By.res("live-updates-reset-button")) + TestHelpers.waitForTextEquals(device, "identified-status", "No") + } + + // ------------------------------------------------------------------------- + // Three Optimization sections display + // ------------------------------------------------------------------------- + + @Test + fun testDisplaysAllThreeOptimizationEntrySections() { + // Android app exposes OptimizedEntry via contentDescription ("*-personalization"). + // iOS uses "*-optimization". Both refer to the same SDK-rendered containers. + TestHelpers.waitForElement(device, By.desc("default-personalization"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.waitForElement(device, By.desc("live-personalization"), TestHelpers.ELEMENT_TIMEOUT) + TestHelpers.waitForElement(device, By.desc("locked-personalization"), TestHelpers.ELEMENT_TIMEOUT) + + TestHelpers.waitForElement(device, By.res("default-entry-id")) + TestHelpers.waitForElement(device, By.res("live-entry-id")) + TestHelpers.waitForElement(device, By.res("locked-entry-id")) + + val defaultEntryIdText = TestHelpers.getElementTextById(device, "default-entry-id") + val liveEntryIdText = TestHelpers.getElementTextById(device, "live-entry-id") + val lockedEntryIdText = TestHelpers.getElementTextById(device, "locked-entry-id") + + Assert.assertTrue( + "default-entry-id \"$defaultEntryIdText\" did not match ENTRY_ID_TEXT_PATTERN", + ENTRY_ID_TEXT_PATTERN.matches(defaultEntryIdText), + ) + Assert.assertTrue( + "live-entry-id \"$liveEntryIdText\" did not match ENTRY_ID_TEXT_PATTERN", + ENTRY_ID_TEXT_PATTERN.matches(liveEntryIdText), + ) + Assert.assertTrue( + "locked-entry-id \"$lockedEntryIdText\" did not match ENTRY_ID_TEXT_PATTERN", + ENTRY_ID_TEXT_PATTERN.matches(lockedEntryIdText), + ) + } + + @Test + fun testDisplaysEntryContentInAllSections() { + TestHelpers.waitForElement(device, By.res("default-container")) + TestHelpers.waitForElement(device, By.res("live-container")) + TestHelpers.waitForElement(device, By.res("locked-container")) + + val defaultText = TestHelpers.getElementTextById(device, "default-text") + val liveText = TestHelpers.getElementTextById(device, "live-text") + val lockedText = TestHelpers.getElementTextById(device, "locked-text") + + Assert.assertTrue("default-text should be non-empty", defaultText.isNotEmpty()) + Assert.assertTrue("live-text should be non-empty", liveText.isNotEmpty()) + Assert.assertTrue("locked-text should be non-empty", lockedText.isNotEmpty()) + Assert.assertNotEquals("default-text should not be 'No content'", "No content", defaultText) + Assert.assertNotEquals("live-text should not be 'No content'", "No content", liveText) + Assert.assertNotEquals("locked-text should not be 'No content'", "No content", lockedText) + // Before any identify/toggle/preview-panel action all three sections wrap the + // same Contentful entry and MUST resolve to the same variant text. + Assert.assertEquals("default-text and live-text should match", defaultText, liveText) + Assert.assertEquals("default-text and locked-text should match", defaultText, lockedText) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt new file mode 100644 index 00000000..ec2a62df --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/OfflineBehaviorTests.kt @@ -0,0 +1,270 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OfflineBehaviorTests { + private lateinit var device: UiDevice + + // Time allowed after reconnecting for the SDK online signal to flip and the + // resulting Experience API queue flush to land before the app is terminated. + private val QUEUE_FLUSH_GRACE_MS = 10_000L + + // Time allowed after an online identify for the Experience upsert round-trip + // to complete before the app is terminated. + private val IDENTIFY_SETTLE_MS = 3_000L + + // Timeout for the post-relaunch variant assertions, generous enough for a + // cold start to boot, fetch entries, and run resolution. + private val POST_RELAUNCH_TIMEOUT = 30_000L + + // Nested level-0 entry id that only appears once the SDK resolves the + // identified profile. + private val NESTED_VARIANT_TEST_ID = "entry-text-2KIWllNZJT205BwOSkMINg" + + // Nested level-0 entry id that only appears for an anonymous profile. + private val NESTED_BASELINE_TEST_ID = "entry-text-1JAU028vQ7v6nB2swl3NBo" + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // beforeEach: restore network first so the device starts each test online, + // then relaunch from clean storage so the next test starts from a true + // anonymous profile (requireFreshAppInstance = true). + enableNetwork() + AppLauncher.launchApp(device) + clearProfileState(device, requireFreshAppInstance = true) + } + + @After + fun tearDown() { + // afterEach: always restore network so subsequent tests are not affected. + enableNetwork() + } + + // --------------------------------------------------------------------------- + // Network helpers — implement disableNetwork / enableNetwork via airplane mode + // shell commands, mirroring the pseudocode network-helpers contract. + // --------------------------------------------------------------------------- + + private fun disableNetwork() { + if (isAirplaneModeEnabled()) return + device.executeShellCommand("cmd connectivity airplane-mode enable") + waitForAirplaneModeState(expectedEnabled = true) + } + + private fun enableNetwork() { + if (!isAirplaneModeEnabled()) return + device.executeShellCommand("cmd connectivity airplane-mode disable") + waitForAirplaneModeState(expectedEnabled = false) + } + + private fun isAirplaneModeEnabled(): Boolean { + val result = device.executeShellCommand( + "settings get global airplane_mode_on", + ).trim() + return result == "1" + } + + private fun waitForAirplaneModeState( + expectedEnabled: Boolean, + timeoutMs: Long = 3_000L, + pollMs: Long = 200L, + ): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (isAirplaneModeEnabled() == expectedEnabled) return true + Thread.sleep(pollMs) + } + // Fallback backoff if the transition could not be confirmed. + Thread.sleep(if (expectedEnabled) 300L else 500L) + return false + } + + // --------------------------------------------------------------------------- + // Local helpers + // --------------------------------------------------------------------------- + + private fun getEventsCount(): Int = + TestHelpers.parseEventsCount(TestHelpers.getElementTextById(device, "events-count")) + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + @Test + fun testContinuesToTrackEventsWhileOffline() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + // Step 2: wait until at least 1 event has been tracked. + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 3: go offline. + disableNetwork() + + // Step 4: capture current events count. + val eventsBeforeIdentify = getEventsCount() + + // Step 5: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 6: wait until events-count has advanced by at least 1. + TestHelpers.waitForElementText(device, "events-count") { text -> + TestHelpers.parseEventsCount(text) >= eventsBeforeIdentify + 1 + } + + // Step 7: restore network so the Experience queue flushes. + enableNetwork() + + // Step 8: wait for the queue flush round-trip to land. + Thread.sleep(QUEUE_FLUSH_GRACE_MS) + + // Step 9: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 10: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 11: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after identified flush", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + } + + @Test + fun testRecoverGracefullyWhenNetworkRestored() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: go offline. + disableNetwork() + + // Step 3: let the offline state stabilize. + Thread.sleep(1_000L) + + // Step 4: restore network. + enableNetwork() + + // Step 5: let the connectivity transition settle before identifying online. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 6: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 7: wait until reset-button is visible (SDK completed the identify pipeline). + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 8: wait for the Experience upsert round-trip to land. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 9: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 10: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 11: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after recovery identify", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + } + + @Test + fun testHandleRapidNetworkStateChanges() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Steps 2–8: rapid offline/online toggles ending online. + disableNetwork() + Thread.sleep(500L) + enableNetwork() + Thread.sleep(500L) + disableNetwork() + Thread.sleep(500L) + enableNetwork() + + // Step 9: let the connectivity churn settle before identifying. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 10: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 11: wait until reset-button is visible (SDK completed the identify pipeline). + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 12: wait for the Experience upsert round-trip to land. + Thread.sleep(IDENTIFY_SETTLE_MS) + + // Step 13: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 14: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 15: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after rapid toggles", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + } + + @Test + fun testQueueEventsOfflineAndFlushWhenOnline() { + // Step 1: wait until "Analytics Events" label is visible. + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + // Step 2: wait until at least 1 event has been tracked. + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 3: go offline. + disableNetwork() + + // Step 4: capture current events count. + val eventsBeforeIdentify = getEventsCount() + + // Step 5: tap identify. + TestHelpers.waitAndTap(device, By.res("identify-button")) + + // Step 6: wait until events-count has advanced by at least 1 (event tracked offline). + TestHelpers.waitForElementText(device, "events-count") { text -> + TestHelpers.parseEventsCount(text) >= eventsBeforeIdentify + 1 + } + + // Step 7: restore network so the offline Experience queue flushes. + enableNetwork() + + // Step 8: wait for the flush round-trip to reach the server. + Thread.sleep(QUEUE_FLUSH_GRACE_MS) + + // Step 9: terminate and relaunch as a new instance. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + + // Step 10: wait until the identified nested variant entry exists. + TestHelpers.waitForElement(device, By.res(NESTED_VARIANT_TEST_ID), POST_RELAUNCH_TIMEOUT) + + // Step 11: assert the anonymous baseline entry does not exist. + Assert.assertNull( + "Baseline nested entry $NESTED_BASELINE_TEST_ID should not exist after queued flush", + device.findObject(By.res(NESTED_BASELINE_TEST_ID)), + ) + + // Step 12: wait until reset-button is visible (identified profile preserved across cold start). + TestHelpers.waitForElement(device, By.res("reset-button"), POST_RELAUNCH_TIMEOUT) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt new file mode 100644 index 00000000..3c6050d1 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelOverridesTests.kt @@ -0,0 +1,376 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewPanelOverridesTests { + private lateinit var device: UiDevice + + companion object { + // Audience + experience these scenarios drive. Both IDs match the + // iOS suite so cross-platform fixtures and failures stay aligned. + const val AUDIENCE_ID = "4yIqY7AWtzeehCZxtQSDB" + const val EXPERIENCE_ID = "7DyidZaPB7Jr1gWKjoogg0" + // The resolved entries the experience can render. Identified-visitor + // mock data renders VARIANT_ENTRY_ID by default; overriding to + // variant-0 / deactivating the audience renders BASELINE_ENTRY_ID. + const val VARIANT_ENTRY_ID = "5a8ONfBdanJtlJ39WWnH1w" + const val BASELINE_ENTRY_ID = "5i4SdJXw9oDEY0vgO7CwF4" + // Scenario 1: the Mobile Browser audience the identified user does NOT + // qualify for. Activating it surfaces the variant content for the + // xFwgG3oNaOcjzWiGe4vXo entry. + const val UNQUALIFIED_AUDIENCE_ID = "3MRuZPQ5EdwDqzUDRgOo7c" + const val MOBILE_VARIANT_LABEL = + "This is a variant content entry for visitors using a mobile browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + } + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // Relaunch the app as a new instance with fresh storage so prior modal + // and override state cannot leak in. + AppLauncher.relaunchClean(device) + clearProfileState(device) + identifyAndRelaunch() + } + + // MARK: - Local helpers + + /** + * Identifies the visitor, then relaunches so the identified-visitor mock + * payload is re-fetched on a fresh app start. + * + * Mirrors the pseudocode `identifyAndRelaunch` helper and the iOS + * `identifyAndRelaunch()` private function exactly. + */ + private fun identifyAndRelaunch() { + TestHelpers.waitAndTap(device, By.res("identify-button")) + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + // Terminate + relaunch so the identified-visitor mock payload is + // re-fetched on a fresh start. The `reset` extra is NOT set here so + // the identified profile just persisted is preserved. + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + // Identified-visitor profile should render the variant entry by default. + // Asserting this here turns a previously-silent setup misalignment into + // a clear precondition failure if the mock data ever drifts. + assertEntryVisible(VARIANT_ENTRY_ID, "entry-text-$VARIANT_ENTRY_ID missing — identified-visitor variant never rendered") + } + + private fun openPanel() { + TestHelpers.waitAndTap(device, By.desc("preview-panel-fab")) + TestHelpers.waitForElement(device, By.text("Preview Panel")) + Thread.sleep(2000) + } + + private fun closePanel() { + device.pressBack() + Thread.sleep(500) + } + + private fun scrollPanelToElement(desc: String) { + val panel = device.findObject(By.desc("preview-panel-list")) + val bounds = panel?.visibleBounds + + val centerX = bounds?.centerX() ?: (device.displayWidth / 2) + val startY = bounds?.let { it.top + (it.height() * 3 / 4) } ?: (device.displayHeight * 3 / 4) + val endY = bounds?.let { it.top + (it.height() / 4) } ?: (device.displayHeight / 4) + + for (i in 0 until 8) { + // Wait for any in-flight scroll/animation to settle. Compose batches + // accessibility-tree updates during a fling and the tree we query + // here would otherwise be stale. + device.waitForIdle(1500L) + val el = device.wait(Until.hasObject(By.descContains(desc)), 500L)?.let { + device.findObject(By.descContains(desc)) + } + if (el != null) { + val elBounds = el.visibleBounds + if (elBounds.height() >= 5 && bounds != null && + elBounds.top >= bounds.top && elBounds.bottom <= bounds.bottom + ) { + return + } + } + device.swipe(centerX, startY, centerX, endY, 25) + } + } + + private fun waitForDefinitionsLoaded() { + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.text("Loading definitions...")) == null) return + Thread.sleep(150) + } + } + + private fun tapVariantPicker(variantDesc: String): Boolean { + val picker = device.findObject(By.descContains(variantDesc)) ?: return false + val panel = device.findObject(By.desc("preview-panel-list")) ?: return false + val panelBounds = panel.visibleBounds + val pickerBounds = picker.visibleBounds + + if (pickerBounds.height() < 5 || + pickerBounds.top < panelBounds.top || + pickerBounds.bottom > panelBounds.bottom + ) { + return false + } + + device.click(pickerBounds.centerX(), pickerBounds.centerY()) + Thread.sleep(500) + return true + } + + private fun expandTargetAudienceAndTapVariant() { + waitForDefinitionsLoaded() + + val expandDesc = "audience-expand-$AUDIENCE_ID" + val variantDesc = "variant-picker-$EXPERIENCE_ID-0" + + scrollPanelToElement(expandDesc) + // audience-expand is a binary toggle, so single-click only. The default + // tapElement double-click (accessibility-click + coordinate click 100ms + // later) would expand then immediately re-collapse it. audience-toggle + // survives the double-click because it is a set-state radio. + val expandEl = TestHelpers.waitForElement( + device, By.descContains(expandDesc), TestHelpers.EXTENDED_TIMEOUT, + ) + TestHelpers.tapElement(device, expandEl, singleClick = true) + Thread.sleep(1000) + + scrollPanelToElement(variantDesc) + + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (tapVariantPicker(variantDesc)) return + scrollPanelToElement(variantDesc) + Thread.sleep(500) + } + + Assert.fail("$variantDesc not found after expanding audience") + } + + /** + * Mirrors iOS `assertEntryVisible`: assert that the entry with the given + * id has its `entry-text-` testTag rendered in main-scroll-view. + * + * This is the strong form of the previous `assertEntryContentContains` + * fuzzy-substring check — it fails when the *wrong* entry was resolved + * rather than just when some accidental substring is missing. + */ + private fun assertEntryVisible(entryId: String, message: String) { + val tag = "entry-text-$entryId" + // Bring the entry into view if it's offscreen. Swallow the scroll + // exception so the assertion below produces the clearer failure. + try { + UiScrollable(UiSelector().resourceId("main-scroll-view")) + .apply { setMaxSearchSwipes(10) } + .scrollIntoView(UiSelector().resourceId(tag)) + } catch (_: Exception) { + } + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.res(tag)) != null) return + Thread.sleep(150) + } + val visibleEntryTags = device.findObjects(By.res(java.util.regex.Pattern.compile("entry-text-.*"))) + .mapNotNull { it.resourceName } + Assert.fail("$message (entry-text-$entryId not found; visible entry-text-* tags: $visibleEntryTags)") + } + + // MARK: - Scenarios + + /** + * Scenario 1: turning on an audience that the identified visitor does not + * qualify for activates an experience whose variant content then renders + * on screen. + */ + @Test + fun testScenario1ActivatingUnqualifiedAudienceRendersItsVariant() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$UNQUALIFIED_AUDIENCE_ID-on") + // singleClick: the toggle is a set-state radio; singleClick avoids the + // coordinate-click fallback landing on a different row after re-sort. + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$UNQUALIFIED_AUDIENCE_ID-on"), singleClick = true) + closePanel() + + // The variant content for the Mobile Browser experience renders using + // the *original* baseline entry id (xFwgG3oNaOcjzWiGe4vXo) in the label. + val deadline = System.currentTimeMillis() + TestHelpers.EXTENDED_TIMEOUT + while (System.currentTimeMillis() < deadline) { + if (device.findObject(By.desc(MOBILE_VARIANT_LABEL)) != null) return + Thread.sleep(150) + } + Assert.fail( + "Expected mobile variant content after activating Mobile Browser audience " + + "(label: $MOBILE_VARIANT_LABEL)", + ) + } + + /** + * Scenario 2: turning off an audience the identified visitor does qualify + * for forces the experience to fall back to its baseline entry. + */ + @Test + fun testScenario2DeactivatingQualifiedAudienceRendersBaseline() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + // singleClick: deactivating the audience demotes it in `sortAudiences`, + // so the row re-sorts between the accessibility click and the default + // coordinate-click fallback, landing the second tap on whichever audience + // now occupies the original screen position. + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline entry after deactivating audience") + } + + /** + * Scenario 3: after deactivating a qualified audience, tapping the + * audience's default toggle removes the override and restores the original + * variant resolution. + */ + @Test + fun testScenario3ResettingAudienceOverrideRestoresVariant() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-default"), singleClick = true) + closePanel() + + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after resetting audience override") + } + + /** + * Scenario 4: explicitly picking the index-0 (baseline) variant for an + * experience forces that experience to render its baseline entry, even + * when the visitor qualifies for a non-baseline variant. + */ + @Test + fun testScenario4SettingVariantOverrideToZeroRendersBaseline() { + openPanel() + expandTargetAudienceAndTapVariant() + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline after variant-0 override") + } + + /** + * Scenario 5: after forcing a variant override, tapping the per-experience + * reset control removes only that override and restores the original + * variant resolution. On Android the `reset-variant-` button invokes + * the reset directly with no confirmation dialog (same as iOS). + */ + @Test + fun testScenario5ResettingSingleVariantOverrideRestoresVariant() { + openPanel() + expandTargetAudienceAndTapVariant() + + val resetDesc = "reset-variant-$EXPERIENCE_ID" + scrollPanelToElement(resetDesc) + TestHelpers.waitAndTap(device, By.desc(resetDesc)) + closePanel() + + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after resetting single variant override") + } + + /** + * Scenario 6: after forcing a variant override, tapping the panel's + * reset-all control and confirming via the native AlertDialog clears every + * override and restores the original variant resolution. On Android the + * confirmation is a Material3 AlertDialog — confirmed by tapping the + * button with text "Reset" (no inline `reset-all-confirm` view as in RN). + */ + @Test + fun testScenario6ResetAllRestoresVariantContent() { + openPanel() + expandTargetAudienceAndTapVariant() + + scrollPanelToElement("reset-all-overrides") + TestHelpers.waitAndTap(device, By.desc("reset-all-overrides")) + // Confirm the native AlertDialog. + TestHelpers.waitAndTap(device, By.text("Reset")) + + closePanel() + + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after reset-all") + } + + /** + * Scenario 7: deactivating an audience and then triggering the in-panel + * refresh (which re-hits the experience API) keeps the audience override + * in place so the experience still resolves to its baseline. + */ + @Test + fun testScenario7OverrideSurvivesAPIRefresh() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + + scrollPanelToElement("preview-refresh-button") + TestHelpers.waitAndTap(device, By.desc("preview-refresh-button")) + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline still rendering after API refresh") + } + + /** + * Scenario 8: a cold relaunch with cleared storage discards all overrides + * — the variant renders again and the overrides section reports that none + * remain. + */ + @Test + fun testScenario8DestroyRemountClearsOverrides() { + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("audience-toggle-$AUDIENCE_ID-off") + TestHelpers.waitAndTap(device, By.desc("audience-toggle-$AUDIENCE_ID-off"), singleClick = true) + closePanel() + + assertEntryVisible(BASELINE_ENTRY_ID, "Expected baseline after deactivating audience (pre-relaunch)") + + // Cold relaunch with fresh storage, then re-identify and rehydrate. + AppLauncher.relaunchClean(device) + identifyAndRelaunch() + + // Override must be gone — variant renders again. + assertEntryVisible(VARIANT_ENTRY_ID, "Expected variant entry after destroy/remount cleared overrides") + + // The Overrides section should show its empty state. The empty-state + // text sits below the fold so the panel content must be scrolled to + // reveal it. On Android, reset-all-overrides lives in the fixed footer + // (outside preview-panel-list), so scrollPanelToElement exhausts its + // swipe budget rather than returning early — this is expected. After + // the swipes the Overrides section is in or near the viewport. Use a + // timed wait so the accessibility tree can settle after the final swipe + // before asserting, mirroring the defensive pattern used by + // assertEntryVisible throughout this suite. + openPanel() + waitForDefinitionsLoaded() + scrollPanelToElement("reset-all-overrides") + val found = device.wait(Until.hasObject(By.text("No active overrides")), TestHelpers.EXTENDED_TIMEOUT) + Assert.assertTrue( + "Expected 'No active overrides' empty-state text in Overrides section", + found, + ) + closePanel() + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt new file mode 100644 index 00000000..faadc2e7 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/PreviewPanelTests.kt @@ -0,0 +1,153 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewPanelTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + TestHelpers.waitForElement(device, By.res("identify-button")) + } + + private fun openPreviewPanel() { + TestHelpers.waitAndTap(device, By.desc("preview-panel-fab")) + TestHelpers.waitForElement(device, By.text("Preview Panel")) + Thread.sleep(2000) + } + + private fun scrollToPreviewElement(testId: String, maxSwipes: Int = 20) { + for (i in 0 until maxSwipes) { + if (TestHelpers.findElement(device, testId) != null) return + val panelBounds = device.findObject(By.desc("preview-panel-list"))?.visibleBounds + val centerX = panelBounds?.centerX() ?: (device.displayWidth / 2) + val startY = panelBounds?.let { it.top + (it.height() * 3 / 4) } ?: (device.displayHeight * 3 / 4) + val endY = panelBounds?.let { it.top + (it.height() / 4) } ?: (device.displayHeight / 4) + device.swipe(centerX, startY, centerX, endY, 10) + Thread.sleep(300) + } + } + + // FAB Visibility + + @Test + fun testFABIsVisible() { + val fab = device.findObject(By.desc("preview-panel-fab")) + Assert.assertNotNull("Preview panel FAB should be visible on main screen", fab) + } + + // Profile Data Loading + + @Test + fun testShowsProfileData() { + openPreviewPanel() + Thread.sleep(2000) + val noData = device.findObject(By.desc("no-profile-data")) + Assert.assertNull("Should not show 'No profile data' after initialization", noData) + } + + @Test + fun testShowsAllExpectedProfileKeys() { + openPreviewPanel() + + val expectedKeys = listOf("audiences", "id", "location", "random", "session", "stableId", "traits") + for (key in expectedKeys) { + scrollToPreviewElement("profile-item-$key") + val item = TestHelpers.findElement(device, "profile-item-$key") + Assert.assertNotNull("Expected profile key '$key' not found in preview panel", item) + } + } + + @Test + fun testShowsLocationData() { + openPreviewPanel() + scrollToPreviewElement("profile-item-location") + + val locationItem = TestHelpers.findElement(device, "profile-item-location") + Assert.assertNotNull("Profile location item not found", locationItem) + + val text = TestHelpers.extractText(locationItem!!) + Assert.assertTrue( + "Expected location to contain Berlin or DE, got: $text", + text.contains("Berlin") || text.contains("DE"), + ) + } + + // Debug Section + + @Test + fun testShowsConsentAccepted() { + openPreviewPanel() + scrollToPreviewElement("debug-consent") + + val consent = TestHelpers.findElement(device, "debug-consent") + Assert.assertNotNull("Debug consent element not found", consent) + + val text = TestHelpers.extractText(consent!!) + Assert.assertTrue( + "Expected consent to contain 'Accepted', got: $text", + text.contains("Accepted"), + ) + } + + @Test + fun testShowsCanPersonalize() { + openPreviewPanel() + scrollToPreviewElement("debug-can-personalize") + + val canPersonalize = TestHelpers.findElement(device, "debug-can-personalize") + Assert.assertNotNull("Debug canPersonalize element not found", canPersonalize) + + val text = TestHelpers.extractText(canPersonalize!!) + Assert.assertTrue( + "Expected canPersonalize to contain 'Yes', got: $text", + text.contains("Yes"), + ) + } + + // Refresh + + @Test + fun testRefreshButtonWorks() { + openPreviewPanel() + scrollToPreviewElement("preview-refresh-button") + + TestHelpers.waitAndTap(device, By.desc("preview-refresh-button")) + + Thread.sleep(2000) + val noData = device.findObject(By.desc("no-profile-data")) + Assert.assertNull("Profile data should persist after refresh", noData) + } + + // Profile After Identify + + @Test + fun testProfileUpdatesAfterIdentify() { + TestHelpers.waitAndTap(device, By.res("identify-button")) + TestHelpers.waitForElement(device, By.res("reset-button"), TestHelpers.EXTENDED_TIMEOUT) + + openPreviewPanel() + + Thread.sleep(2000) + val noData = device.findObject(By.desc("no-profile-data")) + Assert.assertNull("Should show profile data after identify", noData) + + scrollToPreviewElement("profile-item-id") + val idItem = TestHelpers.findElement(device, "profile-item-id") + Assert.assertNotNull("Profile id should be present after identify", idItem) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt new file mode 100644 index 00000000..0dc30366 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/ScreenTrackingTests.kt @@ -0,0 +1,88 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScreenTrackingTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + private fun navigateToTestScreen() { + TestHelpers.waitAndTap(device, By.res("navigation-test-button"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("go-to-view-one-button"), TestHelpers.EXTENDED_TIMEOUT) + } + + @Test + fun testTrackSingleViewVisit() { + navigateToTestScreen() + TestHelpers.waitAndTap(device, By.res("go-to-view-one-button")) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("screen-event-log"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForTextEquals( + device, "screen-event-log", "NavigationHome,NavigationViewOne", + timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + } + + @Test + fun testTrackMultipleViewVisitsInOrder() { + navigateToTestScreen() + TestHelpers.waitAndTap(device, By.res("go-to-view-one-button")) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitAndTap(device, By.res("go-to-view-two-button"), TestHelpers.EXTENDED_TIMEOUT) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-two"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("screen-event-log"), TestHelpers.EXTENDED_TIMEOUT) + + val logText = TestHelpers.waitForElementText( + device, "screen-event-log", timeout = TestHelpers.EXTENDED_TIMEOUT, + ) { text -> + text.contains("NavigationViewTwo") + } + + val viewOneIndex = logText.indexOf("NavigationViewOne") + val viewTwoIndex = logText.indexOf("NavigationViewTwo") + + Assert.assertTrue("ViewOne not found in log", viewOneIndex >= 0) + Assert.assertTrue("ViewTwo not found in log", viewTwoIndex >= 0) + Assert.assertTrue("ViewOne should come before ViewTwo", viewOneIndex < viewTwoIndex) + } + + @Test + fun testTrackRevisitingViewOneAfterViewTwo() { + navigateToTestScreen() + TestHelpers.waitAndTap(device, By.res("go-to-view-one-button")) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitAndTap(device, By.res("go-to-view-two-button"), TestHelpers.EXTENDED_TIMEOUT) + + TestHelpers.waitForElement(device, By.res("navigation-view-test-two"), TestHelpers.EXTENDED_TIMEOUT) + + device.pressBack() + + TestHelpers.waitForElement(device, By.res("navigation-view-test-one"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForElement(device, By.res("screen-event-log"), TestHelpers.EXTENDED_TIMEOUT) + TestHelpers.waitForTextEquals( + device, + "screen-event-log", + "NavigationHome,NavigationViewOne,NavigationViewTwo,NavigationViewOne", + timeout = TestHelpers.EXTENDED_TIMEOUT, + ) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt new file mode 100644 index 00000000..e68ca7e9 --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/TapTrackingTests.kt @@ -0,0 +1,60 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TapTrackingTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + AppLauncher.launchApp(device) + clearProfileState(device) + } + + @Test + fun testEmitsComponentClickWhenTappingContentEntry() { + // Step 1: wait for "Analytics Events" text to be visible + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: tap the content entry + val entry = TestHelpers.waitForElement(device, By.desc("content-entry-1MwiFl4z7gkwqGYdvCmr8c")) + TestHelpers.tapElement(device, entry) + + // Step 3: wait until at least 1 event has been tracked + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 4: scroll to and assert the component_click event element is visible + val testId = "event-component_click-1MwiFl4z7gkwqGYdvCmr8c" + TestHelpers.scrollToElement(device, testId, "main-scroll-view") + TestHelpers.waitForElement(device, By.res(testId), TestHelpers.ELEMENT_TIMEOUT) + } + + @Test + fun testEmitsComponentClickForDifferentEntry() { + // Step 1: wait for "Analytics Events" text to be visible + TestHelpers.waitForElement(device, By.text("Analytics Events"), TestHelpers.ELEMENT_TIMEOUT) + + // Step 2: tap the content entry + val entry = TestHelpers.waitForElement(device, By.desc("content-entry-2Z2WLOx07InSewC3LUB3eX")) + TestHelpers.tapElement(device, entry) + + // Step 3: wait until at least 1 event has been tracked + TestHelpers.waitForEventsCountAtLeast(device, 1) + + // Step 4: scroll to and assert the component_click event element is visible + val testId = "event-component_click-2Z2WLOx07InSewC3LUB3eX" + TestHelpers.scrollToElement(device, testId, "main-scroll-view") + TestHelpers.waitForElement(device, By.res(testId), TestHelpers.ELEMENT_TIMEOUT) + } +} diff --git a/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt new file mode 100644 index 00000000..de39b27f --- /dev/null +++ b/implementations/android-sdk/uitests/src/main/kotlin/com/contentful/optimization/uitests/tests/UnidentifiedVariantsTests.kt @@ -0,0 +1,190 @@ +package com.contentful.optimization.uitests.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import com.contentful.optimization.uitests.support.AppLauncher +import com.contentful.optimization.uitests.support.TestHelpers +import com.contentful.optimization.uitests.support.clearProfileState +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UnidentifiedVariantsTests { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + clearProfileState(device, requireFreshAppInstance = true) + } + + // Drives the unidentified -> identified round-trip the baseline tests rely on. + // The home-screen optimized entries lock on their first resolved value, so a + // mid-test identify does not re-resolve them; only a relaunch makes the SDK + // re-run audience evaluation against the now-identified profile. + private fun identifyAndRelaunch() { + TestHelpers.waitForElement(device, By.res("identify-button")) + TestHelpers.waitAndTap(device, By.res("identify-button")) + TestHelpers.waitForElement(device, By.res("reset-button")) + AppLauncher.forceStop(device) + AppLauncher.launchApp(device) + TestHelpers.waitForElement(device, By.res("main-scroll-view"), TestHelpers.EXTENDED_TIMEOUT) + } + + // MARK: - common variants + + @Test + fun testDisplaysMergeTagContentWithResolvedValue() { + val expectedLabel = "This is a merge tag content entry that displays the visitor's continent \"EU\" embedded within the text. [Entry: 1MwiFl4z7gkwqGYdvCmr8c]" + // The Android app resolves merge tags asynchronously, so wait for the + // resolved description rather than asserting on it immediately. + TestHelpers.waitForElement(device, By.desc(expectedLabel), TestHelpers.ELEMENT_TIMEOUT) + } + + @Test + fun testDisplaysVariantForVisitorsFromEurope() { + TestHelpers.waitForElement(device, By.res("entry-text-4ib0hsHWoSOnCVdDkizE8d")) + val expectedLabel = "This is a variant content entry for visitors from Europe. [Entry: 4ib0hsHWoSOnCVdDkizE8d]" + Assert.assertNotNull( + "Expected Europe variant content", + device.findObject(By.desc(expectedLabel)), + ) + } + + @Test + fun testDisplaysVariantForDesktopBrowserVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-xFwgG3oNaOcjzWiGe4vXo")) + val expectedLabel = "This is a variant content entry for visitors using a desktop browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + Assert.assertNotNull( + "Expected desktop-browser variant content", + device.findObject(By.desc(expectedLabel)), + ) + } + + // MARK: - unidentified user variants + + @Test + fun testDisplaysVariantForNewVisitors() { + TestHelpers.waitForElement(device, By.res("entry-text-2Z2WLOx07InSewC3LUB3eX")) + val expectedLabel = "This is a variant content entry for new visitors. [Entry: 2Z2WLOx07InSewC3LUB3eX]" + Assert.assertNotNull( + "Expected new-visitor variant content", + device.findObject(By.desc(expectedLabel)), + ) + } + + @Test + fun testDisplaysVariantBForABCExperiment() { + TestHelpers.waitForElement(device, By.res("entry-text-5XHssysWUDECHzKLzoIsg1")) + val expectedLabel = "This is a variant content entry for an A/B/C experiment: B [Entry: 5XHssysWUDECHzKLzoIsg1]" + Assert.assertNotNull( + "Expected A/B/C experiment variant B", + device.findObject(By.desc(expectedLabel)), + ) + } + + @Test + fun testDisplaysBaselineForVisitorsWithOrWithoutCustomEvent() { + TestHelpers.waitForElement(device, By.res("entry-text-6zqoWXyiSrf0ja7I2WGtYj")) + val baselineLabel = "This is a baseline content entry for all visitors with or without a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + Assert.assertNotNull( + "Expected baseline custom-event content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + val variantLabel = "This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + TestHelpers.waitForElement(device, By.desc(variantLabel)) + Assert.assertNull( + "Baseline custom-event content should be gone after identify", + device.findObject(By.desc(baselineLabel)), + ) + } + + @Test + fun testDisplaysBaselineForAllIdentifiedOrUnidentifiedUsers() { + TestHelpers.scrollToElement(device, "entry-text-7pa5bOx8Z9NmNcr7mISvD", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-7pa5bOx8Z9NmNcr7mISvD")) + val baselineLabel = "This is a baseline content entry for all identified or unidentified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + Assert.assertNotNull( + "Expected baseline all-users content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + val variantLabel = "This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + TestHelpers.waitForElement(device, By.desc(variantLabel)) + Assert.assertNull( + "Baseline all-users content should be gone after identify", + device.findObject(By.desc(baselineLabel)), + ) + } + + // MARK: - nested optimization baselines + + @Test + fun testDisplaysLevel0NestedBaselineForNewVisitors() { + TestHelpers.scrollToElement(device, "entry-text-1JAU028vQ7v6nB2swl3NBo", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-1JAU028vQ7v6nB2swl3NBo")) + val baselineLabel = "This is a level 0 nested baseline entry. [Entry: 1JAU028vQ7v6nB2swl3NBo]" + Assert.assertNotNull( + "Expected level 0 nested baseline content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + TestHelpers.scrollToElement(device, "entry-text-2KIWllNZJT205BwOSkMINg", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-2KIWllNZJT205BwOSkMINg")) + Assert.assertNull( + "Level 0 nested baseline content should be gone after identify", + device.findObject(By.res("entry-text-1JAU028vQ7v6nB2swl3NBo")), + ) + } + + @Test + fun testDisplaysLevel1NestedBaselineForNewVisitors() { + TestHelpers.scrollToElement(device, "entry-text-5i4SdJXw9oDEY0vgO7CwF4", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-5i4SdJXw9oDEY0vgO7CwF4")) + val baselineLabel = "This is a level 1 nested baseline entry. [Entry: 5i4SdJXw9oDEY0vgO7CwF4]" + Assert.assertNotNull( + "Expected level 1 nested baseline content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + TestHelpers.scrollToElement(device, "entry-text-5a8ONfBdanJtlJ39WWnH1w", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-5a8ONfBdanJtlJ39WWnH1w")) + Assert.assertNull( + "Level 1 nested baseline content should be gone after identify", + device.findObject(By.res("entry-text-5i4SdJXw9oDEY0vgO7CwF4")), + ) + } + + @Test + fun testDisplaysLevel2NestedBaselineForNewVisitors() { + TestHelpers.scrollToElement(device, "entry-text-uaNY4YJ0HFPAX3gKXiRdX", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-uaNY4YJ0HFPAX3gKXiRdX")) + val baselineLabel = "This is a level 2 nested baseline entry. [Entry: uaNY4YJ0HFPAX3gKXiRdX]" + Assert.assertNotNull( + "Expected level 2 nested baseline content", + device.findObject(By.desc(baselineLabel)), + ) + + identifyAndRelaunch() + + TestHelpers.scrollToElement(device, "entry-text-4hDiXxYEFrXHXcQgmdL9Uv", "main-scroll-view") + TestHelpers.waitForElement(device, By.res("entry-text-4hDiXxYEFrXHXcQgmdL9Uv")) + Assert.assertNull( + "Level 2 nested baseline content should be gone after identify", + device.findObject(By.res("entry-text-uaNY4YJ0HFPAX3gKXiRdX")), + ) + } +} diff --git a/implementations/ios-sdk/AGENTS.md b/implementations/ios-sdk/AGENTS.md index 8344a4e9..03a54f15 100644 --- a/implementations/ios-sdk/AGENTS.md +++ b/implementations/ios-sdk/AGENTS.md @@ -21,7 +21,7 @@ uses separate SwiftUI and UIKit app shells generated from `project.yml`. - Keep this app focused on validating native iOS integration behavior. Reusable Swift SDK behavior belongs in `packages/ios/ContentfulOptimization`, and TypeScript bridge behavior belongs in - `packages/ios/ios-jsc-bridge`. + `packages/universal/optimization-js-bridge`. - The mock server must be running at `http://localhost:8000` before UI tests. - The Xcode project is generated by XcodeGen from `project.yml`. After adding, renaming, or moving iOS source files, run `xcodegen generate` from `implementations/ios-sdk/`. @@ -48,4 +48,4 @@ uses separate SwiftUI and UIKit app shells generated from `project.yml`. identifiers, or scenario-specific behavior. - Run the full XCUITest suite for broad native integration, preview-panel, tracking, or lifecycle changes. -- Rebuild `@contentful/optimization-ios-bridge` before testing when bridge source changed. +- Rebuild `@contentful/optimization-js-bridge` before testing when bridge source changed. diff --git a/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj b/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj index e11faad1..720c5451 100644 --- a/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj +++ b/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 2D489A968E200B477EF8F8B9 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DDE184BA14D36BAC6A5936 /* Config.swift */; }; 394C0BFFE6C5F4E2E1429CEF /* IdentifiedVariantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44D0B58FB5CDE1A33ADEC3E /* IdentifiedVariantsTests.swift */; }; 3AE0D2F928ABA07AE77FB039 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38EFCB367C35AF0AEB0A4AE /* TestHelpers.swift */; }; + 3EC1F3129468B71C9DE3C486 /* MockPreviewContentfulClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A72A25C095AB9AA5B35E3F57 /* MockPreviewContentfulClient.swift */; }; 3EE8AB65755C4AAECE6F088E /* ContentfulFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F383552F49942336F9725020 /* ContentfulFetcher.swift */; }; 4067B88302C9720268628FF4 /* OfflineBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682E3BF881B8093C2D5EF2CE /* OfflineBehaviorTests.swift */; }; 42933080441B12904731C661 /* ContentfulOptimization in Frameworks */ = {isa = PBXBuildFile; productRef = 244F9A9729F24EEBFDC1862F /* ContentfulOptimization */; }; @@ -38,12 +39,15 @@ 71EE02D789A2D2D922B4DC0A /* NavigationTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEDF68085F694A44D33D237A /* NavigationTestScreen.swift */; }; 73A7EECB26977730B7241077 /* EventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CE81FC20185EE7C2FF7BEF /* EventStore.swift */; }; 7CE0E6C067281C01D8F0A85F /* NavigationTestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4808FBBA0BFA7C40F52FC7 /* NavigationTestViewController.swift */; }; + 7E0AE3A92E2EB13F275145C5 /* MockPreviewContentfulClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A72A25C095AB9AA5B35E3F57 /* MockPreviewContentfulClient.swift */; }; + 802A2114E9A91D5F798272B2 /* RichText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820BE574DFF5E022220890CD /* RichText.swift */; }; 8A29BA19D8A78BC9DB688B37 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B7AD7AF5C9B56BD605DB59 /* AppDelegate.swift */; }; 8BE046C5FD5539712A40551C /* MainScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F34F81667A8807581B59F /* MainScreen.swift */; }; 9014D5B10826A433A7412BAC /* PreviewPanelOverridesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D155E6889AE670D2B6097CC /* PreviewPanelOverridesTests.swift */; }; 91A5DFA622284D666F20D46E /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29032ACE1D4AEB3E5D1BB51A /* MainViewController.swift */; }; 93DDB4FCE61B74F35663301D /* LiveUpdatesTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A41CEBEA796C5A3179D691E /* LiveUpdatesTestScreen.swift */; }; 9E261E1E35994E3D7A652291 /* ScreenTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2A09744E23C92FDCD9F5439 /* ScreenTrackingTests.swift */; }; + 9F3986444C6D6D3BCB8ECEA3 /* RichText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820BE574DFF5E022220890CD /* RichText.swift */; }; A375768DD5B98ECEC51B04C9 /* FlagViewTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E79C1C23CB5619203CAD28A3 /* FlagViewTrackingTests.swift */; }; B1A5A931BBDFC3438E935012 /* OfflineBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682E3BF881B8093C2D5EF2CE /* OfflineBehaviorTests.swift */; }; C04EF55982CA837E62CC0669 /* ContentfulFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F383552F49942336F9725020 /* ContentfulFetcher.swift */; }; @@ -95,11 +99,13 @@ 72BC55BDAE08B0B386CCFA65 /* ContentfulOptimization */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ContentfulOptimization; path = ../../packages/ios/ContentfulOptimization; sourceTree = SOURCE_ROOT; }; 7A41CEBEA796C5A3179D691E /* LiveUpdatesTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveUpdatesTestScreen.swift; sourceTree = ""; }; 7FDD088B0ACEC1271B3C5509 /* OptimizedEntryUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizedEntryUIView.swift; sourceTree = ""; }; + 820BE574DFF5E022220890CD /* RichText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichText.swift; sourceTree = ""; }; 89CE81FC20185EE7C2FF7BEF /* EventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventStore.swift; sourceTree = ""; }; 8C4808FBBA0BFA7C40F52FC7 /* NavigationTestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTestViewController.swift; sourceTree = ""; }; 8FB2B5375330439D2F81AACF /* OptimizationAppUITestsSwiftUI.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = OptimizationAppUITestsSwiftUI.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9711C5717754C40619B585EE /* XCTestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestExtensions.swift; sourceTree = ""; }; A38EFCB367C35AF0AEB0A4AE /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; + A72A25C095AB9AA5B35E3F57 /* MockPreviewContentfulClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPreviewContentfulClient.swift; sourceTree = ""; }; AAC9CA7E33164E91046FF6DF /* AnalyticsEventDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventDisplay.swift; sourceTree = ""; }; B2A09744E23C92FDCD9F5439 /* ScreenTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTrackingTests.swift; sourceTree = ""; }; B85C1A5AF835283004FFE76D /* LiveUpdatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveUpdatesTests.swift; sourceTree = ""; }; @@ -203,6 +209,8 @@ D5DDE184BA14D36BAC6A5936 /* Config.swift */, F383552F49942336F9725020 /* ContentfulFetcher.swift */, 89CE81FC20185EE7C2FF7BEF /* EventStore.swift */, + A72A25C095AB9AA5B35E3F57 /* MockPreviewContentfulClient.swift */, + 820BE574DFF5E022220890CD /* RichText.swift */, ); path = shared; sourceTree = ""; @@ -427,8 +435,10 @@ 4447121F6CD440EAC27713E7 /* EventStore.swift in Sources */, 93DDB4FCE61B74F35663301D /* LiveUpdatesTestScreen.swift in Sources */, 8BE046C5FD5539712A40551C /* MainScreen.swift in Sources */, + 7E0AE3A92E2EB13F275145C5 /* MockPreviewContentfulClient.swift in Sources */, 71EE02D789A2D2D922B4DC0A /* NavigationTestScreen.swift in Sources */, F510376F8D6D3EFE7BB197E1 /* NestedContentEntryView.swift in Sources */, + 9F3986444C6D6D3BCB8ECEA3 /* RichText.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -464,9 +474,11 @@ 73A7EECB26977730B7241077 /* EventStore.swift in Sources */, D28A6D01EBA9877DD9174BCB /* LiveUpdatesTestViewController.swift in Sources */, 91A5DFA622284D666F20D46E /* MainViewController.swift in Sources */, + 3EC1F3129468B71C9DE3C486 /* MockPreviewContentfulClient.swift in Sources */, 7CE0E6C067281C01D8F0A85F /* NavigationTestViewController.swift in Sources */, D93DA9530B2B7E95815DB931 /* NestedContentEntryUIView.swift in Sources */, 072516CD147CCCA5D7F0BE53 /* OptimizedEntryUIView.swift in Sources */, + 802A2114E9A91D5F798272B2 /* RichText.swift in Sources */, 68EBA430F06D7974D323C432 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/implementations/ios-sdk/shared/Config.swift b/implementations/ios-sdk/shared/Config.swift index 1253a3df..19d667a2 100644 --- a/implementations/ios-sdk/shared/Config.swift +++ b/implementations/ios-sdk/shared/Config.swift @@ -1,7 +1,14 @@ +import CoreGraphics import Foundation struct AppConfig { static let clientId = "mock-client-id" + + /// Minimum height for each home-screen content entry card. Sized so the + /// home list is taller than the viewport and the lower entries genuinely + /// start below the fold — the layout the cross-platform view-tracking + /// contract assumes for `BELOW_FOLD_ENTRY_ID`. + static let contentEntryMinHeight: CGFloat = 180 static let environment = "master" static let experienceBaseUrl = "http://localhost:8000/experience/" static let insightsBaseUrl = "http://localhost:8000/insights/" diff --git a/implementations/ios-sdk/shared/ContentfulFetcher.swift b/implementations/ios-sdk/shared/ContentfulFetcher.swift index 93e4631d..e9da1ef2 100644 --- a/implementations/ios-sdk/shared/ContentfulFetcher.swift +++ b/implementations/ios-sdk/shared/ContentfulFetcher.swift @@ -54,25 +54,27 @@ struct ContentfulFetcher { return resolveValue(entry, lookup: lookup) as? [String: Any] ?? entry } + // `depth` counts logical link hops, not JSON-tree nodes, so a budget of 10 + // matches the `include=10` CDA contract regardless of how deeply the followed + // entries nest plain dictionaries and arrays. private static func resolveValue(_ value: Any, lookup: [String: [String: Any]], depth: Int = 0) -> Any { - guard depth < 10 else { return value } - if let dict = value as? [String: Any] { if let sys = dict["sys"] as? [String: Any], let type = sys["type"] as? String, type == "Link", let id = sys["id"] as? String, let resolved = lookup[id] { + guard depth < 10 else { return value } return resolveValue(resolved, lookup: lookup, depth: depth + 1) } var result: [String: Any] = [:] for (key, val) in dict { - result[key] = resolveValue(val, lookup: lookup, depth: depth + 1) + result[key] = resolveValue(val, lookup: lookup, depth: depth) } return result } else if let array = value as? [Any] { - return array.map { resolveValue($0, lookup: lookup, depth: depth + 1) } + return array.map { resolveValue($0, lookup: lookup, depth: depth) } } return value diff --git a/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift b/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift new file mode 100644 index 00000000..6499c4da --- /dev/null +++ b/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift @@ -0,0 +1,73 @@ +import ContentfulOptimization +import Foundation + +/// `PreviewContentfulClient` that targets `AppConfig.contentfulBaseUrl` +/// (the local mock server) rather than Contentful's production CDA. +/// +/// Mirrors `ContentfulHTTPPreviewClient` in shape but uses the demo app's +/// configured base URL so the preview panel can load audiences and +/// experiences against the mock fixture during E2E tests. +final class MockPreviewContentfulClient: PreviewContentfulClient { + private let baseUrl: String + private let spaceId: String + private let environment: String + private let accessToken: String + private let session: URLSession + + init( + baseUrl: String = AppConfig.contentfulBaseUrl, + spaceId: String = AppConfig.contentfulSpaceId, + environment: String = AppConfig.environment, + accessToken: String = "mock-access-token", + session: URLSession = .shared + ) { + self.baseUrl = baseUrl + self.spaceId = spaceId + self.environment = environment + self.accessToken = accessToken + self.session = session + } + + func getEntries(contentType: String, include: Int, skip: Int, limit: Int) async throws -> ContentfulEntriesResult { + let urlString = "\(baseUrl)spaces/\(spaceId)/environments/\(environment)/entries" + guard var components = URLComponents(string: urlString) else { + throw URLError(.badURL) + } + components.queryItems = [ + URLQueryItem(name: "content_type", value: contentType), + URLQueryItem(name: "include", value: String(include)), + URLQueryItem(name: "skip", value: String(skip)), + URLQueryItem(name: "limit", value: String(limit)), + ] + guard let url = components.url else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + throw URLError(.badServerResponse) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw URLError(.cannotParseResponse) + } + + let includesJSON = json["includes"] as? [String: Any] + let includedEntries = includesJSON?["Entry"] as? [[String: Any]] ?? [] + + return ContentfulEntriesResult( + items: json["items"] as? [[String: Any]] ?? [], + total: json["total"] as? Int ?? 0, + skip: json["skip"] as? Int ?? 0, + limit: json["limit"] as? Int ?? 0, + includes: ContentfulIncludes(entries: includedEntries) + ) + } +} diff --git a/implementations/ios-sdk/shared/RichText.swift b/implementations/ios-sdk/shared/RichText.swift new file mode 100644 index 00000000..c6fb510b --- /dev/null +++ b/implementations/ios-sdk/shared/RichText.swift @@ -0,0 +1,73 @@ +import ContentfulOptimization +import Foundation + +/// Flattens a Contentful Rich Text document into a plain display string, +/// resolving any inline merge-tag entries against the current profile. +/// +/// Mirrors the React Native demo's `getRichTextContent` so the flattened text +/// (used for accessibility labels) matches byte for byte across SDKs: top-level +/// nodes are joined with a single space, a node's children with the empty string. +@MainActor +enum RichText { + + /// True when `field` is a Rich Text document node rather than a plain string. + static func isRichTextDocument(_ field: Any?) -> Bool { + guard let dict = field as? [String: Any] else { return false } + return dict["nodeType"] as? String == "document" && dict["content"] is [Any] + } + + /// Resolve an entry's `text` field to a display string: flatten a Rich Text + /// document (resolving merge tags), pass a plain string through, otherwise + /// fall back to `"No content"`. + static func resolveText(_ field: Any?, client: OptimizationClient) -> String { + if isRichTextDocument(field), let document = field as? [String: Any] { + return flatten(document, client: client) + } + return field as? String ?? "No content" + } + + /// Flatten a Rich Text document to its display string. + static func flatten(_ document: [String: Any], client: OptimizationClient) -> String { + guard let content = document["content"] as? [Any] else { return "" } + return content + .compactMap { $0 as? [String: Any] } + .map { extractText($0, client: client) } + .joined(separator: " ") + } + + private static func extractText(_ node: [String: Any], client: OptimizationClient) -> String { + switch node["nodeType"] as? String { + case "text": + return node["value"] as? String ?? "" + case "embedded-entry-inline": + return resolveEmbeddedEntry(node, client: client) + default: + guard let content = node["content"] as? [Any] else { return "" } + return content + .compactMap { $0 as? [String: Any] } + .map { extractText($0, client: client) } + .joined() + } + } + + private static func resolveEmbeddedEntry(_ node: [String: Any], client: OptimizationClient) -> String { + guard let data = node["data"] as? [String: Any], + let target = data["target"] as? [String: Any], + let sys = target["sys"] as? [String: Any] + else { return "[Merge Tag]" } + + // A still-unresolved Link means `ContentfulFetcher` did not inline the + // entry; the flattener has nothing to resolve against. + if sys["type"] as? String == "Link" { return "[Merge Tag]" } + + let contentTypeId = ((sys["contentType"] as? [String: Any])?["sys"] as? [String: Any])?["id"] as? String + guard contentTypeId == "nt_mergetag" else { return "[Merge Tag]" } + + if let resolved = client.getMergeTagValue(mergeTagEntry: target), !resolved.isEmpty { + return resolved + } + // Fall back to the merge tag's configured fallback value. + let fields = target["fields"] as? [String: Any] + return fields?["nt_fallback"] as? String ?? "[Merge Tag]" + } +} diff --git a/implementations/ios-sdk/swiftui/App.swift b/implementations/ios-sdk/swiftui/App.swift index f41956b1..ec4367c4 100644 --- a/implementations/ios-sdk/swiftui/App.swift +++ b/implementations/ios-sdk/swiftui/App.swift @@ -24,11 +24,12 @@ struct OptimizationDemoApp: App { debug: true ), trackViews: true, - trackTaps: true + trackTaps: true, + previewPanel: PreviewPanelConfig( + contentfulClient: MockPreviewContentfulClient() + ) ) { - PreviewPanelOverlay { - MainScreen() - } + MainScreen() } } } diff --git a/implementations/ios-sdk/swiftui/Components/AnalyticsEventDisplay.swift b/implementations/ios-sdk/swiftui/Components/AnalyticsEventDisplay.swift index fffec69e..14c842a9 100644 --- a/implementations/ios-sdk/swiftui/Components/AnalyticsEventDisplay.swift +++ b/implementations/ios-sdk/swiftui/Components/AnalyticsEventDisplay.swift @@ -1,9 +1,6 @@ -import Combine -import ContentfulOptimization import SwiftUI struct AnalyticsEventDisplay: View { - @EnvironmentObject private var client: OptimizationClient @ObservedObject private var store = EventStore.shared var body: some View { @@ -25,9 +22,6 @@ struct AnalyticsEventDisplay: View { .padding() .accessibilityElement(children: .contain) .accessibilityIdentifier("analytics-events-container") - .onAppear { - store.subscribe(to: client.eventPublisher) - } } private var nonComponentEvents: some View { @@ -56,7 +50,7 @@ struct AnalyticsEventDisplay: View { .accessibilityIdentifier("event-view-id-\(cid)") } .accessibilityElement(children: .contain) - .accessibilityIdentifier("component-stats-\(cid)") + .accessibilityIdentifier("entry-stats-\(cid)") } } } diff --git a/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift b/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift index 20c25aed..465e0adb 100644 --- a/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift +++ b/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift @@ -4,6 +4,8 @@ import SwiftUI struct ContentEntryView: View { let entry: [String: Any] + @EnvironmentObject private var client: OptimizationClient + private var entryId: String { let sys = entry["sys"] as? [String: Any] return sys?["id"] as? String ?? "" @@ -15,7 +17,7 @@ struct ContentEntryView: View { trackTaps: true, accessibilityIdentifier: "content-entry-\(entryId)" ) { resolvedEntry in - EntryContent(entry: resolvedEntry, entryId: entryId) + EntryContent(entry: resolvedEntry, entryId: entryId, client: client) } } } @@ -23,10 +25,11 @@ struct ContentEntryView: View { private struct EntryContent: View { let entry: [String: Any] let entryId: String + let client: OptimizationClient private var text: String { let fields = entry["fields"] as? [String: Any] - return fields?["text"] as? String ?? "No content" + return RichText.resolveText(fields?["text"], client: client) } var body: some View { @@ -35,6 +38,10 @@ private struct EntryContent: View { Text("[Entry: \(entryId)]") } .padding() + // A card-sized minimum height keeps the home list taller than the + // viewport so the lower entries genuinely start below the fold — the + // layout the cross-platform view-tracking contract assumes. + .frame(maxWidth: .infinity, minHeight: AppConfig.contentEntryMinHeight, alignment: .topLeading) .accessibilityElement(children: .ignore) .accessibilityLabel("\(text) [Entry: \(entryId)]") .accessibilityIdentifier("entry-text-\(entryId)") diff --git a/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift b/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift index eb8665f0..8a66b4b0 100644 --- a/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift +++ b/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift @@ -4,13 +4,32 @@ import SwiftUI struct NestedContentEntryView: View { let entry: [String: Any] + @EnvironmentObject private var client: OptimizationClient + private var entryId: String { let sys = entry["sys"] as? [String: Any] return sys?["id"] as? String ?? "" } + var body: some View { + OptimizedEntry( + entry: entry, + accessibilityIdentifier: "content-entry-\(entryId)" + ) { resolvedEntry in + NestedContentItemView(resolvedEntry: resolvedEntry, client: client) + } + } +} + +/// Renders a resolved nested entry's text plus its children. Children are read +/// from the *resolved* entry so an identified/variant entry recurses into the +/// variant's nested children rather than the baseline's. +private struct NestedContentItemView: View { + let resolvedEntry: [String: Any] + let client: OptimizationClient + private var nestedEntries: [[String: Any]] { - let fields = entry["fields"] as? [String: Any] + let fields = resolvedEntry["fields"] as? [String: Any] guard let nestedArray = fields?["nested"] as? [Any] else { return [] } return nestedArray.compactMap { $0 as? [String: Any] }.filter { item in guard let sys = item["sys"] as? [String: Any] else { return false } @@ -20,12 +39,7 @@ struct NestedContentEntryView: View { var body: some View { VStack(alignment: .leading) { - OptimizedEntry( - entry: entry, - accessibilityIdentifier: "content-entry-\(entryId)" - ) { resolvedEntry in - NestedEntryText(entry: resolvedEntry) - } + NestedEntryText(entry: resolvedEntry, client: client) ForEach(0..=` height constraint just stretches it to the card minimum. NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + heightAnchor.constraint(greaterThanOrEqualToConstant: AppConfig.contentEntryMinHeight), ]) isAccessibilityElement = true diff --git a/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift b/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift index 125de720..5622d4d0 100644 --- a/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift @@ -9,6 +9,37 @@ final class NestedContentEntryUIView: UIView { let entryId = (entry["sys"] as? [String: Any])?["id"] as? String ?? "" + let optimized = OptimizedEntryUIView( + client: client, + entry: entry, + scrollView: scrollView, + accessibilityIdentifier: "content-entry-\(entryId)" + ) { resolved in + NestedContentItemUIView(client: client, resolvedEntry: resolved, scrollView: scrollView) + } + optimized.translatesAutoresizingMaskIntoConstraints = false + addSubview(optimized) + NSLayoutConstraint.activate([ + optimized.topAnchor.constraint(equalTo: topAnchor), + optimized.leadingAnchor.constraint(equalTo: leadingAnchor), + optimized.trailingAnchor.constraint(equalTo: trailingAnchor), + optimized.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} + +/// Renders a resolved nested entry's text plus its children. Children are read +/// from the *resolved* entry so an identified/variant entry recurses into the +/// variant's nested children rather than the baseline's. +private final class NestedContentItemUIView: UIView { + + init(client: OptimizationClient, resolvedEntry: [String: Any], scrollView: UIScrollView?) { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + let stack = UIStackView() stack.axis = .vertical stack.alignment = .fill @@ -22,17 +53,9 @@ final class NestedContentEntryUIView: UIView { stack.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - let optimized = OptimizedEntryUIView( - client: client, - entry: entry, - scrollView: scrollView, - accessibilityIdentifier: "content-entry-\(entryId)" - ) { resolved in - NestedEntryText(entry: resolved) - } - stack.addArrangedSubview(optimized) + stack.addArrangedSubview(NestedEntryText(entry: resolvedEntry, client: client)) - for child in nestedEntries(in: entry) { + for child in nestedEntries(in: resolvedEntry) { stack.addArrangedSubview(NestedContentEntryUIView(client: client, entry: child, scrollView: scrollView)) } } @@ -51,13 +74,13 @@ final class NestedContentEntryUIView: UIView { private final class NestedEntryText: UIView { - init(entry: [String: Any]) { + init(entry: [String: Any], client: OptimizationClient) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false let entryId = (entry["sys"] as? [String: Any])?["id"] as? String ?? "" let fields = entry["fields"] as? [String: Any] - let text = (fields?["text"] as? String) ?? "No content" + let text = RichText.resolveText(fields?["text"], client: client) let textLabel = UILabel() textLabel.text = text @@ -84,6 +107,7 @@ private final class NestedEntryText: UIView { isAccessibilityElement = true accessibilityLabel = "\(text) [Entry: \(entryId)]" + accessibilityIdentifier = "entry-text-\(entryId)" } @available(*, unavailable) diff --git a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift index 2cf08376..3be3a272 100644 --- a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift @@ -88,9 +88,13 @@ final class OptimizedEntryUIView: UIView { return fields["nt_experiences"] != nil } + // An open preview panel always forces live updates, overriding an explicit + // `liveUpdates: false`. The global toggle only acts as the default when no + // explicit per-component value is set. private var shouldLiveUpdate: Bool { + if client.isPreviewPanelOpen { return true } if let explicit = liveUpdates { return explicit } - return globalLiveUpdates || client.isPreviewPanelOpen + return globalLiveUpdates } private var effectivePersonalizations: [[String: Any]]? { @@ -113,6 +117,10 @@ final class OptimizedEntryUIView: UIView { private func subscribeToPersonalizations() { client.$selectedPersonalizations + // `@Published` fires in `willSet`, so a synchronous sink would read + // the *previous* value back off the client. Hop to the next main + // run-loop turn so re-resolution sees the committed personalizations. + .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self else { return } if self.shouldLiveUpdate { @@ -129,6 +137,7 @@ final class OptimizedEntryUIView: UIView { private func subscribeToPreviewPanel() { client.$isPreviewPanelOpen .dropFirst() + .receive(on: RunLoop.main) .sink { [weak self] open in guard let self else { return } if !open, self.hasLocked { diff --git a/implementations/ios-sdk/uikit/SceneDelegate.swift b/implementations/ios-sdk/uikit/SceneDelegate.swift index 9198b6db..5adafa40 100644 --- a/implementations/ios-sdk/uikit/SceneDelegate.swift +++ b/implementations/ios-sdk/uikit/SceneDelegate.swift @@ -30,6 +30,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.window = window window.makeKeyAndVisible() - PreviewPanelViewController.addFloatingButton(to: main, client: client, contentfulClient: nil) + PreviewPanelViewController.addFloatingButton( + to: main, + client: client, + contentfulClient: MockPreviewContentfulClient() + ) } } diff --git a/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift b/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift index 5a84681c..b41f8651 100644 --- a/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift +++ b/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift @@ -208,7 +208,7 @@ final class LiveUpdatesTestViewController: UIViewController { subtitle: "No liveUpdates prop - inherits from OptimizationRoot (false)", liveUpdates: nil, prefix: "default", - sectionIdentifier: "default-personalization", + sectionIdentifier: "default-optimization", store: { [weak self] in self?.defaultSection = $0 } )) sectionsHost.addArrangedSubview(makeSection( @@ -217,7 +217,7 @@ final class LiveUpdatesTestViewController: UIViewController { subtitle: "Always updates when personalization state changes", liveUpdates: true, prefix: "live", - sectionIdentifier: "live-personalization", + sectionIdentifier: "live-optimization", store: { [weak self] in self?.liveSection = $0 } )) sectionsHost.addArrangedSubview(makeSection( @@ -226,7 +226,7 @@ final class LiveUpdatesTestViewController: UIViewController { subtitle: "Never updates - locks to first variant received", liveUpdates: false, prefix: "locked", - sectionIdentifier: "locked-personalization", + sectionIdentifier: "locked-optimization", store: { [weak self] in self?.lockedSection = $0 } )) } @@ -300,6 +300,9 @@ final class LiveUpdatesTestViewController: UIViewController { private func togglePreviewPanel() { isPreviewPanelSimulated.toggle() + // Drive the SDK preview-panel flag so default/locked sections switch to + // live-update mode while the panel is "open". + client.setPreviewPanelOpen(isPreviewPanelSimulated) updatePreviewPanelButtonTitle() refreshUI() } diff --git a/implementations/ios-sdk/uikit/Screens/MainViewController.swift b/implementations/ios-sdk/uikit/Screens/MainViewController.swift index c9d7feb2..0c3dd88d 100644 --- a/implementations/ios-sdk/uikit/Screens/MainViewController.swift +++ b/implementations/ios-sdk/uikit/Screens/MainViewController.swift @@ -6,14 +6,16 @@ final class MainViewController: UIViewController { private let client: OptimizationClient private var entries: [[String: Any]] = [] - private var isIdentified = false private var firstAppearHandled = false + private var flagSubscribed = false private var cancellables = Set() private let identifyButton = UIButton(type: .system) private let resetButton = UIButton(type: .system) private let navigationTestButton = UIButton(type: .system) private let liveUpdatesTestButton = UIButton(type: .system) + private let simulateOfflineButton = UIButton(type: .system) + private let simulateOnlineButton = UIButton(type: .system) private let scrollView = UIScrollView() private let contentStack = UIStackView() private let analyticsView = AnalyticsEventDisplayView() @@ -47,7 +49,15 @@ final class MainViewController: UIViewController { return l == r } .sink { [weak self] profile in - guard let self, profile != nil else { return } + guard let self else { return } + self.updateIdentifyControls(profile: profile) + guard profile != nil else { return } + // Subscribe to the `boolean` flag once a profile (and consent) + // is available so a flag-view `component` event is emitted. + if !self.flagSubscribed { + self.flagSubscribed = true + self.client.subscribeToFlag("boolean") + } Task { @MainActor in let fetched = await ContentfulFetcher.fetchEntries(ids: AppConfig.entryIds) self.entries = fetched @@ -65,12 +75,13 @@ final class MainViewController: UIViewController { client.consent(true) Task { @MainActor in _ = try? await client.page(properties: ["url": "app"]) - if ProcessInfo.processInfo.arguments.contains("--simulate-offline") { - client.setOnline(false) - } } } + private var networkControlsEnabled: Bool { + ProcessInfo.processInfo.arguments.contains("--enable-network-controls") + } + // MARK: - Layout private func configureControls() { @@ -91,6 +102,14 @@ final class MainViewController: UIViewController { liveUpdatesTestButton.accessibilityIdentifier = "live-updates-test-button" liveUpdatesTestButton.addAction(UIAction { [weak self] _ in self?.openLiveUpdatesTest() }, for: .touchUpInside) + simulateOfflineButton.setTitle("Go Offline", for: .normal) + simulateOfflineButton.accessibilityIdentifier = "simulate-offline-button" + simulateOfflineButton.addAction(UIAction { [weak self] _ in self?.client.setOnline(false) }, for: .touchUpInside) + + simulateOnlineButton.setTitle("Go Online", for: .normal) + simulateOnlineButton.accessibilityIdentifier = "simulate-online-button" + simulateOnlineButton.addAction(UIAction { [weak self] _ in self?.client.setOnline(true) }, for: .touchUpInside) + loadingLabel.text = "Loading..." loadingLabel.textAlignment = .center } @@ -109,10 +128,24 @@ final class MainViewController: UIViewController { buttonRow.distribution = .fillEqually buttonRow.spacing = 8 - let root = UIStackView(arrangedSubviews: [buttonRow, scrollView]) + let root = UIStackView(arrangedSubviews: [buttonRow]) root.axis = .vertical root.spacing = 8 root.translatesAutoresizingMaskIntoConstraints = false + + // Test-only runtime network controls. XCUITest cannot toggle real + // connectivity, so the offline-behavior suite drives the SDK online + // state on the live process — keeping the in-memory Experience queue + // intact across the offline/online transition. + if networkControlsEnabled { + let networkRow = UIStackView(arrangedSubviews: [simulateOfflineButton, simulateOnlineButton]) + networkRow.axis = .horizontal + networkRow.distribution = .fillEqually + networkRow.spacing = 8 + root.addArrangedSubview(networkRow) + } + + root.addArrangedSubview(scrollView) view.addSubview(root) contentStack.translatesAutoresizingMaskIntoConstraints = false @@ -164,9 +197,6 @@ final class MainViewController: UIViewController { Task { @MainActor in _ = try? await client.identify(userId: "charles", traits: ["identified": true]) } - isIdentified = true - identifyButton.isHidden = true - resetButton.isHidden = false } private func handleReset() { @@ -174,9 +204,16 @@ final class MainViewController: UIViewController { Task { @MainActor in _ = try? await client.page(properties: ["url": "app"]) } - isIdentified = false - identifyButton.isHidden = false - resetButton.isHidden = true + } + + /// Derive the identify/reset control from the SDK profile so a rehydrated + /// identified profile shows the reset control after a cold start, and the + /// control only flips once `identify` has resolved and been persisted. + private func updateIdentifyControls(profile: [String: Any]?) { + let traits = profile?["traits"] as? [String: Any] + let identified = traits?["identified"] as? Bool == true + identifyButton.isHidden = identified + resetButton.isHidden = !identified } private func openNavigationTest() { diff --git a/implementations/ios-sdk/uitests/Support/TestHelpers.swift b/implementations/ios-sdk/uitests/Support/TestHelpers.swift index b630071f..c360cb8f 100644 --- a/implementations/ios-sdk/uitests/Support/TestHelpers.swift +++ b/implementations/ios-sdk/uitests/Support/TestHelpers.swift @@ -66,6 +66,12 @@ func waitForTextEquals(_ testId: String, expected: String, app: XCUIApplication, _ = waitForElementText(testId, app: app, timeout: timeout) { $0 == expected } } +/// Polls element text until it differs from the supplied baseline. +@discardableResult +func waitForTextChange(_ testId: String, baseline: String, app: XCUIApplication, timeout: TimeInterval = ELEMENT_VISIBILITY_TIMEOUT) -> String { + return waitForElementText(testId, app: app, timeout: timeout) { $0 != baseline } +} + /// Parses "Events: N" text and waits until count >= minCount. func waitForEventsCountAtLeast(_ minCount: Int, app: XCUIApplication, timeout: TimeInterval = ELEMENT_VISIBILITY_TIMEOUT) { _ = waitForElementText("events-count", app: app, timeout: timeout) { text in @@ -123,3 +129,50 @@ func scrollToElement(testId: String, scrollViewId: String, app: XCUIApplication, scrollView.swipeUp() } } + +/// Scrolls a scroll view by a precise point offset with no fling momentum, so a +/// tracked entry's on-screen dwell time is predictable. A positive `dy` reveals +/// lower content; a negative `dy` reveals upper content. `swipeUp`/`swipeDown` +/// flings are too coarse and momentum-heavy for dwell-sensitive assertions. +/// +/// A larger `velocity` keeps the drag motion-free of fling momentum while still +/// moving the content quickly — useful when an entry must transit the viewport +/// faster than the dwell threshold. +func scrollByOffset( + scrollViewId: String, + dy: CGFloat, + app: XCUIApplication, + velocity: XCUIGestureVelocity = .default +) { + let scrollView = app.scrollViews[scrollViewId] + guard scrollView.exists else { return } + // Anchor near the bottom when dragging the finger up, near the top when + // dragging down, so even a near-full-screen drag endpoint stays on screen. + // The trailing hold cancels fling momentum. + let startNormalizedY: CGFloat = dy > 0 ? 0.9 : 0.1 + let start = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: startNormalizedY)) + let end = start.withOffset(CGVector(dx: 0, dy: -dy)) + start.press(forDuration: 0.05, thenDragTo: end, withVelocity: velocity, thenHoldForDuration: 0.2) +} + +/// Fraction of `element` that is vertically inside `container` (0...1). +func visibleRatio(of element: XCUIElement, in container: XCUIElement) -> CGFloat { + guard element.exists, container.exists else { return 0 } + let e = element.frame + let c = container.frame + guard e.height > 0 else { return 0 } + let top = max(e.minY, c.minY) + let bottom = min(e.maxY, c.maxY) + return max(0, bottom - top) / e.height +} + +/// Scrolls (momentum-free) until `testId` is at least 85% visible inside the +/// scroll view, so a fresh view-tracking cycle starts at a known instant. +func scrollEntryIntoView(_ testId: String, scrollViewId: String, app: XCUIApplication, maxSteps: Int = 16) { + let scrollView = app.scrollViews[scrollViewId] + for _ in 0..= 0.85 { return } + scrollByOffset(scrollViewId: scrollViewId, dy: -260, app: app) + } +} diff --git a/implementations/ios-sdk/uitests/Tests/AnalyticsTests.swift b/implementations/ios-sdk/uitests/Tests/AnalyticsTests.swift index 586c4a02..239f2920 100644 --- a/implementations/ios-sdk/uitests/Tests/AnalyticsTests.swift +++ b/implementations/ios-sdk/uitests/Tests/AnalyticsTests.swift @@ -1,5 +1,9 @@ import XCTest +/// Insights API Events +/// +/// Verifies that the SDK's analytics layer automatically emits entry view events +/// through the Insights API event stream when entries become visible in the UI. final class AnalyticsTests: XCTestCase { let app = XCUIApplication() @@ -9,9 +13,23 @@ final class AnalyticsTests: XCTestCase { clearProfileState(app: app) } - func testTracksComponentImpressionEventsForVisibleEntries() { - waitForElement(app.staticTexts["Analytics Events"]) + /// "should track entry view events for visible entries" + /// + /// When the Analytics Events screen is open, the SDK emits at least one Insights API + /// event and a per-entry stats element for the tracked merge tag entry becomes visible + /// after scrolling. + func testTracksEntryViewEventsForVisibleEntries() { + // 1. Wait until the "Analytics Events" text is visible, using the default + // element visibility timeout. + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // 2. Wait until the recorded Insights API event count is at least 1. waitForEventsCountAtLeast(1, app: app) - waitForComponentEventCount("1MwiFl4z7gkwqGYdvCmr8c", minCount: 1, app: app) + + // 3. Scroll the `main-scroll-view` until the per-entry stats summary element for + // the merge tag entry becomes visible. + let statsId = "entry-stats-1MwiFl4z7gkwqGYdvCmr8c" + scrollToElement(testId: statsId, scrollViewId: "main-scroll-view", app: app) + waitForElement(findElement(statsId, app: app), timeout: ELEMENT_VISIBILITY_TIMEOUT) } } diff --git a/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift b/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift index 26c4d7f8..adad0793 100644 --- a/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift +++ b/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift @@ -3,8 +3,13 @@ import XCTest final class ExtendedViewTrackingTests: XCTestCase { let app = XCUIApplication() + // The merge tag entry is always first in the list and visible immediately on launch. let VISIBLE_ENTRY_ID = "1MwiFl4z7gkwqGYdvCmr8c" + + // Second entry visible on launch (immediately after the merge tag entry). let SECOND_ENTRY_ID = "4ib0hsHWoSOnCVdDkizE8d" + + // An entry that starts below the fold (not visible on launch). let BELOW_FOLD_ENTRY_ID = "7pa5bOx8Z9NmNcr7mISvD" override func setUp() { @@ -14,100 +19,141 @@ final class ExtendedViewTrackingTests: XCTestCase { func testPeriodicEventsForContinuouslyVisibleEntry() { waitForElement(app.staticTexts["Analytics Events"]) + + // Wait for the initial event (after dwell threshold ~2s) waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) + + // Wait for at least one periodic update (dwell 2s + update interval 5s = ~7s total) waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) } func testIncreasingViewDurationMs() { waitForElement(app.staticTexts["Analytics Events"]) + + // Wait for at least 2 events so we can check duration is increasing waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) let duration = getViewDuration(VISIBLE_ENTRY_ID, app: app) + + // Duration should exceed the dwell threshold (2000ms) since we've had at least 2 events XCTAssertNotNil(duration) XCTAssertGreaterThan(duration!, 2000) } func testStableViewIdWithinCycle() { waitForElement(app.staticTexts["Analytics Events"]) + + // Capture the viewId from the FIRST event of the cycle, before any periodic + // update can overwrite latestViewId. + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) + let firstEventViewId = getViewId(VISIBLE_ENTRY_ID, app: app) + + XCTAssertNotNil(firstEventViewId) + XCTAssertGreaterThan(firstEventViewId!.count, 0) + + // Wait for the next periodic event in the SAME visibility cycle and re-read. + // A correct SDK reuses one viewId for the whole cycle, so the second read + // must equal the first. waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) + let secondEventViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - let viewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(viewId) - XCTAssertGreaterThan(viewId!.count, 0) + XCTAssertEqual(secondEventViewId, firstEventViewId) } func testFinalEventOnScrollOut() { waitForElement(app.staticTexts["Analytics Events"]) + + // Wait for at least 1 event from the visible entry waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) let preScrollViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - // Scroll entry out of viewport + // Scroll the entry out of the viewport let scrollView = app.scrollViews["main-scroll-view"] scrollView.swipeUp(times: 2) + + // Give the final event time to fire Thread.sleep(forTimeInterval: 1.0) - // Scroll back to top + // Scroll back to the top so the stats elements become visible again scrollView.swipeDown(times: 3) - // Verify at least one additional event was emitted (final event on scroll out) - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) + // Scroll to the events display to read updated stats + scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", + scrollViewId: "main-scroll-view", app: app) + + // The event count should have incremented by the final event + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: ELEMENT_VISIBILITY_TIMEOUT) // The viewId should still match the original cycle let postScrollViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertEqual(postScrollViewId, preScrollViewId, - "ViewId should remain stable within the same visibility cycle") + XCTAssertEqual(postScrollViewId, preScrollViewId) } func testNewViewIdAfterScrollAwayAndBack() { waitForElement(app.staticTexts["Analytics Events"]) - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) + // Cycle 1: wait for the initial event; reading the stats scrolls entry 0 + // off, ending cycle 1 with a final event. + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) let firstCycleViewId = getViewId(VISIBLE_ENTRY_ID, app: app) + XCTAssertNotNil(firstCycleViewId) + let countAfterCycle1 = parseComponentCount( + getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) - // Scroll away - let scrollView = app.scrollViews["main-scroll-view"] - scrollView.swipeUp(times: 2) - Thread.sleep(forTimeInterval: 1.0) - - // Scroll back - scrollView.swipeDown(times: 3) - Thread.sleep(forTimeInterval: 0.5) - - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 3, app: app, timeout: EXTENDED_TIMEOUT) + // Scroll entry 0 back into view to start a fresh cycle, and dwell past + // the threshold so the new cycle emits its initial event. + scrollEntryIntoView("content-entry-\(VISIBLE_ENTRY_ID)", + scrollViewId: "main-scroll-view", app: app) + Thread.sleep(forTimeInterval: 2.6) + // Wait for the new cycle's event and confirm it carries a fresh viewId. + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countAfterCycle1 + 1, + app: app, timeout: EXTENDED_TIMEOUT) let secondCycleViewId = getViewId(VISIBLE_ENTRY_ID, app: app) XCTAssertNotNil(secondCycleViewId) - XCTAssertNotEqual(secondCycleViewId, firstCycleViewId) + XCTAssertNotEqual(secondCycleViewId, firstCycleViewId, + "Second visibility cycle should have a different viewId") } func testNoEventsBeforeDwellThreshold() { - waitForElement(app.staticTexts["Analytics Events"]) - - // Scroll below-fold entry briefly into view - scrollToElement(testId: "content-entry-\(BELOW_FOLD_ENTRY_ID)", - scrollViewId: "main-scroll-view", app: app) - - // Immediately scroll back to top let scrollView = app.scrollViews["main-scroll-view"] - scrollView.swipeDown(times: 3) - - // Wait long enough that an event WOULD have fired + waitForElement(scrollView, timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // On a tall simulator the "below-fold" entry can render just inside the + // viewport at launch. Sweep it up and out with large, fast momentum-free + // drags: each single drag carries the entry all the way through the 0.8 + // tracked-visibility band in a few hundred milliseconds and ends with it + // below that band, so it never rests on screen — between XCUITest + // gestures or otherwise — long enough to trip the 2000 ms dwell timer. + // A fling instead leaves the entry resting mid-viewport during XCUITest's + // post-gesture idle wait; a slow drag keeps it fully visible for seconds. + let fast = XCUIGestureVelocity(rawValue: 2500) + for _ in 0..<5 { + scrollByOffset(scrollViewId: "main-scroll-view", dy: 700, app: app, velocity: fast) + } + + // Wait long enough that an event WOULD have fired if tracking hadn't been cancelled Thread.sleep(forTimeInterval: 3.0) - let statsElement = app.staticTexts["component-stats-\(BELOW_FOLD_ENTRY_ID)"] + // The stats element only renders when an entry view event has fired. + // It should not exist for the below-fold entry since it wasn't visible long enough. + let statsElement = app.otherElements["entry-stats-\(BELOW_FOLD_ENTRY_ID)"] XCTAssertFalse(statsElement.waitForExistence(timeout: 2.0)) } func testIndependentViewIdsForMultipleEntries() { waitForElement(app.staticTexts["Analytics Events"]) + // Wait for at least 1 event from each visible entry waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) waitForComponentEventCount(SECOND_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) + // Get viewIds for both entries let viewId1 = getViewId(VISIBLE_ENTRY_ID, app: app) let viewId2 = getViewId(SECOND_ENTRY_ID, app: app) + // Both should have non-null, distinct viewIds XCTAssertNotNil(viewId1) XCTAssertNotNil(viewId2) XCTAssertNotEqual(viewId1, viewId2) @@ -115,30 +161,36 @@ final class ExtendedViewTrackingTests: XCTestCase { func testFinalEventOnNavigationUnmount() { waitForElement(app.staticTexts["Analytics Events"]) + + // Wait for at least 1 tracking event (active cycle with emitted event) waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) + // Record the current event count let preNavText = getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app) let preNavCount = parseComponentCount(preNavText) - // Scroll to top to access navigation button + // Scroll back to top so the Navigation Test button is accessible let scrollView = app.scrollViews["main-scroll-view"] scrollView.swipeDown(times: 3) - // Navigate away + // Navigate away: this unmounts all tracked entries, triggering cleanup let navButton = app.buttons["navigation-test-button"] waitForElement(navButton) navButton.tap() waitForElement(app.buttons["close-navigation-test-button"]) + // Give the final event time to fire Thread.sleep(forTimeInterval: 0.5) - // Navigate back + // Navigate back to main screen app.buttons["close-navigation-test-button"].tap() + // Wait for the events display to reappear (screen remounts with persisted state) waitForElement(app.staticTexts["Analytics Events"]) scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", scrollViewId: "main-scroll-view", app: app) + // The event count should have increased (final event emitted during unmount) let postNavText = getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app) let postNavCount = parseComponentCount(postNavText) XCTAssertGreaterThan(postNavCount, preNavCount) @@ -146,83 +198,88 @@ final class ExtendedViewTrackingTests: XCTestCase { func testPauseResumeOnBackgroundForeground() { waitForElement(app.staticTexts["Analytics Events"]) - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) - - let preBackgroundViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - // Scroll back to top so entry is visible before backgrounding - let scrollView = app.scrollViews["main-scroll-view"] - scrollView.swipeDown(times: 3) - - // Wait for tracking to stabilize with entry visible - Thread.sleep(forTimeInterval: 3.0) - - // Record count before background (scroll to analytics to read) - scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", - scrollViewId: "main-scroll-view", app: app) - let countBefore = parseComponentCount( + // Cycle 1: entry 0 is visible on launch. Wait for its initial event; + // reading the stats then scrolls it off, which ends cycle 1. + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) + let firstCycleViewId = getViewId(VISIBLE_ENTRY_ID, app: app) + XCTAssertNotNil(firstCycleViewId) + let countBeforeBackground = parseComponentCount( getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) - // Scroll back to top so entry is visible when we background - scrollView.swipeDown(times: 3) - Thread.sleep(forTimeInterval: 1.0) + // Start a cycle that is ACTIVE when the app backgrounds: scroll entry 0 + // back into view and dwell past the threshold so its initial event + // fires. `pause()` must then emit a final event for this active cycle. + scrollEntryIntoView("content-entry-\(VISIBLE_ENTRY_ID)", + scrollViewId: "main-scroll-view", app: app) + Thread.sleep(forTimeInterval: 3.0) - // Background the app + // Send app to background — pause() ends the active cycle with a final event. XCUIDevice.shared.press(.home) Thread.sleep(forTimeInterval: 1.0) - // Foreground the app + // Foreground — resume() re-evaluates the stored geometry (entry 0 was + // visible at background time) and starts a fresh cycle. app.activate() + waitForElement(app.staticTexts["Analytics Events"]) - // Wait for the app to fully resume and new tracking events to fire - Thread.sleep(forTimeInterval: 5.0) - - // Check that events continued after resume + // Let the resumed cycle dwell past the threshold so its initial event + // fires, then scroll the stats into view to read the updated count. + Thread.sleep(forTimeInterval: 3.0) scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", scrollViewId: "main-scroll-view", app: app) - let countAfter = parseComponentCount( - getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) - XCTAssertGreaterThan(countAfter, countBefore, - "Events should continue accumulating after app returns from background (before=\(countBefore), after=\(countAfter))") - // Backgrounding ends the cycle and foregrounding starts a new one — viewId should differ + // Backgrounding ended the pre-background cycle with a final event and + // foregrounding started a fresh one with its own initial event, so the + // count must advance by at least 2. + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countBeforeBackground + 2, + app: app, timeout: EXTENDED_TIMEOUT) + + // The resumed cycle must carry a different viewId than the first cycle. let postForegroundViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotEqual(postForegroundViewId, preBackgroundViewId, + XCTAssertNotEqual(postForegroundViewId, firstCycleViewId, "ViewId should change after background/foreground cycle (new tracking cycle)") } func testDurationResetOnNewCycle() { waitForElement(app.staticTexts["Analytics Events"]) - // Wait for at least 2 events so duration accumulates beyond the dwell threshold - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) - - let firstDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(firstDuration) - // Duration after 2 events should be above the dwell threshold - XCTAssertGreaterThan(firstDuration!, 3000, - "First cycle duration should exceed 3000ms after 2+ events") + // Cycle 1: entry 0 is visible on launch. Leave it untouched well past the + // dwell threshold so cycle 1 accumulates more than 4000 ms of view time. + Thread.sleep(forTimeInterval: 6.0) - // Scroll away (end cycle, triggers final event) - let scrollView = app.scrollViews["main-scroll-view"] - scrollView.swipeUp(times: 2) - Thread.sleep(forTimeInterval: 1.0) + // Reading the stats scrolls entry 0 off, ending cycle 1 with a final + // event whose duration is the full ~6 s the entry was continuously + // visible — comfortably above the 4000 ms contract floor. + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) + let firstCycleDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) + XCTAssertNotNil(firstCycleDuration) + XCTAssertGreaterThan(firstCycleDuration!, 4000) - // Scroll back (new cycle starts) - scrollView.swipeDown(times: 3) - Thread.sleep(forTimeInterval: 0.5) + let countAfterCycle1 = parseComponentCount( + getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) - // Cycle 1: initial(1) + periodic(2) + final(3) = 3 events - // New cycle initial = event 4 - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 4, app: app, timeout: EXTENDED_TIMEOUT) - - // The new cycle's duration should be around the dwell threshold (~2000ms), - // not carrying over the 4000+ms from cycle 1 - let newDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(newDuration) - XCTAssertGreaterThanOrEqual(newDuration!, 2000) - XCTAssertLessThan(newDuration!, 4000, - "New cycle duration should reset — expected < 4000ms but got \(newDuration!)ms") + // Start a fresh, short cycle. Scroll entry 0 back to the top, then reset + // its tracking cycle to a known start with a quick out-and-in jiggle — + // measuring from the jiggle (rather than from somewhere inside the slow + // scroll-in) keeps the new cycle's duration tightly bounded and well + // under 4000 ms, proving the accumulator reset between cycles. + let fastVelocity = XCUIGestureVelocity(rawValue: 2500) + scrollEntryIntoView("content-entry-\(VISIBLE_ENTRY_ID)", + scrollViewId: "main-scroll-view", app: app) + scrollByOffset(scrollViewId: "main-scroll-view", dy: 260, app: app, velocity: fastVelocity) + scrollByOffset(scrollViewId: "main-scroll-view", dy: -260, app: app, velocity: fastVelocity) + Thread.sleep(forTimeInterval: 1.4) + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countAfterCycle1 + 1, + app: app, timeout: EXTENDED_TIMEOUT) + + // The new cycle's duration must have reset — it reflects only this short + // cycle, not the 4000+ ms accumulated in cycle 1. + let secondCycleDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) + XCTAssertNotNil(secondCycleDuration) + XCTAssertGreaterThanOrEqual(secondCycleDuration!, 2000) + XCTAssertLessThan(secondCycleDuration!, 4000, + "New cycle duration should reset — expected < 4000ms but got \(secondCycleDuration!)ms") } // MARK: - Helpers diff --git a/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift b/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift index 733efdfb..099c89b9 100644 --- a/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift +++ b/implementations/ios-sdk/uitests/Tests/FlagViewTrackingTests.swift @@ -1,21 +1,33 @@ import XCTest -/// Tests that a subscribed boolean flag emits component events. +/// Flag View Tracking /// -/// Note: The iOS implementation app currently does not subscribe to boolean flags -/// (this is a feature of the RN SDK's `sdk.states.flag('boolean').subscribe()`). -/// This test verifies basic event tracking as a placeholder until boolean flag -/// subscriptions are supported. +/// Verifies that when an app subscribes to a flag through the SDK, the SDK emits a +/// view event for that flag so the value's exposure can be measured downstream. +/// +/// Platform note: the pseudocode contract subscribes to the `boolean` flag on app +/// launch and asserts a view event for it. The iOS implementation app shells +/// (`swiftui`/`uikit`) do not currently call `sdk.states.flag("boolean").subscribe()`, +/// so the `event-count-boolean` stats label is not produced. See the report for this +/// app-side gap. final class FlagViewTrackingTests: XCTestCase { let app = XCUIApplication() override func setUp() { continueAfterFailure = false + // beforeEach: clear profile state with a fresh app instance. clearProfileState(app: app, requireFreshAppInstance: true) } - func testEmitsComponentEventsForTrackedEntries() { - waitForElement(app.staticTexts["Analytics Events"]) - waitForEventsCountAtLeast(1, app: app, timeout: EXTENDED_TIMEOUT) + /// "should emit flag view events for the subscribed boolean flag" + /// + /// Verifies: subscribing to the `boolean` flag on app launch produces at least + /// one view event for that flag. + func testEmitsFlagViewEventsForSubscribedBooleanFlag() { + // 1. Wait until the "Analytics Events" text is visible. + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // 2. Wait until flag `boolean` has at least 1 view event. + waitForComponentEventCount("boolean", minCount: 1, app: app, timeout: ELEMENT_VISIBILITY_TIMEOUT) } } diff --git a/implementations/ios-sdk/uitests/Tests/IdentifiedVariantsTests.swift b/implementations/ios-sdk/uitests/Tests/IdentifiedVariantsTests.swift index caf04388..05d5e638 100644 --- a/implementations/ios-sdk/uitests/Tests/IdentifiedVariantsTests.swift +++ b/implementations/ios-sdk/uitests/Tests/IdentifiedVariantsTests.swift @@ -1,5 +1,13 @@ import XCTest +/// 1:1 port of `displays-identified-user-variants.test.js`. +/// +/// Verifies that once a visitor has been identified and the app has been +/// relaunched, the SDK resolves and renders the correct variant for each +/// optimized entry on screen. Covers common variants (merge tag, continent, +/// device), identified-user-only variants (return visitor, A/B/C bucket, +/// custom-event audience, identified audience), and nested optimization +/// variants across three levels of depth. final class IdentifiedVariantsTests: XCTestCase { let app = XCUIApplication() @@ -8,95 +16,116 @@ final class IdentifiedVariantsTests: XCTestCase { app.launch() clearProfileState(app: app) - waitForElement(app.buttons["identify-button"]) + waitForElement(app.buttons["identify-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) app.buttons["identify-button"].tap() - waitForElement(app.buttons["reset-button"]) + waitForElement(app.buttons["reset-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) - // Relaunch to test persistence of identified state + // Relaunch as a new instance so the identified state is rehydrated from + // persistent storage. The relaunched app derives the identify/reset + // control from the rehydrated identified profile, so `reset-button` + // (not `identify-button`) appears. Waiting for it both confirms the + // relaunch finished loading and proves the identified profile actually + // survived the cold start — the precondition every test in this suite + // needs. app.terminate() app.launch() - waitForElement(app.buttons["identify-button"]) + waitForElement(app.buttons["reset-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) } - func testDisplaysMergeTagEntry() { - let entry = app.otherElements["content-entry-1MwiFl4z7gkwqGYdvCmr8c"] - waitForElement(entry) - XCTAssertFalse(entry.label.isEmpty, "Entry should have content") + // MARK: - common variants + + func testShouldDisplayMergeTagContentWithResolvedValue() { + let element = app.otherElements["entry-text-1MwiFl4z7gkwqGYdvCmr8c"] + waitForElement(element, timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // Asserted against the element's label (not via a label subscript) because + // the merge tag label exceeds XCUITest's 128-character identifier limit. + let expected = "This is a merge tag content entry that displays the visitor's continent \"EU\" embedded within the text. [Entry: 1MwiFl4z7gkwqGYdvCmr8c]" + XCTAssertEqual(element.label, expected, "Expected merge tag variant label to be visible") } - func testDisplaysContinentBasedEntry() { - let entry = app.otherElements["content-entry-4ib0hsHWoSOnCVdDkizE8d"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("continent") || entry.label.contains("Europe"), - "Expected continent-based content, got: \(entry.label)") + func testShouldDisplayVariantForVisitorsFromEurope() { + waitForElement(app.otherElements["entry-text-4ib0hsHWoSOnCVdDkizE8d"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a variant content entry for visitors from Europe. [Entry: 4ib0hsHWoSOnCVdDkizE8d]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected Europe continent variant label to be visible") } - func testDisplaysDeviceBasedEntry() { - let entry = app.otherElements["content-entry-xFwgG3oNaOcjzWiGe4vXo"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("device") || entry.label.contains("desktop"), - "Expected device-based content, got: \(entry.label)") + func testShouldDisplayVariantForDesktopBrowserVisitors() { + waitForElement(app.otherElements["entry-text-xFwgG3oNaOcjzWiGe4vXo"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a variant content entry for visitors using a desktop browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected desktop browser variant label to be visible") } - func testDisplaysReturnVisitorEntry() { - let entry = app.otherElements["content-entry-2Z2WLOx07InSewC3LUB3eX"] - waitForElement(entry) - XCTAssertFalse(entry.label.isEmpty, "Expected return visitor content") + // MARK: - identified user variants + + func testShouldDisplayVariantForReturnVisitors() { + waitForElement(app.otherElements["entry-text-2Z2WLOx07InSewC3LUB3eX"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a variant content entry for return visitors. [Entry: 2Z2WLOx07InSewC3LUB3eX]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected return visitor variant label to be visible") } - func testDisplaysABCExperimentEntry() { - let entry = app.otherElements["content-entry-5XHssysWUDECHzKLzoIsg1"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("A/B/C") || entry.label.contains("experiment"), - "Expected A/B/C experiment content, got: \(entry.label)") + func testShouldDisplayVariantBForABCExperiment() { + waitForElement(app.otherElements["entry-text-5XHssysWUDECHzKLzoIsg1"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a variant content entry for an A/B/C experiment: B [Entry: 5XHssysWUDECHzKLzoIsg1]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected A/B/C experiment variant B label to be visible") } - func testDisplaysCustomEventEntry() { - let entry = app.otherElements["content-entry-6zqoWXyiSrf0ja7I2WGtYj"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("custom event") || entry.label.contains("baseline") || entry.label.contains("variant"), - "Expected custom event entry content, got: \(entry.label)") + func testShouldDisplayVariantForVisitorsWithCustomEvent() { + waitForElement(app.otherElements["entry-text-6zqoWXyiSrf0ja7I2WGtYj"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected custom event variant label to be visible") } - func testDisplaysIdentifiedUserEntry() { - scrollToElement(testId: "content-entry-7pa5bOx8Z9NmNcr7mISvD", - scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-7pa5bOx8Z9NmNcr7mISvD"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("identified") || entry.label.contains("baseline") || entry.label.contains("variant"), - "Expected identification entry content, got: \(entry.label)") + func testShouldDisplayVariantForIdentifiedUsers() { + waitForElement(app.otherElements["entry-text-7pa5bOx8Z9NmNcr7mISvD"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected identified users variant label to be visible") } - // MARK: - Nested optimization variants - // - // Nested entries use the baseline entry ID for their accessibility identifier - // (set on the OptimizedEntry wrapper). The resolved variant content is displayed - // inside, so the label reflects the variant text. - - func testDisplaysLevel0NestedVariant() { - scrollToElement(testId: "content-entry-1JAU028vQ7v6nB2swl3NBo", - scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-1JAU028vQ7v6nB2swl3NBo"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("level 0") || entry.label.contains("nested"), - "Expected level 0 nested variant content, got: \(entry.label)") + // MARK: - nested optimization variants + + func testShouldDisplayLevel0NestedVariantForReturnVisitors() { + waitForElement(app.otherElements["entry-text-2KIWllNZJT205BwOSkMINg"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a level 0 nested variant entry. [Entry: 2KIWllNZJT205BwOSkMINg]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 0 nested variant label to be visible") } - func testDisplaysLevel1NestedVariant() { - scrollToElement(testId: "content-entry-5i4SdJXw9oDEY0vgO7CwF4", - scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-5i4SdJXw9oDEY0vgO7CwF4"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("level 1") || entry.label.contains("nested"), - "Expected level 1 nested variant content, got: \(entry.label)") + func testShouldDisplayLevel1NestedVariantForReturnVisitors() { + waitForElement(app.otherElements["entry-text-5a8ONfBdanJtlJ39WWnH1w"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a level 1 nested variant entry. [Entry: 5a8ONfBdanJtlJ39WWnH1w]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 1 nested variant label to be visible") } - func testDisplaysLevel2NestedVariant() { - scrollToElement(testId: "content-entry-uaNY4YJ0HFPAX3gKXiRdX", - scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-uaNY4YJ0HFPAX3gKXiRdX"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("level 2") || entry.label.contains("nested"), - "Expected level 2 nested variant content, got: \(entry.label)") + func testShouldDisplayLevel2NestedVariantForReturnVisitors() { + waitForElement(app.otherElements["entry-text-4hDiXxYEFrXHXcQgmdL9Uv"], + timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let expected = "This is a level 2 nested variant entry. [Entry: 4hDiXxYEFrXHXcQgmdL9Uv]" + XCTAssertTrue(app.otherElements[expected].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 2 nested variant label to be visible") } } diff --git a/implementations/ios-sdk/uitests/Tests/LiveUpdatesTests.swift b/implementations/ios-sdk/uitests/Tests/LiveUpdatesTests.swift index ea20519a..bfaea237 100644 --- a/implementations/ios-sdk/uitests/Tests/LiveUpdatesTests.swift +++ b/implementations/ios-sdk/uitests/Tests/LiveUpdatesTests.swift @@ -1,5 +1,11 @@ import XCTest +/// Pattern that the `*-entry-id` Text nodes render via LiveUpdatesEntryDisplay. +/// The id segment after "Entry: " is a Contentful sys.id (alphanumeric, no spaces). +/// Asserting against this proves the SDK actually resolved an entry rather than +/// rendering an empty/default state. +private let ENTRY_ID_TEXT_PATTERN = #/^Entry: [a-zA-Z0-9]+$/# + final class LiveUpdatesTests: XCTestCase { let app = XCUIApplication() @@ -11,7 +17,7 @@ final class LiveUpdatesTests: XCTestCase { waitForElement(app.buttons["live-updates-test-button"]) app.buttons["live-updates-test-button"].tap() - waitForElement(app.otherElements["default-personalization"]) + waitForElement(app.otherElements["default-optimization"]) } override func tearDown() { @@ -24,13 +30,25 @@ final class LiveUpdatesTests: XCTestCase { // MARK: - Default behavior (locked on first value) - func testDefaultDoesNotUpdateOnIdentify() { + func testDefaultDoesNotUpdateOnIdentifyGlobalLiveUpdatesFalse() { + // Capture the SDK-resolved entry id for both the locked default section + // and the always-live reference section. waitForElement(app.staticTexts["default-entry-id"]) - let initialText = getElementTextById("default-entry-id", app: app) + waitForElement(app.staticTexts["live-entry-id"]) + + let initialDefaultEntryIdText = getElementTextById("default-entry-id", app: app) + let initialLiveEntryIdText = getElementTextById("live-entry-id", app: app) app.buttons["live-updates-identify-button"].tap() waitForTextEquals("identified-status", expected: "Yes", app: app) - waitForTextEquals("default-entry-id", expected: initialText, app: app) + + // The live-prefixed section has liveUpdates=true, so it MUST re-resolve + // to a different variant after identify. Without this proof, the + // "locked stays locked" assertion below would be meaningless. + waitForTextChange("live-entry-id", baseline: initialLiveEntryIdText, app: app) + + // Default section inherits the global setting (off), so the lock must hold. + waitForTextEquals("default-entry-id", expected: initialDefaultEntryIdText, app: app) } // MARK: - Global liveUpdates enabled @@ -40,10 +58,12 @@ final class LiveUpdatesTests: XCTestCase { waitForTextEquals("global-live-updates-status", expected: "ON", app: app) waitForElement(app.staticTexts["default-entry-id"]) + let initialDefaultEntryIdText = getElementTextById("default-entry-id", app: app) + app.buttons["live-updates-identify-button"].tap() waitForTextEquals("identified-status", expected: "Yes", app: app) - XCTAssertTrue(app.staticTexts["default-entry-id"].exists) + waitForTextChange("default-entry-id", baseline: initialDefaultEntryIdText, app: app) } func testLockedComponentsIgnoreGlobalLiveUpdates() { @@ -51,11 +71,21 @@ final class LiveUpdatesTests: XCTestCase { waitForTextEquals("global-live-updates-status", expected: "ON", app: app) waitForElement(app.staticTexts["locked-entry-id"]) - let initialText = getElementTextById("locked-entry-id", app: app) + waitForElement(app.staticTexts["default-entry-id"]) + + let initialLockedEntryIdText = getElementTextById("locked-entry-id", app: app) + let initialDefaultEntryIdText = getElementTextById("default-entry-id", app: app) app.buttons["live-updates-identify-button"].tap() waitForTextEquals("identified-status", expected: "Yes", app: app) - waitForTextEquals("locked-entry-id", expected: initialText, app: app) + + // With global=ON the default section (no per-component prop) MUST + // re-resolve. This is the live-reference that proves the SDK is + // actually swapping variants. + waitForTextChange("default-entry-id", baseline: initialDefaultEntryIdText, app: app) + + // Locked section has liveUpdates=false, so it must stay at its captured id. + waitForTextEquals("locked-entry-id", expected: initialLockedEntryIdText, app: app) } // MARK: - Per-component liveUpdates=true @@ -64,10 +94,12 @@ final class LiveUpdatesTests: XCTestCase { waitForTextEquals("global-live-updates-status", expected: "OFF", app: app) waitForElement(app.staticTexts["live-entry-id"]) + let initialLiveEntryIdText = getElementTextById("live-entry-id", app: app) + app.buttons["live-updates-identify-button"].tap() waitForTextEquals("identified-status", expected: "Yes", app: app) - XCTAssertTrue(app.staticTexts["live-entry-id"].exists) + waitForTextChange("live-entry-id", baseline: initialLiveEntryIdText, app: app) } // MARK: - Per-component liveUpdates=false @@ -77,11 +109,20 @@ final class LiveUpdatesTests: XCTestCase { waitForTextEquals("global-live-updates-status", expected: "ON", app: app) waitForElement(app.staticTexts["locked-entry-id"]) - let initialText = getElementTextById("locked-entry-id", app: app) + waitForElement(app.staticTexts["live-entry-id"]) + + let initialLockedEntryIdText = getElementTextById("locked-entry-id", app: app) + let initialLiveEntryIdText = getElementTextById("live-entry-id", app: app) app.buttons["live-updates-identify-button"].tap() waitForTextEquals("identified-status", expected: "Yes", app: app) - waitForTextEquals("locked-entry-id", expected: initialText, app: app) + + // Live section (per-component liveUpdates=true) MUST change — the SDK is + // re-resolving on identify. The per-component prop is the path under + // test: it must override the global=true setting and keep the locked + // section stable. + waitForTextChange("live-entry-id", baseline: initialLiveEntryIdText, app: app) + waitForTextEquals("locked-entry-id", expected: initialLockedEntryIdText, app: app) } // MARK: - Preview panel simulation @@ -95,12 +136,19 @@ final class LiveUpdatesTests: XCTestCase { waitForElement(app.staticTexts["live-entry-id"]) waitForElement(app.staticTexts["locked-entry-id"]) + let initialDefaultEntryIdText = getElementTextById("default-entry-id", app: app) + let initialLiveEntryIdText = getElementTextById("live-entry-id", app: app) + let initialLockedEntryIdText = getElementTextById("locked-entry-id", app: app) + app.buttons["live-updates-identify-button"].tap() waitForTextEquals("identified-status", expected: "Yes", app: app) - XCTAssertTrue(app.staticTexts["default-entry-id"].exists) - XCTAssertTrue(app.staticTexts["live-entry-id"].exists) - XCTAssertTrue(app.staticTexts["locked-entry-id"].exists) + // While the preview panel is open, the SDK forces shouldLiveUpdate=true + // for ALL sections, including the per-component liveUpdates=false one. + // All three resolved variants must change. + waitForTextChange("default-entry-id", baseline: initialDefaultEntryIdText, app: app) + waitForTextChange("live-entry-id", baseline: initialLiveEntryIdText, app: app) + waitForTextChange("locked-entry-id", baseline: initialLockedEntryIdText, app: app) } // MARK: - Screen controls @@ -129,20 +177,59 @@ final class LiveUpdatesTests: XCTestCase { waitForTextEquals("identified-status", expected: "No", app: app) } - // MARK: - Three Personalization sections display + // MARK: - Three Optimization sections display + + func testDisplaysAllThreeOptimizationEntrySections() { + waitForElement(app.otherElements["default-optimization"]) + waitForElement(app.otherElements["live-optimization"]) + waitForElement(app.otherElements["locked-optimization"]) + + // Section wrappers render unconditionally — proving they're mounted is + // just a smoke check. The SDK responsibility is to feed each section a + // resolved entry whose sys.id surfaces in the entry-id Text. + waitForElement(app.staticTexts["default-entry-id"]) + waitForElement(app.staticTexts["live-entry-id"]) + waitForElement(app.staticTexts["locked-entry-id"]) + + let defaultEntryIdText = getElementTextById("default-entry-id", app: app) + let liveEntryIdText = getElementTextById("live-entry-id", app: app) + let lockedEntryIdText = getElementTextById("locked-entry-id", app: app) - func testDisplaysAllThreePersonalizationComponents() { - waitForElement(app.otherElements["default-personalization"]) - waitForElement(app.otherElements["live-personalization"]) - waitForElement(app.otherElements["locked-personalization"]) + XCTAssertNotNil(try? ENTRY_ID_TEXT_PATTERN.wholeMatch(in: defaultEntryIdText), + "default-entry-id \"\(defaultEntryIdText)\" did not match ENTRY_ID_TEXT_PATTERN") + XCTAssertNotNil(try? ENTRY_ID_TEXT_PATTERN.wholeMatch(in: liveEntryIdText), + "live-entry-id \"\(liveEntryIdText)\" did not match ENTRY_ID_TEXT_PATTERN") + XCTAssertNotNil(try? ENTRY_ID_TEXT_PATTERN.wholeMatch(in: lockedEntryIdText), + "locked-entry-id \"\(lockedEntryIdText)\" did not match ENTRY_ID_TEXT_PATTERN") } func testDisplaysEntryContentInAllSections() { - waitForElement(app.staticTexts["default-text"]) - waitForElement(app.staticTexts["live-text"]) - waitForElement(app.staticTexts["locked-text"]) - XCTAssertTrue(app.staticTexts["default-entry-id"].exists) - XCTAssertTrue(app.staticTexts["live-entry-id"].exists) - XCTAssertTrue(app.staticTexts["locked-entry-id"].exists) + // iOS collapses the RN `*-container` wrapper into the section's + // accessibility container `*-optimization` — SwiftUI cannot expose a + // nested accessibility container inside another. The `*-text` assertions + // below carry the real entry-content verification. + waitForElement(app.otherElements["default-optimization"]) + waitForElement(app.otherElements["live-optimization"]) + waitForElement(app.otherElements["locked-optimization"]) + + let defaultText = getElementTextById("default-text", app: app) + let liveText = getElementTextById("live-text", app: app) + let lockedText = getElementTextById("locked-text", app: app) + + // LiveUpdatesEntryDisplay falls back to 'No content' when the resolved + // entry has no text field — a non-empty text that isn't the fallback + // proves the SDK fed a real field value. + XCTAssertGreaterThan(defaultText.count, 0) + XCTAssertGreaterThan(liveText.count, 0) + XCTAssertGreaterThan(lockedText.count, 0) + XCTAssertNotEqual(defaultText, "No content") + XCTAssertNotEqual(liveText, "No content") + XCTAssertNotEqual(lockedText, "No content") + + // All three sections wrap the same Contentful entry, so before any + // identify/toggle/preview-panel actions they MUST resolve to the same + // variant. + XCTAssertEqual(defaultText, liveText) + XCTAssertEqual(defaultText, lockedText) } } diff --git a/implementations/ios-sdk/uitests/Tests/OfflineBehaviorTests.swift b/implementations/ios-sdk/uitests/Tests/OfflineBehaviorTests.swift index 375b9186..2ad3c37d 100644 --- a/implementations/ios-sdk/uitests/Tests/OfflineBehaviorTests.swift +++ b/implementations/ios-sdk/uitests/Tests/OfflineBehaviorTests.swift @@ -1,95 +1,196 @@ import XCTest -/// Tests offline behavior using network simulation via launch arguments. +/// Tests offline behavior using runtime network simulation. /// -/// The app checks for `--simulate-offline` and `--simulate-online` launch arguments -/// to toggle `client.setOnline()`. Since XCUITest cannot toggle network state at runtime -/// without relaunching, these tests verify offline behavior across app launches. +/// The RN/Detox suite toggles real network state with adb airplane mode. XCUITest +/// cannot toggle network at runtime, so the app exposes test-only `Go Offline` / +/// `Go Online` controls (gated behind `--enable-network-controls`) that call +/// `client.setOnline(false/true)` on the live process. Toggling online state on a +/// running app — rather than relaunching — keeps the in-memory Experience queue +/// intact across the offline/online transition, exactly as a real airplane-mode +/// toggle would, so a queued offline identify can genuinely flush on reconnect. +/// +/// Each test preserves the hardened pseudocode contract: identify across an +/// offline/online cycle, relaunch, then gate on the SDK resolving the IDENTIFIED +/// nested variant entry — a no-op SDK cannot pass. final class OfflineBehaviorTests: XCTestCase { let app = XCUIApplication() + // Time allowed after reconnecting for the SDK online signal to flip and the + // resulting Experience API queue flush to land before the app is terminated. + let QUEUE_FLUSH_GRACE_MS = 10000 + + // Time allowed after an online identify for the Experience upsert round-trip + // to complete before the app is terminated. + let IDENTIFY_SETTLE_MS = 3000 + + // Timeout for the post-relaunch variant assertions, generous enough for a + // cold start to boot, fetch entries, and run resolution. + let POST_RELAUNCH_TIMEOUT: TimeInterval = 30.0 + + // Nested level-0 entry id that only appears once the SDK resolves the + // identified profile. + let NESTED_VARIANT_TEST_ID = "entry-text-2KIWllNZJT205BwOSkMINg" + + // Nested level-0 entry id that only appears for an anonymous profile. + let NESTED_BASELINE_TEST_ID = "entry-text-1JAU028vQ7v6nB2swl3NBo" + override func setUp() { continueAfterFailure = false - app.launchArguments = [] + // Launch from clean storage (`--reset`) with the test-only network + // controls enabled. `--reset` guarantees a true anonymous starting + // profile; each test identifies and leaves the app identified, so the + // clean start matters for the next test's baseline resolution. + app.launchArguments = ["--reset", "--enable-network-controls"] app.launch() - clearProfileState(app: app) + waitForElement(app.buttons["identify-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) } - override func tearDown() { - // Ensure app is restored to normal state - app.launchArguments = [] + /// Read the `events-count` element text and parse the integer event count. + private func getEventsCount() -> Int { + return parseEventsCount(getElementTextById("events-count", app: app)) + } + + /// Take the live app offline by tapping the test-only `Go Offline` control. + /// The process stays alive, so the in-memory Experience queue survives. + private func goOffline() { + let button = app.buttons["simulate-offline-button"] + waitForElement(button, timeout: ELEMENT_VISIBILITY_TIMEOUT) + button.tap() + } + + /// Bring the live app back online by tapping the test-only `Go Online` + /// control, which flips the SDK online signal and flushes queued events. + private func goOnline() { + let button = app.buttons["simulate-online-button"] + waitForElement(button, timeout: ELEMENT_VISIBILITY_TIMEOUT) + button.tap() } func testContinuesToTrackEventsWhileOffline() { - waitForElement(app.staticTexts["Analytics Events"]) + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) waitForEventsCountAtLeast(1, app: app) - let eventsTextBefore = getElementTextById("events-count", app: app) - let countBefore = parseEventsCount(eventsTextBefore) + // Go offline. + goOffline() + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + let eventsBeforeIdentify = getEventsCount() - // Tap identify which generates analytics events even offline + // Trigger an identify that generates an Experience API event. app.buttons["identify-button"].tap() - // Verify event counter increased - _ = waitForElementText("events-count", app: app) { text in - parseEventsCount(text) >= countBefore + 1 + // The SDK must still emit the identify event to its in-process + // eventStream while offline — the analytics counter advances offline. + _ = waitForElementText("events-count", app: app, timeout: ELEMENT_VISIBILITY_TIMEOUT) { text in + parseEventsCount(text) >= eventsBeforeIdentify + 1 } - } - - func testRecoverGracefullyWhenNetworkRestored() { - waitForElement(app.staticTexts["Analytics Events"]) - // Simulate offline by relaunching with flag + // Counter emission alone is not proof the event was retained. Reconnect + // so the Experience queue flushes, give the flush round-trip time to + // land, then relaunch. The identified-only nested variant id can only + // resolve if the offline identify was genuinely queued and delivered. + goOnline() + Thread.sleep(forTimeInterval: Double(QUEUE_FLUSH_GRACE_MS) / 1000.0) app.terminate() - app.launchArguments = ["--simulate-offline"] + app.launchArguments = [] app.launch() - waitForElement(app.staticTexts["Analytics Events"]) + waitForElement(findElement(NESTED_VARIANT_TEST_ID, app: app), timeout: POST_RELAUNCH_TIMEOUT) + XCTAssertFalse(findElement(NESTED_BASELINE_TEST_ID, app: app).exists, + "Baseline nested entry \(NESTED_BASELINE_TEST_ID) should not exist after identified flush") + } + + func testRecoverGracefullyWhenNetworkRestored() { + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // Take the SDK through an offline -> online transition. + goOffline() + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) Thread.sleep(forTimeInterval: 1.0) + goOnline() - // Simulate reconnect + // Real proof of recovery is that the SDK can still complete an + // end-to-end identify pipeline after the blip. The identify runs while + // online, so let the connectivity transition settle first. + Thread.sleep(forTimeInterval: Double(IDENTIFY_SETTLE_MS) / 1000.0) + app.buttons["identify-button"].tap() + waitForElement(app.buttons["reset-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // Give the Experience upsert round-trip time to land, then relaunch to + // observe the resolved profile. + Thread.sleep(forTimeInterval: Double(IDENTIFY_SETTLE_MS) / 1000.0) app.terminate() app.launchArguments = [] app.launch() - // App should be functional - waitForElement(app.staticTexts["Analytics Events"]) - waitForElement(app.buttons["identify-button"]) + waitForElement(findElement(NESTED_VARIANT_TEST_ID, app: app), timeout: POST_RELAUNCH_TIMEOUT) + XCTAssertFalse(findElement(NESTED_BASELINE_TEST_ID, app: app).exists, + "Baseline nested entry \(NESTED_BASELINE_TEST_ID) should not exist after recovery identify") } func testHandleRapidNetworkStateChanges() { - waitForElement(app.staticTexts["Analytics Events"]) - - // Rapid offline/online cycles via relaunches - app.terminate() - app.launchArguments = ["--simulate-offline"] - app.launch() - waitForElement(app.staticTexts["Analytics Events"], timeout: EXTENDED_TIMEOUT) + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // Rapidly toggle network state, ending online. + goOffline() + Thread.sleep(forTimeInterval: 0.5) + goOnline() + Thread.sleep(forTimeInterval: 0.5) + goOffline() + Thread.sleep(forTimeInterval: 0.5) + goOnline() + + // Prove the SDK is still fully operational after the churn: a complete + // identify pipeline must still resolve the identified-only nested + // variant after relaunch. A wedged SDK would resolve the baseline. + Thread.sleep(forTimeInterval: Double(IDENTIFY_SETTLE_MS) / 1000.0) + app.buttons["identify-button"].tap() + waitForElement(app.buttons["reset-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + Thread.sleep(forTimeInterval: Double(IDENTIFY_SETTLE_MS) / 1000.0) app.terminate() app.launchArguments = [] app.launch() - waitForElement(app.staticTexts["Analytics Events"], timeout: EXTENDED_TIMEOUT) - // App should remain stable - waitForElement(app.buttons["identify-button"]) + waitForElement(findElement(NESTED_VARIANT_TEST_ID, app: app), timeout: POST_RELAUNCH_TIMEOUT) + XCTAssertFalse(findElement(NESTED_BASELINE_TEST_ID, app: app).exists, + "Baseline nested entry \(NESTED_BASELINE_TEST_ID) should not exist after rapid toggles") } func testQueueEventsOfflineAndFlushWhenOnline() { - waitForElement(app.staticTexts["Analytics Events"]) + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) waitForEventsCountAtLeast(1, app: app) - let countBefore = parseEventsCount(getElementTextById("events-count", app: app)) + // Go offline. + goOffline() + waitForElement(app.staticTexts["Analytics Events"], timeout: ELEMENT_VISIBILITY_TIMEOUT) - // Trigger identify which creates events + let eventsBeforeIdentify = getEventsCount() + + // Trigger identify, which creates an Experience API event. app.buttons["identify-button"].tap() - // Verify event counter increased - _ = waitForElementText("events-count", app: app) { text in - parseEventsCount(text) >= countBefore + 1 + // Verify event counter increased while still offline. + _ = waitForElementText("events-count", app: app, timeout: ELEMENT_VISIBILITY_TIMEOUT) { text in + parseEventsCount(text) >= eventsBeforeIdentify + 1 } - // After identify, reset button should be visible - waitForElement(app.buttons["reset-button"]) + // Go back online so the offline Experience queue flushes, and give the + // flush round-trip time to reach the server before the app is killed. + goOnline() + Thread.sleep(forTimeInterval: Double(QUEUE_FLUSH_GRACE_MS) / 1000.0) + + // Relaunch and verify the queued-then-flushed identify took effect end + // to end: the identified-only nested variant resolves and `reset-button` + // renders only for a rehydrated identified profile. + app.terminate() + app.launchArguments = [] + app.launch() + + waitForElement(findElement(NESTED_VARIANT_TEST_ID, app: app), timeout: POST_RELAUNCH_TIMEOUT) + XCTAssertFalse(findElement(NESTED_BASELINE_TEST_ID, app: app).exists, + "Baseline nested entry \(NESTED_BASELINE_TEST_ID) should not exist after queued flush") + waitForElement(app.buttons["reset-button"], timeout: POST_RELAUNCH_TIMEOUT) } } diff --git a/implementations/ios-sdk/uitests/Tests/PreviewPanelOverridesTests.swift b/implementations/ios-sdk/uitests/Tests/PreviewPanelOverridesTests.swift index b10d9e01..4428a6d4 100644 --- a/implementations/ios-sdk/uitests/Tests/PreviewPanelOverridesTests.swift +++ b/implementations/ios-sdk/uitests/Tests/PreviewPanelOverridesTests.swift @@ -1,10 +1,28 @@ import XCTest -/// Cross-platform preview-panel override scenarios (iOS side). +/// 1:1 port of `preview-panel-overrides.test.js`. /// -/// Scenarios mirror `implementations/PREVIEW_PANEL_SCENARIOS.md` and the RN -/// Detox suite `preview-panel-overrides.test.js`. Keep test names and fixture -/// IDs identical across platforms so cross-platform regressions are visible. +/// The preview panel lets developers override audience membership and +/// experience variant selection at runtime so they can preview each variant +/// without changing the underlying visitor profile. This suite verifies that +/// an audience override can activate an unqualified audience or deactivate a +/// qualified one, that a per-experience variant index can be forced, that +/// overrides can be reset individually or in bulk, that audience overrides +/// survive an in-panel API refresh, and that a cold relaunch with cleared +/// storage wipes all overrides so the identified-visitor baseline renders +/// again. +/// +/// Scenario names, accessibility identifiers, expected text, and ordering +/// mirror the RN Detox suite and the platform-agnostic +/// `preview-panel-overrides-pseudocode.md` contract. Two scenarios use an +/// iOS-correct mechanism for a step the contract describes generically: +/// +/// - Scenario 5: the per-experience reset (`reset-variant-`) is a plain +/// row-action button on iOS (no confirmation alert), so iOS taps it +/// directly. RN wraps the same action in `Alert.alert`. +/// - Scenario 6: the reset-all control (`reset-all-overrides`) triggers a +/// native `.alert` on iOS, so iOS confirms via `app.alerts.buttons["Reset"]` +/// rather than the RN inline `reset-all-confirm` view. final class PreviewPanelOverridesTests: XCTestCase { let app = XCUIApplication() @@ -13,112 +31,265 @@ final class PreviewPanelOverridesTests: XCTestCase { static let VARIANT_ENTRY_ID = "5a8ONfBdanJtlJ39WWnH1w" static let BASELINE_ENTRY_ID = "5i4SdJXw9oDEY0vgO7CwF4" + // Scenario 1 reuses the Mobile Browser audience, which the identified user + // does NOT qualify for. The associated experience is the first entry in the + // `nt_experiences` array of baseline xFwgG3oNaOcjzWiGe4vXo, so + // `OptimizedEntryResolver` picks it deterministically once activated. + // xFwgG3oNaOcjzWiGe4vXo renders through the demo app's top-level content + // entry, whose identifier is keyed on the *original* entry id and stays + // constant across resolution — so scenario 1 asserts on the resolved + // entry's accessibility label (variant text + original baseline id). + static let UNQUALIFIED_AUDIENCE_ID = "3MRuZPQ5EdwDqzUDRgOo7c" + static let MOBILE_VARIANT_LABEL = + "This is a variant content entry for visitors using a mobile browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + override func setUp() { continueAfterFailure = false - app.launch() + // Relaunch the app as a new instance with fresh storage so prior modal + // and override state cannot leak in. + app.relaunchClean() clearProfileState(app: app) - identifyAndWaitForEntries() + identifyAndRelaunch() } - // MARK: - Helpers + // MARK: - Local helpers - private func identifyAndWaitForEntries() { + /// Identifies the visitor, then relaunches so the identified-visitor mock + /// payload is re-fetched on a fresh app start. + private func identifyAndRelaunch() { let identifyButton = app.buttons["identify-button"] - waitForElement(identifyButton) + waitForElement(identifyButton, timeout: ELEMENT_VISIBILITY_TIMEOUT) identifyButton.tap() - waitForElement(app.buttons["reset-button"]) + waitForElement(app.buttons["reset-button"], timeout: ELEMENT_VISIBILITY_TIMEOUT) + + // Terminate + relaunch so the identified-visitor mock payload is + // re-fetched on a fresh start. Clear `--reset` first: `relaunchClean()` + // (run in `setUp`) leaves it set, and relaunching with it here would + // wipe the identified profile this helper just persisted. + app.terminate() + app.launchArguments = [] + app.launch() // Identified-visitor profile should render variant entries by default. - let variantEntry = findElement("entry-text-\(Self.VARIANT_ENTRY_ID)", app: app) - XCTAssertTrue(variantEntry.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), - "Expected variant entry to render after identify") + XCTAssertTrue( + app.otherElements["entry-text-\(Self.VARIANT_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "entry-text-\(Self.VARIANT_ENTRY_ID) missing — identified-visitor variant never rendered") } + /// Opens the preview panel modal from the floating action button. private func openPanel() { let fab = app.buttons["preview-panel-fab"] - waitForElement(fab) + XCTAssertTrue(fab.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "preview-panel-fab did not appear") fab.tap() XCTAssertTrue(app.staticTexts["Preview Panel"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), "Preview Panel did not appear") + waitForDefinitionsLoaded() } - private func closePanel() { - // The panel sheet is dismissed via the drag handle or close button; - // sheets can also be dismissed by swiping down on the header. - let dismissGesture = app.navigationBars.buttons.firstMatch - if dismissGesture.exists { - dismissGesture.tap() - return + /// `loadDefinitions()` runs async after `openPanel()`. Until it finishes + /// the audience section is empty (and the audience-toggle / variant-picker + /// buttons are absent from the accessibility tree). Wait for the + /// "Loading definitions..." status text to disappear before interacting. + private func waitForDefinitionsLoaded() { + let loadingText = app.staticTexts["Loading definitions..."] + let deadline = Date().addingTimeInterval(EXTENDED_TIMEOUT) + while Date() < deadline { + if !loadingText.exists { return } + Thread.sleep(forTimeInterval: 0.15) + } + XCTFail("Definitions did not finish loading within \(EXTENDED_TIMEOUT)s") + } + + /// Scrolls the preview panel's internal scroll view until the requested + /// element is visible and hittable. The panel is tall and the target + /// audiences and controls typically sit below the fold. + private func scrollPanelToId(_ identifier: String) { + let panel = app.scrollViews["preview-panel-list"] + if !panel.exists { return } + for _ in 0..<10 { + let target = findElement(identifier, app: app) + if target.exists && target.isHittable { return } + panel.swipeUp() } - // Fallback: swipe down from top of sheet to dismiss. - app.swipeDown() } - private func assertEntryVisible(_ entryId: String, message: String) { - let entry = findElement("entry-text-\(entryId)", app: app) - XCTAssertTrue(entry.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), message) + /// Dismisses the preview panel modal. The panel is a SwiftUI sheet with no + /// hardware back and no close button, so it is dismissed with a downward + /// swipe from the top of the sheet. + private func closePanel() { + app.swipeDown() } // MARK: - Scenarios + /// Scenario 1: turning on an audience that the identified visitor does not + /// qualify for activates an experience whose variant content then renders + /// on screen. + func testScenario1ActivatingUnqualifiedAudienceRendersItsVariant() { + openPanel() + let toggleId = "audience-toggle-\(Self.UNQUALIFIED_AUDIENCE_ID)-on" + scrollPanelToId(toggleId) + app.buttons[toggleId].tap() + closePanel() + + XCTAssertTrue(app.otherElements[Self.MOBILE_VARIANT_LABEL].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected mobile variant content after activating Mobile Browser audience") + } + + /// Scenario 2: turning off an audience the identified visitor does qualify + /// for forces the experience to fall back to its baseline entry. func testScenario2DeactivatingQualifiedAudienceRendersBaseline() { openPanel() - let toggle = app.buttons["audience-toggle-\(Self.AUDIENCE_ID)-off"] - XCTAssertTrue(toggle.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), - "Off toggle not found for audience") - toggle.tap() + let toggleId = "audience-toggle-\(Self.AUDIENCE_ID)-off" + scrollPanelToId(toggleId) + app.buttons[toggleId].tap() closePanel() - assertEntryVisible(Self.BASELINE_ENTRY_ID, - message: "Expected baseline entry after deactivating audience") + XCTAssertTrue( + app.otherElements["entry-text-\(Self.BASELINE_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected baseline entry after deactivating qualified audience") } + /// Scenario 3: after deactivating a qualified audience, tapping the + /// audience's default toggle removes the override and restores the original + /// variant resolution. func testScenario3ResettingAudienceOverrideRestoresVariant() { - // Set up by first deactivating, then resetting to default. openPanel() - app.buttons["audience-toggle-\(Self.AUDIENCE_ID)-off"].tap() + let offId = "audience-toggle-\(Self.AUDIENCE_ID)-off" + scrollPanelToId(offId) + app.buttons[offId].tap() app.buttons["audience-toggle-\(Self.AUDIENCE_ID)-default"].tap() closePanel() - assertEntryVisible(Self.VARIANT_ENTRY_ID, - message: "Expected variant entry after resetting audience override") + XCTAssertTrue( + app.otherElements["entry-text-\(Self.VARIANT_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected variant entry after resetting audience override") } + /// Scenario 4: explicitly picking the index-0 (baseline) variant for an + /// experience forces that experience to render its baseline entry, even + /// when the visitor qualifies for a non-baseline variant. func testScenario4SettingVariantOverrideToZeroRendersBaseline() { openPanel() - let picker = app.buttons["variant-picker-\(Self.EXPERIENCE_ID)-0"] - XCTAssertTrue(picker.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), - "Variant picker baseline option not found") - picker.tap() + // The audience must be expanded for its experience variant picker to + // mount; scrolling the off toggle into view also brings the audience + // row that owns the experience into view. + scrollPanelToId("audience-toggle-\(Self.AUDIENCE_ID)-off") + // Tap the audience header row to expand experiences. + app.staticTexts["Identified Users"].tap() + let pickerId = "variant-picker-\(Self.EXPERIENCE_ID)-0" + scrollPanelToId(pickerId) + app.buttons[pickerId].tap() + closePanel() + + XCTAssertTrue( + app.otherElements["entry-text-\(Self.BASELINE_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected baseline entry after setting variant override to 0") + } + + /// Scenario 5: after forcing a variant override, tapping the per-experience + /// reset control removes only that override and restores the original + /// variant resolution. The iOS panel applies the reset synchronously — the + /// `reset-variant-` control is a plain row-action button with no + /// confirmation alert (unlike RN's `Alert.alert`). + func testScenario5ResettingSingleVariantOverrideRestoresVariant() { + // Drive scenario 4 first so a variant override exists in the Overrides + // section. + openPanel() + scrollPanelToId("audience-toggle-\(Self.AUDIENCE_ID)-off") + app.staticTexts["Identified Users"].tap() + let pickerId = "variant-picker-\(Self.EXPERIENCE_ID)-0" + scrollPanelToId(pickerId) + app.buttons[pickerId].tap() + + // Tap the per-experience reset. + let resetId = "reset-variant-\(Self.EXPERIENCE_ID)" + scrollPanelToId(resetId) + app.buttons[resetId].tap() closePanel() - assertEntryVisible(Self.BASELINE_ENTRY_ID, - message: "Expected baseline after variant-0 override") + XCTAssertTrue( + app.otherElements["entry-text-\(Self.VARIANT_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected variant entry after resetting single variant override") } + /// Scenario 6: after forcing a variant override, tapping the panel's + /// reset-all control and confirming clears every override and restores the + /// original variant resolution. On iOS the reset-all confirmation is a + /// native `.alert`, so confirmation taps `app.alerts.buttons["Reset"]`. func testScenario6ResetAllRestoresVariantContent() { - // Apply a variant override, then reset all. openPanel() - app.buttons["variant-picker-\(Self.EXPERIENCE_ID)-0"].tap() - let resetAll = app.buttons["reset-all-overrides"] - XCTAssertTrue(resetAll.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), - "Reset-all button not found") - resetAll.tap() + scrollPanelToId("audience-toggle-\(Self.AUDIENCE_ID)-off") + app.staticTexts["Identified Users"].tap() + let pickerId = "variant-picker-\(Self.EXPERIENCE_ID)-0" + scrollPanelToId(pickerId) + app.buttons[pickerId].tap() + + scrollPanelToId("reset-all-overrides") + app.buttons["reset-all-overrides"].tap() - // Confirm the alert. + // Confirm the native alert. let resetButton = app.alerts.buttons["Reset"] XCTAssertTrue(resetButton.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), "Reset confirmation button not found") resetButton.tap() closePanel() - assertEntryVisible(Self.VARIANT_ENTRY_ID, - message: "Expected variant entry after reset-all") + XCTAssertTrue( + app.otherElements["entry-text-\(Self.VARIANT_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected variant entry after reset-all") + } + + /// Scenario 7: deactivating an audience and then triggering the in-panel + /// refresh (which re-hits the experience API) keeps the audience override + /// in place so the experience still resolves to its baseline. + func testScenario7OverrideSurvivesAPIRefresh() { + openPanel() + let offId = "audience-toggle-\(Self.AUDIENCE_ID)-off" + scrollPanelToId(offId) + app.buttons[offId].tap() + + let refreshId = "preview-refresh-button" + scrollPanelToId(refreshId) + app.buttons[refreshId].tap() + closePanel() + + XCTAssertTrue( + app.otherElements["entry-text-\(Self.BASELINE_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected baseline still rendering after API refresh") } - // TODO: scenarios 1, 5, 7, 8 — see implementations/PREVIEW_PANEL_SCENARIOS.md. - // - 1 (activate unqualified audience) requires a mock audience the identified user does not qualify for. - // - 5 (reset single variant override) taps the per-item reset button in the Overrides section. - // - 7 (override survives API refresh) drives preview-refresh-button between open/close. - // - 8 (destroy/remount) uses app.terminate() + app.launch(). + /// Scenario 8: a cold relaunch with cleared storage discards all overrides + /// — the variant renders again and the overrides section reports that none + /// remain. + func testScenario8DestroyRemountClearsOverrides() { + openPanel() + let offId = "audience-toggle-\(Self.AUDIENCE_ID)-off" + scrollPanelToId(offId) + app.buttons[offId].tap() + closePanel() + + XCTAssertTrue( + app.otherElements["entry-text-\(Self.BASELINE_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected baseline after deactivating audience (pre-relaunch)") + + // Cold relaunch with fresh storage, then re-identify and rehydrate. + app.relaunchClean() + identifyAndRelaunch() + + // Override must be gone — variant renders again. + XCTAssertTrue( + app.otherElements["entry-text-\(Self.VARIANT_ENTRY_ID)"].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected variant entry after destroy/remount cleared overrides") + + // The Overrides section should show its empty state. The empty-state + // text sits below the fold, so scrolling the reset-all control into + // view also pulls the Overrides section into the viewport. + openPanel() + scrollPanelToId("reset-all-overrides") + XCTAssertTrue(app.staticTexts["No active overrides"].exists, + "Expected 'No active overrides' empty-state text in Overrides section") + closePanel() + } } diff --git a/implementations/ios-sdk/uitests/Tests/ScreenTrackingTests.swift b/implementations/ios-sdk/uitests/Tests/ScreenTrackingTests.swift index 0df34f12..8850f67d 100644 --- a/implementations/ios-sdk/uitests/Tests/ScreenTrackingTests.swift +++ b/implementations/ios-sdk/uitests/Tests/ScreenTrackingTests.swift @@ -8,6 +8,15 @@ final class ScreenTrackingTests: XCTestCase { clearProfileState(app: app, requireFreshAppInstance: true) } + /// Reads the `screen-event-log` element and returns its visible text + /// (falling back to its accessibility label) as a string. + private func getScreenEventLogText() -> String { + let element = findElement("screen-event-log", app: app) + XCTAssertTrue(element.waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Element screen-event-log not found") + return extractText(element) + } + func testTrackSingleViewVisit() { waitForElement(app.buttons["navigation-test-button"]) app.buttons["navigation-test-button"].tap() @@ -16,6 +25,8 @@ final class ScreenTrackingTests: XCTestCase { app.buttons["go-to-view-one-button"].tap() waitForElement(app.otherElements["navigation-view-test-one"]) + waitForElement(findElement("screen-event-log", app: app)) + waitForTextEquals("screen-event-log", expected: "NavigationHome,NavigationViewOne", app: app) } @@ -31,17 +42,21 @@ final class ScreenTrackingTests: XCTestCase { app.buttons["go-to-view-two-button"].tap() waitForElement(app.otherElements["navigation-view-test-two"]) + waitForElement(findElement("screen-event-log", app: app)) let logText = waitForElementText("screen-event-log", app: app) { text in text.contains("NavigationViewTwo") } - let viewOneIndex = logText.range(of: "NavigationViewOne") - let viewTwoIndex = logText.range(of: "NavigationViewTwo") + let viewOneIndex = logText.range(of: "NavigationViewOne").map { + logText.distance(from: logText.startIndex, to: $0.lowerBound) + } ?? -1 + let viewTwoIndex = logText.range(of: "NavigationViewTwo").map { + logText.distance(from: logText.startIndex, to: $0.lowerBound) + } ?? -1 - XCTAssertNotNil(viewOneIndex) - XCTAssertNotNil(viewTwoIndex) - XCTAssertLessThan(viewOneIndex!.lowerBound, viewTwoIndex!.lowerBound) + XCTAssertGreaterThanOrEqual(viewOneIndex, 0) + XCTAssertGreaterThan(viewTwoIndex, viewOneIndex) } func testTrackRevisitingViewOneAfterViewTwo() { @@ -57,10 +72,12 @@ final class ScreenTrackingTests: XCTestCase { waitForElement(app.otherElements["navigation-view-test-two"]) - // Press back to return to view one + // Trigger platform back navigation to return to view one app.navigationBars.buttons.element(boundBy: 0).tap() waitForElement(app.otherElements["navigation-view-test-one"]) + waitForElement(findElement("screen-event-log", app: app)) + waitForTextEquals( "screen-event-log", expected: "NavigationHome,NavigationViewOne,NavigationViewTwo,NavigationViewOne", diff --git a/implementations/ios-sdk/uitests/Tests/UnidentifiedVariantsTests.swift b/implementations/ios-sdk/uitests/Tests/UnidentifiedVariantsTests.swift index edae4a3d..35c2471d 100644 --- a/implementations/ios-sdk/uitests/Tests/UnidentifiedVariantsTests.swift +++ b/implementations/ios-sdk/uitests/Tests/UnidentifiedVariantsTests.swift @@ -16,83 +16,212 @@ final class UnidentifiedVariantsTests: XCTestCase { clearProfileState(app: app, requireFreshAppInstance: true) } - func testDisplaysMergeTagEntry() { - // Merge tag entry uses rich text which renders as "No content" in the iOS app - let entry = app.otherElements["content-entry-1MwiFl4z7gkwqGYdvCmr8c"] + // MARK: - Local helper + + /// Drives the unidentified -> identified round-trip the baseline tests rely + /// on. The home-screen OptimizedEntry instances lock on their first resolved + /// value, so a mid-test identify does not re-resolve them; only a relaunch + /// makes the SDK re-run audience evaluation against the now-identified + /// profile. That relaunch is exactly what turns a "baseline rendered" + /// assertion from a no-op-tolerant check into proof the SDK genuinely + /// evaluated the audience. + private func identifyAndRelaunch() { + let identifyButton = app.buttons["identify-button"] + waitForElement(identifyButton) + identifyButton.tap() + waitForElement(app.buttons["reset-button"]) + app.terminate() + app.launchArguments = [] + app.launch() + _ = app.wait(for: .runningForeground, timeout: 10) + } + + // MARK: - common variants + + func testDisplaysMergeTagContentWithResolvedValue() { + let entry = app.otherElements["entry-text-1MwiFl4z7gkwqGYdvCmr8c"] waitForElement(entry) - XCTAssertFalse(entry.label.isEmpty, "Entry should have content") + + // Asserted against the element's label (not via a label subscript) because + // the merge tag label exceeds XCUITest's 128-character identifier limit. + let expectedLabel = "This is a merge tag content entry that displays the visitor's continent \"EU\" embedded within the text. [Entry: 1MwiFl4z7gkwqGYdvCmr8c]" + XCTAssertEqual(entry.label, expectedLabel, + "Expected merge tag content with resolved continent value") } - func testDisplaysContinentBasedEntry() { - let entry = app.otherElements["content-entry-4ib0hsHWoSOnCVdDkizE8d"] + func testDisplaysVariantForVisitorsFromEurope() { + let entry = app.otherElements["entry-text-4ib0hsHWoSOnCVdDkizE8d"] waitForElement(entry) - XCTAssertTrue(entry.label.contains("continent") || entry.label.contains("Europe"), - "Expected continent-based content, got: \(entry.label)") + + let expectedLabel = "This is a variant content entry for visitors from Europe. [Entry: 4ib0hsHWoSOnCVdDkizE8d]" + XCTAssertTrue(app.descendants(matching: .any)[expectedLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected Europe variant content") } - func testDisplaysDeviceBasedEntry() { - let entry = app.otherElements["content-entry-xFwgG3oNaOcjzWiGe4vXo"] + func testDisplaysVariantForDesktopBrowserVisitors() { + let entry = app.otherElements["entry-text-xFwgG3oNaOcjzWiGe4vXo"] waitForElement(entry) - XCTAssertTrue(entry.label.contains("device") || entry.label.contains("desktop"), - "Expected device-based content, got: \(entry.label)") + + let expectedLabel = "This is a variant content entry for visitors using a desktop browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]" + XCTAssertTrue(app.descendants(matching: .any)[expectedLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected desktop-browser variant content") } - func testDisplaysBaselineForNewVisitors() { - let entry = app.otherElements["content-entry-2Z2WLOx07InSewC3LUB3eX"] + // MARK: - unidentified user variants + + func testDisplaysVariantForNewVisitors() { + let entry = app.otherElements["entry-text-2Z2WLOx07InSewC3LUB3eX"] waitForElement(entry) - XCTAssertTrue(entry.label.contains("baseline") || entry.label.contains("new") || entry.label.contains("all"), - "Expected baseline content, got: \(entry.label)") + + let expectedLabel = "This is a variant content entry for new visitors. [Entry: 2Z2WLOx07InSewC3LUB3eX]" + XCTAssertTrue(app.descendants(matching: .any)[expectedLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected new-visitor variant content") } - func testDisplaysABCExperimentEntry() { - let entry = app.otherElements["content-entry-5XHssysWUDECHzKLzoIsg1"] + func testDisplaysVariantBForABCExperiment() { + let entry = app.otherElements["entry-text-5XHssysWUDECHzKLzoIsg1"] waitForElement(entry) - XCTAssertTrue(entry.label.contains("A/B/C") || entry.label.contains("experiment"), - "Expected A/B/C experiment content, got: \(entry.label)") + + let expectedLabel = "This is a variant content entry for an A/B/C experiment: B [Entry: 5XHssysWUDECHzKLzoIsg1]" + XCTAssertTrue(app.descendants(matching: .any)[expectedLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected A/B/C experiment variant B") } - func testDisplaysCustomEventEntry() { - let entry = app.otherElements["content-entry-6zqoWXyiSrf0ja7I2WGtYj"] + func testDisplaysBaselineForVisitorsWithOrWithoutCustomEvent() { + // Unidentified visitor: the custom-event audience is unmatched, so the + // SDK must resolve this entry to its baseline rich-text body. + let entry = app.otherElements["entry-text-6zqoWXyiSrf0ja7I2WGtYj"] waitForElement(entry) - XCTAssertTrue(entry.label.contains("custom event") || entry.label.contains("baseline"), - "Expected custom event entry content, got: \(entry.label)") + + let baselineLabel = "This is a baseline content entry for all visitors with or without a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + XCTAssertTrue(app.descendants(matching: .any)[baselineLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected baseline custom-event content") + + // The baseline label alone is satisfied even by a no-op SDK: the render + // pipeline falls through to the untouched entry whenever no variant is + // selected, so "baseline rendered" and "SDK did nothing" are + // indistinguishable. Identifying must flip the SAME entry to its + // custom-event variant, whose body text exists only in the variant and + // is unreachable without real audience evaluation. Observing the swap + // retroactively proves the unidentified baseline was a genuine SDK + // decision rather than a pipeline artifact. + identifyAndRelaunch() + + let variantLabel = "This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]" + XCTAssertTrue(app.descendants(matching: .any)[variantLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected custom-event variant after identify") + + // The baseline copy must be gone — the SDK replaced the rendered body. + XCTAssertFalse(app.descendants(matching: .any)[baselineLabel].exists, + "Baseline custom-event content should be gone after identify") } - func testDisplaysIdentificationEntry() { - scrollToElement(testId: "content-entry-7pa5bOx8Z9NmNcr7mISvD", + func testDisplaysBaselineForAllIdentifiedOrUnidentifiedUsers() { + // Unidentified visitor: this "all users" experience has no qualifying + // variant for an anonymous profile, so it must render baseline. + scrollToElement(testId: "entry-text-7pa5bOx8Z9NmNcr7mISvD", scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-7pa5bOx8Z9NmNcr7mISvD"] + let entry = app.otherElements["entry-text-7pa5bOx8Z9NmNcr7mISvD"] waitForElement(entry) - XCTAssertTrue(entry.label.contains("identified") || entry.label.contains("baseline"), - "Expected identification entry content, got: \(entry.label)") + + let baselineLabel = "This is a baseline content entry for all identified or unidentified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + XCTAssertTrue(app.descendants(matching: .any)[baselineLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected baseline all-users content") + + // "All users" is the most failure-open audience shape: a no-op SDK + // satisfies the baseline assertion above purely by accident. Identifying + // must flip this entry to its identified-users variant, whose body text + // never appears in the baseline. The swap is the evidence that the + // unidentified baseline was an evaluated outcome, not a fall-through. + identifyAndRelaunch() + + let variantLabel = "This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]" + XCTAssertTrue(app.descendants(matching: .any)[variantLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected identified-users variant after identify") + + XCTAssertFalse(app.descendants(matching: .any)[baselineLabel].exists, + "Baseline all-users content should be gone after identify") } - // MARK: - Nested optimization baselines + // MARK: - nested optimization baselines - func testDisplaysLevel0NestedBaseline() { + func testDisplaysLevel0NestedBaselineForNewVisitors() { + // New (unidentified) visitor: the level-0 nested experience is unmatched, + // so NestedContentEntry keys its content off the baseline entry. scrollToElement(testId: "content-entry-1JAU028vQ7v6nB2swl3NBo", scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-1JAU028vQ7v6nB2swl3NBo"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("level 0") || entry.label.contains("nested") || entry.label.contains("baseline"), - "Expected level 0 nested baseline content, got: \(entry.label)") + waitForElement(app.otherElements["content-entry-1JAU028vQ7v6nB2swl3NBo"]) + + let baselineLabel = "This is a level 0 nested baseline entry. [Entry: 1JAU028vQ7v6nB2swl3NBo]" + XCTAssertTrue(app.descendants(matching: .any)[baselineLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 0 nested baseline content") + + // The nested content is keyed off resolvedEntry.sys.id, so the variant id + // 2KIW... can only enter the tree if the SDK actually selects the level-0 + // variant. A no-op SDK leaves the baseline id in place forever. + // Identifying must surface 2KIW... and retire 1JAU..., proving the + // unidentified baseline render was a real resolution decision rather than + // the entry passing through untouched. + identifyAndRelaunch() + + let variantLabel = "This is a level 0 nested variant entry. [Entry: 2KIWllNZJT205BwOSkMINg]" + scrollToElement(testId: variantLabel, scrollViewId: "main-scroll-view", app: app) + XCTAssertTrue(app.descendants(matching: .any)[variantLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 0 nested variant after identify") + + XCTAssertFalse(app.descendants(matching: .any)[baselineLabel].exists, + "Level 0 nested baseline content should be gone after identify") } - func testDisplaysLevel1NestedBaseline() { + func testDisplaysLevel1NestedBaselineForNewVisitors() { + // New (unidentified) visitor: the level-1 nested experience is unmatched, + // so the resolved content is the baseline entry. scrollToElement(testId: "content-entry-5i4SdJXw9oDEY0vgO7CwF4", scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-5i4SdJXw9oDEY0vgO7CwF4"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("level 1") || entry.label.contains("nested") || entry.label.contains("baseline"), - "Expected level 1 nested baseline content, got: \(entry.label)") + waitForElement(app.otherElements["content-entry-5i4SdJXw9oDEY0vgO7CwF4"]) + + let baselineLabel = "This is a level 1 nested baseline entry. [Entry: 5i4SdJXw9oDEY0vgO7CwF4]" + XCTAssertTrue(app.descendants(matching: .any)[baselineLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 1 nested baseline content") + + // Identifying must re-resolve the level-1 experience to its variant + // (5a8...), an id the host app never fetches directly — it only enters + // the tree when the SDK selects it. The baseline id must disappear, + // confirming the unidentified baseline was an evaluated outcome. + identifyAndRelaunch() + + let variantLabel = "This is a level 1 nested variant entry. [Entry: 5a8ONfBdanJtlJ39WWnH1w]" + scrollToElement(testId: variantLabel, scrollViewId: "main-scroll-view", app: app) + XCTAssertTrue(app.descendants(matching: .any)[variantLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 1 nested variant after identify") + + XCTAssertFalse(app.descendants(matching: .any)[baselineLabel].exists, + "Level 1 nested baseline content should be gone after identify") } - func testDisplaysLevel2NestedBaseline() { + func testDisplaysLevel2NestedBaselineForNewVisitors() { + // New (unidentified) visitor: the deepest nested experience is unmatched, + // so the resolved content is the baseline entry. scrollToElement(testId: "content-entry-uaNY4YJ0HFPAX3gKXiRdX", scrollViewId: "main-scroll-view", app: app) - let entry = app.otherElements["content-entry-uaNY4YJ0HFPAX3gKXiRdX"] - waitForElement(entry) - XCTAssertTrue(entry.label.contains("level 2") || entry.label.contains("nested") || entry.label.contains("baseline"), - "Expected level 2 nested baseline content, got: \(entry.label)") + waitForElement(app.otherElements["content-entry-uaNY4YJ0HFPAX3gKXiRdX"]) + + let baselineLabel = "This is a level 2 nested baseline entry. [Entry: uaNY4YJ0HFPAX3gKXiRdX]" + XCTAssertTrue(app.descendants(matching: .any)[baselineLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 2 nested baseline content") + + // Identifying must re-resolve the level-2 experience to its variant + // (4hDi...). Its appearance, paired with the baseline id disappearing, + // proves the SDK descends and evaluates audiences at every nesting depth + // rather than leaving deep entries untouched. + identifyAndRelaunch() + + let variantLabel = "This is a level 2 nested variant entry. [Entry: 4hDiXxYEFrXHXcQgmdL9Uv]" + scrollToElement(testId: variantLabel, scrollViewId: "main-scroll-view", app: app) + XCTAssertTrue(app.descendants(matching: .any)[variantLabel].waitForExistence(timeout: ELEMENT_VISIBILITY_TIMEOUT), + "Expected level 2 nested variant after identify") + + XCTAssertFalse(app.descendants(matching: .any)[baselineLabel].exists, + "Level 2 nested baseline content should be gone after identify") } } diff --git a/implementations/react-native-sdk/App.tsx b/implementations/react-native-sdk/App.tsx index c016af28..1d471a29 100644 --- a/implementations/react-native-sdk/App.tsx +++ b/implementations/react-native-sdk/App.tsx @@ -57,6 +57,7 @@ function AppContent(): React.JSX.Element { const subscription = sdk.states.profile.subscribe((profile) => { setHasProfile(profile !== undefined) + setIsIdentified(profile?.traits.identified === true) if (!profile) { return @@ -89,13 +90,11 @@ function AppContent(): React.JSX.Element { const handleIdentify = (): void => { void sdk.identify({ userId: 'charles', traits: { identified: true } }) - setIsIdentified(true) } const handleReset = (): void => { sdk.reset() void sdk.page({ properties: { url: 'app' } }) - setIsIdentified(false) } if (sdkError) { @@ -129,7 +128,12 @@ function AppContent(): React.JSX.Element { } return ( - + { + void sdk.page({ properties: { url: 'app' } }) + }} + > {!isIdentified ? ( diff --git a/implementations/react-native-sdk/e2e/displays-identified-user-variants.test.js b/implementations/react-native-sdk/e2e/displays-identified-user-variants.test.js index 326ef3f7..464b2e8e 100644 --- a/implementations/react-native-sdk/e2e/displays-identified-user-variants.test.js +++ b/implementations/react-native-sdk/e2e/displays-identified-user-variants.test.js @@ -18,7 +18,13 @@ describe('identified user', () => { await device.terminateApp() await device.launchApp({ newInstance: true }) - await waitFor(element(by.id('identify-button'))) + // After identifying and relaunching, the SDK rehydrates the persisted + // identified profile. App.tsx derives the identify/reset control from + // `sdk.states.profile`, so the relaunched app renders `reset-button`, not + // `identify-button`. Waiting for `reset-button` both confirms the relaunch + // finished loading and proves the identified profile actually survived the + // cold start — which is the precondition every test in this suite needs. + await waitFor(element(by.id('reset-button'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) }) diff --git a/implementations/react-native-sdk/e2e/displays-unidentified-user-variants.test.js b/implementations/react-native-sdk/e2e/displays-unidentified-user-variants.test.js index 0b02be8b..d5d9a0c5 100644 --- a/implementations/react-native-sdk/e2e/displays-unidentified-user-variants.test.js +++ b/implementations/react-native-sdk/e2e/displays-unidentified-user-variants.test.js @@ -1,12 +1,34 @@ const { clearProfileState, ELEMENT_VISIBILITY_TIMEOUT } = require('./helpers') +// Drives the unidentified -> identified round-trip the baseline tests rely on. +// The home-screen OptimizedEntry instances lock on their first resolved value, +// so a mid-test identify does not re-resolve them; only a relaunch makes the +// SDK re-run audience evaluation against the now-identified profile. That +// relaunch is exactly what turns a "baseline rendered" assertion from a +// no-op-tolerant check into proof the SDK genuinely evaluated the audience. +async function identifyAndRelaunch() { + await waitFor(element(by.id('identify-button'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await element(by.id('identify-button')).tap() + await waitFor(element(by.id('reset-button'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await device.terminateApp() + await device.launchApp({ newInstance: true }) +} + describe('unidentified user', () => { beforeAll(async () => { await device.launchApp() }) + // Relaunch with cleared storage before every test. The baseline tests below + // identify and leave the app in an identified state, so a plain in-app reset + // would not restore the locked unidentified variant resolution. A fresh + // instance guarantees every test starts from a true unidentified profile. beforeEach(async () => { - await clearProfileState() + await clearProfileState({ requireFreshAppInstance: true }) }) describe('common variants', () => { @@ -89,6 +111,8 @@ describe('unidentified user', () => { }) it('should display baseline for visitors with or without custom event', async () => { + // Unidentified visitor: the custom-event audience is unmatched, so the + // SDK must resolve this entry to its baseline rich-text body. await waitFor(element(by.id('entry-text-6zqoWXyiSrf0ja7I2WGtYj'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) @@ -100,9 +124,38 @@ describe('unidentified user', () => { ), ), ).toBeVisible() + + // The baseline label alone is satisfied even by a no-op SDK: the render + // pipeline falls through to the untouched entry whenever no variant is + // selected, so "baseline rendered" and "SDK did nothing" are + // indistinguishable. Identifying must flip the SAME entry to its + // custom-event variant, whose body text exists only in the variant and + // is unreachable without real audience evaluation. Observing the swap + // retroactively proves the unidentified baseline was a genuine SDK + // decision rather than a pipeline artifact. + await identifyAndRelaunch() + + await expect( + element( + by.label( + 'This is a variant content entry for visitors with a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]', + ), + ), + ).toBeVisible() + + // The baseline copy must be gone — the SDK replaced the rendered body. + await expect( + element( + by.label( + 'This is a baseline content entry for all visitors with or without a custom event. [Entry: 6zqoWXyiSrf0ja7I2WGtYj]', + ), + ), + ).not.toExist() }) it('should display baseline for all identified or unidentified users', async () => { + // Unidentified visitor: this "all users" experience has no qualifying + // variant for an anonymous profile, so it must render baseline. await waitFor(element(by.id('entry-text-7pa5bOx8Z9NmNcr7mISvD'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) @@ -114,11 +167,36 @@ describe('unidentified user', () => { ), ), ).toBeVisible() + + // "All users" is the most failure-open audience shape: a no-op SDK + // satisfies the baseline assertion above purely by accident. Identifying + // must flip this entry to its identified-users variant, whose body text + // never appears in the baseline. The swap is the evidence that the + // unidentified baseline was an evaluated outcome, not a fall-through. + await identifyAndRelaunch() + + await expect( + element( + by.label( + 'This is a variant content entry for identified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]', + ), + ), + ).toBeVisible() + + await expect( + element( + by.label( + 'This is a baseline content entry for all identified or unidentified users. [Entry: 7pa5bOx8Z9NmNcr7mISvD]', + ), + ), + ).not.toExist() }) }) describe('nested optimization baselines', () => { it('should display level 0 nested baseline for new visitors', async () => { + // New (unidentified) visitor: the level-0 nested experience is unmatched, + // so NestedContentItem keys its testID and label off the baseline entry. await waitFor(element(by.id('entry-text-1JAU028vQ7v6nB2swl3NBo'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) @@ -128,9 +206,25 @@ describe('unidentified user', () => { by.label('This is a level 0 nested baseline entry. [Entry: 1JAU028vQ7v6nB2swl3NBo]'), ), ).toBeVisible() + + // The nested testID is keyed off resolvedEntry.sys.id, so the variant id + // 2KIW... can only enter the tree if the SDK actually selects the level-0 + // variant. A no-op SDK leaves the baseline id in place forever. + // Identifying must surface 2KIW... and retire 1JAU..., proving the + // unidentified baseline render was a real resolution decision rather than + // the entry passing through untouched. + await identifyAndRelaunch() + + await waitFor(element(by.id('entry-text-2KIWllNZJT205BwOSkMINg'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await expect(element(by.id('entry-text-1JAU028vQ7v6nB2swl3NBo'))).not.toExist() }) it('should display level 1 nested baseline for new visitors', async () => { + // New (unidentified) visitor: the level-1 nested experience is unmatched, + // so the resolved-entry-keyed testID is the baseline id. await waitFor(element(by.id('entry-text-5i4SdJXw9oDEY0vgO7CwF4'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) @@ -140,9 +234,23 @@ describe('unidentified user', () => { by.label('This is a level 1 nested baseline entry. [Entry: 5i4SdJXw9oDEY0vgO7CwF4]'), ), ).toBeVisible() + + // Identifying must re-resolve the level-1 experience to its variant + // (5a8...), an id the host app never fetches directly — it only enters + // the tree when the SDK selects it. The baseline id must disappear, + // confirming the unidentified baseline was an evaluated outcome. + await identifyAndRelaunch() + + await waitFor(element(by.id('entry-text-5a8ONfBdanJtlJ39WWnH1w'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await expect(element(by.id('entry-text-5i4SdJXw9oDEY0vgO7CwF4'))).not.toExist() }) it('should display level 2 nested baseline for new visitors', async () => { + // New (unidentified) visitor: the deepest nested experience is unmatched, + // so the resolved-entry-keyed testID is the baseline id. await waitFor(element(by.id('entry-text-uaNY4YJ0HFPAX3gKXiRdX'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) @@ -152,6 +260,18 @@ describe('unidentified user', () => { by.label('This is a level 2 nested baseline entry. [Entry: uaNY4YJ0HFPAX3gKXiRdX]'), ), ).toBeVisible() + + // Identifying must re-resolve the level-2 experience to its variant + // (4hDi...). Its appearance, paired with the baseline id disappearing, + // proves the SDK descends and evaluates audiences at every nesting depth + // rather than leaving deep entries untouched. + await identifyAndRelaunch() + + await waitFor(element(by.id('entry-text-4hDiXxYEFrXHXcQgmdL9Uv'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await expect(element(by.id('entry-text-uaNY4YJ0HFPAX3gKXiRdX'))).not.toExist() }) }) }) diff --git a/implementations/react-native-sdk/e2e/extended-view-tracking.test.js b/implementations/react-native-sdk/e2e/extended-view-tracking.test.js index 86bc7331..97c65497 100644 --- a/implementations/react-native-sdk/e2e/extended-view-tracking.test.js +++ b/implementations/react-native-sdk/e2e/extended-view-tracking.test.js @@ -36,7 +36,7 @@ describe('Extended View Tracking', () => { const analyticsTitle = element(by.text('Analytics Events')) await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Wait for the initial event (after dwell requirement ~2s) + // Wait for the initial event (after dwell threshold ~2s) await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) // Wait for at least one periodic update (dwell 2s + update interval 5s = ~7s total) @@ -52,7 +52,7 @@ describe('Extended View Tracking', () => { const duration = await getViewDuration(VISIBLE_ENTRY_ID) - // Duration should exceed the dwell requirement (2000ms) since we've had at least 2 events + // Duration should exceed the dwell threshold (2000ms) since we've had at least 2 events jestExpect(duration).toBeGreaterThan(2000) }) @@ -60,15 +60,26 @@ describe('Extended View Tracking', () => { const analyticsTitle = element(by.text('Analytics Events')) await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Wait for at least 2 events - await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + // Capture the viewId from the FIRST event of the cycle, before any periodic + // update can overwrite latestViewId. The previous version read the viewId + // only once after >=2 events, so it could not distinguish a genuinely + // stable cycle from an SDK that mints a fresh viewId on every event — the + // single read would still see *some* string either way. + await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + const firstEventViewId = await getViewId(VISIBLE_ENTRY_ID) - const viewId = await getViewId(VISIBLE_ENTRY_ID) + jestExpect(firstEventViewId).not.toBeNull() + jestExpect(typeof firstEventViewId).toBe('string') + jestExpect(firstEventViewId.length).toBeGreaterThan(0) + + // Wait for the next periodic event in the SAME visibility cycle and re-read. + // A correct SDK reuses one viewId for the whole cycle, so the second read + // must equal the first. This equality is the assertion that actually + // exercises the "stable viewId" contract the test name claims. + await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + const secondEventViewId = await getViewId(VISIBLE_ENTRY_ID) - // The viewId should be a non-null string (UUID or fallback format) - jestExpect(viewId).not.toBeNull() - jestExpect(typeof viewId).toBe('string') - jestExpect(viewId.length).toBeGreaterThan(0) + jestExpect(secondEventViewId).toBe(firstEventViewId) }) it('should emit a final event when scrolling a tracked entry out of view', async () => { @@ -130,7 +141,7 @@ describe('Extended View Tracking', () => { jestExpect(secondCycleViewId).not.toBe(firstCycleViewId) }) - it('should emit zero events when entry scrolls out before dwell requirement', async () => { + it('should emit zero events when entry scrolls out before dwell threshold', async () => { const analyticsTitle = element(by.text('Analytics Events')) await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) @@ -226,10 +237,23 @@ describe('Extended View Tracking', () => { .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Wait for at least 1 tracking event + // Establish the first visibility cycle and prove its viewId is stable + // across two events BEFORE backgrounding. Anchoring the post-resume + // comparison to a viewId proven stable within this process closes the + // hole the previous version had: comparing against a viewId captured + // after a single event meant any SDK that emits *some* fresh viewId on + // resume passed trivially, even one that never actually paused. await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 1, EXTENDED_TIMEOUT) + const firstCycleViewId = await getViewId(VISIBLE_ENTRY_ID) + await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) + jestExpect(await getViewId(VISIBLE_ENTRY_ID)).toBe(firstCycleViewId) - const preBackgroundViewId = await getViewId(VISIBLE_ENTRY_ID) + // Record the cycle's event count so the post-resume assertion can require + // a concrete delta rather than an arbitrary absolute threshold. + const preBackgroundText = await getElementTextById(`event-count-${VISIBLE_ENTRY_ID}`) + const preBackgroundMatch = /Count:\s*(\d+)/.exec(preBackgroundText) + const countBeforeBackground = + preBackgroundMatch && preBackgroundMatch[1] ? Number(preBackgroundMatch[1]) : 0 // Send app to background await device.sendToHome() @@ -244,13 +268,21 @@ describe('Extended View Tracking', () => { .whileElement(by.id('main-scroll-view')) .scroll(300, 'down') - // Backgrounding ends the cycle (final event) and foregrounding starts a new one. - // Wait for events from the new cycle. - await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 3, EXTENDED_TIMEOUT) - - // The viewId should differ — new cycle after background/foreground + // Backgrounding must end the cycle with a final event and foregrounding + // must start a fresh one with its own initial event, so the count must + // advance by at least 2. Requiring a delta — instead of the old absolute + // `>= 3` — means an always-on emitter that never paused cannot pass by + // simply having accumulated enough periodic events. + await waitForTrackedItemEventCount( + VISIBLE_ENTRY_ID, + countBeforeBackground + 2, + EXTENDED_TIMEOUT, + ) + + // The resumed cycle must carry a different viewId than the first cycle — + // whose stability we proved above — which is the real pause/resume contract. const postForegroundViewId = await getViewId(VISIBLE_ENTRY_ID) - jestExpect(postForegroundViewId).not.toBe(preBackgroundViewId) + jestExpect(postForegroundViewId).not.toBe(firstCycleViewId) }) it('should reset accumulated duration for a new visibility cycle', async () => { @@ -258,11 +290,11 @@ describe('Extended View Tracking', () => { .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Wait for at least 2 events so duration accumulates beyond the dwell requirement + // Wait for at least 2 events so duration accumulates beyond the dwell threshold await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 2, EXTENDED_TIMEOUT) const firstCycleDuration = await getViewDuration(VISIBLE_ENTRY_ID) - // Duration after 2 events should be well above the dwell requirement + // Duration after 2 events should be well above the dwell threshold jestExpect(firstCycleDuration).toBeGreaterThan(4000) // Scroll entry out of view (end cycle, triggers final event) @@ -278,7 +310,7 @@ describe('Extended View Tracking', () => { // New cycle initial = event 4 await waitForTrackedItemEventCount(VISIBLE_ENTRY_ID, 4, EXTENDED_TIMEOUT) - // The new cycle's duration should be around the dwell requirement (~2000ms), + // The new cycle's duration should be around the dwell threshold (~2000ms), // not carrying over the 4000+ms from cycle 1 const secondCycleDuration = await getViewDuration(VISIBLE_ENTRY_ID) jestExpect(secondCycleDuration).toBeGreaterThanOrEqual(2000) diff --git a/implementations/react-native-sdk/e2e/helpers.js b/implementations/react-native-sdk/e2e/helpers.js index 4242254e..104da241 100644 --- a/implementations/react-native-sdk/e2e/helpers.js +++ b/implementations/react-native-sdk/e2e/helpers.js @@ -167,6 +167,16 @@ async function getViewId(componentId) { return match && match[1] && match[1] !== 'N/A' ? match[1].trim() : null } +// Native `Alert.alert` on both Android (AlertDialog) and iOS (UIAlertController) +// exposes buttons as text-matchable elements. `atIndex(0)` guards against +// duplicate matchers if the same label is also present elsewhere in the panel. +async function tapAlertButton(label, timeout = ELEMENT_VISIBILITY_TIMEOUT) { + await waitFor(element(by.text(label)).atIndex(0)) + .toBeVisible() + .withTimeout(timeout) + await element(by.text(label)).atIndex(0).tap() +} + module.exports = { clearProfileState, ELEMENT_VISIBILITY_TIMEOUT, @@ -175,6 +185,7 @@ module.exports = { getElementTextById, isVisibleById, sleep, + tapAlertButton, tapIfVisibleById, waitForTrackedItemEventCount, waitForElementTextById, diff --git a/implementations/react-native-sdk/e2e/live-updates.test.js b/implementations/react-native-sdk/e2e/live-updates.test.js index 04cfdf75..9a4dc268 100644 --- a/implementations/react-native-sdk/e2e/live-updates.test.js +++ b/implementations/react-native-sdk/e2e/live-updates.test.js @@ -1,11 +1,19 @@ +const { expect: jestExpect } = require('expect') const { clearProfileState, ELEMENT_VISIBILITY_TIMEOUT, getElementTextById, tapIfVisibleById, + waitForTextChangeById, waitForTextEqualsById, } = require('./helpers') +// Pattern that the `*-entry-id` Text nodes render via LiveUpdatesEntryDisplay.tsx:23. +// The id segment after "Entry: " is a Contentful sys.id (alphanumeric, no spaces). +// Asserting against this proves the SDK actually resolved an entry rather than +// rendering an empty/default state. +const ENTRY_ID_TEXT_PATTERN = /^Entry: [a-zA-Z0-9]+$/ + describe('live updates behavior', () => { beforeAll(async () => { await device.launchApp() @@ -35,11 +43,17 @@ describe('live updates behavior', () => { describe('default behavior (locked on first value)', () => { it('should NOT update variant when user identifies (global liveUpdates=false)', async () => { + // Capture the SDK-resolved entry id for both the locked default section + // and the always-live reference section. await waitFor(element(by.id('default-entry-id'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await waitFor(element(by.id('live-entry-id'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) const initialDefaultEntryIdText = await getElementTextById('default-entry-id') + const initialLiveEntryIdText = await getElementTextById('live-entry-id') await element(by.id('live-updates-identify-button')).tap() @@ -47,6 +61,14 @@ describe('live updates behavior', () => { .toHaveText('Yes') .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + // The live-prefixed section has liveUpdates=true, so it MUST re-resolve to a + // different variant after identify. If this fails, either the SDK is not + // re-resolving on profile change or the fixture doesn't differentiate + // anonymous from charles — in either case, the "locked stays locked" + // assertion below would be meaningless without this proof. + await waitForTextChangeById('live-entry-id', initialLiveEntryIdText) + + // Default section inherits the global setting (off), so the lock must hold. await waitForTextEqualsById('default-entry-id', initialDefaultEntryIdText) }) }) @@ -63,13 +85,15 @@ describe('live updates behavior', () => { .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + const initialDefaultEntryIdText = await getElementTextById('default-entry-id') + await element(by.id('live-updates-identify-button')).tap() await waitFor(element(by.id('identified-status'))) .toHaveText('Yes') .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await expect(element(by.id('default-entry-id'))).toBeVisible() + await waitForTextChangeById('default-entry-id', initialDefaultEntryIdText) }) it('should NOT update locked entries even when global liveUpdates=true', async () => { @@ -82,8 +106,12 @@ describe('live updates behavior', () => { await waitFor(element(by.id('locked-entry-id'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await waitFor(element(by.id('default-entry-id'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) const initialLockedEntryIdText = await getElementTextById('locked-entry-id') + const initialDefaultEntryIdText = await getElementTextById('default-entry-id') await element(by.id('live-updates-identify-button')).tap() @@ -91,6 +119,11 @@ describe('live updates behavior', () => { .toHaveText('Yes') .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + // With global=ON the default section (no per-component prop) MUST re-resolve. + // This is the live-reference that proves the SDK is actually swapping variants. + await waitForTextChangeById('default-entry-id', initialDefaultEntryIdText) + + // Locked section has liveUpdates=false, so it must stay at its captured id. await waitForTextEqualsById('locked-entry-id', initialLockedEntryIdText) }) }) @@ -105,13 +138,15 @@ describe('live updates behavior', () => { .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + const initialLiveEntryIdText = await getElementTextById('live-entry-id') + await element(by.id('live-updates-identify-button')).tap() await waitFor(element(by.id('identified-status'))) .toHaveText('Yes') .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await expect(element(by.id('live-entry-id'))).toBeVisible() + await waitForTextChangeById('live-entry-id', initialLiveEntryIdText) }) }) @@ -126,8 +161,12 @@ describe('live updates behavior', () => { await waitFor(element(by.id('locked-entry-id'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await waitFor(element(by.id('live-entry-id'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) const initialLockedEntryIdText = await getElementTextById('locked-entry-id') + const initialLiveEntryIdText = await getElementTextById('live-entry-id') await element(by.id('live-updates-identify-button')).tap() @@ -135,6 +174,10 @@ describe('live updates behavior', () => { .toHaveText('Yes') .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + // Live section (per-component liveUpdates=true) MUST change — the SDK is + // re-resolving on identify. The per-component prop is the path under test: + // it must override the global=true setting and keep the locked section stable. + await waitForTextChangeById('live-entry-id', initialLiveEntryIdText) await waitForTextEqualsById('locked-entry-id', initialLockedEntryIdText) }) }) @@ -154,24 +197,29 @@ describe('live updates behavior', () => { await waitFor(element(by.id('default-entry-id'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await waitFor(element(by.id('live-entry-id'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await waitFor(element(by.id('locked-entry-id'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + const initialDefaultEntryIdText = await getElementTextById('default-entry-id') + const initialLiveEntryIdText = await getElementTextById('live-entry-id') + const initialLockedEntryIdText = await getElementTextById('locked-entry-id') + await element(by.id('live-updates-identify-button')).tap() await waitFor(element(by.id('identified-status'))) .toHaveText('Yes') .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await expect(element(by.id('default-entry-id'))).toBeVisible() - await expect(element(by.id('live-entry-id'))).toBeVisible() - await expect(element(by.id('locked-entry-id'))).toBeVisible() + // While the preview panel is open, OptimizedEntry.tsx:229-231 forces + // shouldLiveUpdate=true for ALL sections, including the per-component + // liveUpdates=false one. All three resolved variants must change. + await waitForTextChangeById('default-entry-id', initialDefaultEntryIdText) + await waitForTextChangeById('live-entry-id', initialLiveEntryIdText) + await waitForTextChangeById('locked-entry-id', initialLockedEntryIdText) }) }) @@ -238,6 +286,27 @@ describe('live updates behavior', () => { await waitFor(element(by.id('locked-optimization'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Section wrappers render unconditionally from JSX — proving they're + // mounted is just a smoke check. The SDK responsibility is to feed each + // section a resolved entry whose sys.id surfaces in the entry-id Text. + await waitFor(element(by.id('default-entry-id'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await waitFor(element(by.id('live-entry-id'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await waitFor(element(by.id('locked-entry-id'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + const defaultEntryIdText = await getElementTextById('default-entry-id') + const liveEntryIdText = await getElementTextById('live-entry-id') + const lockedEntryIdText = await getElementTextById('locked-entry-id') + + jestExpect(defaultEntryIdText).toMatch(ENTRY_ID_TEXT_PATTERN) + jestExpect(liveEntryIdText).toMatch(ENTRY_ID_TEXT_PATTERN) + jestExpect(lockedEntryIdText).toMatch(ENTRY_ID_TEXT_PATTERN) }) it('should display entry content in all sections', async () => { @@ -253,13 +322,26 @@ describe('live updates behavior', () => { .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await expect(element(by.id('default-text'))).toBeVisible() - await expect(element(by.id('live-text'))).toBeVisible() - await expect(element(by.id('locked-text'))).toBeVisible() - - await expect(element(by.id('default-entry-id'))).toBeVisible() - await expect(element(by.id('live-entry-id'))).toBeVisible() - await expect(element(by.id('locked-entry-id'))).toBeVisible() + const defaultText = await getElementTextById('default-text') + const liveText = await getElementTextById('live-text') + const lockedText = await getElementTextById('locked-text') + + // LiveUpdatesEntryDisplay.tsx:15 falls back to 'No content' when the + // resolved entry has no text field — a non-empty text that isn't the + // fallback proves the SDK fed a real field value. + jestExpect(defaultText.length).toBeGreaterThan(0) + jestExpect(liveText.length).toBeGreaterThan(0) + jestExpect(lockedText.length).toBeGreaterThan(0) + jestExpect(defaultText).not.toBe('No content') + jestExpect(liveText).not.toBe('No content') + jestExpect(lockedText).not.toBe('No content') + + // All three sections wrap the same Contentful entry, so before any + // identify/toggle/preview-panel actions they MUST resolve to the same + // variant — anything else means the lock semantics are inconsistent + // between sections at first render. + jestExpect(defaultText).toBe(liveText) + jestExpect(defaultText).toBe(lockedText) }) }) }) diff --git a/implementations/react-native-sdk/e2e/offline-behavior.test.js b/implementations/react-native-sdk/e2e/offline-behavior.test.js index 99dee2de..19744661 100644 --- a/implementations/react-native-sdk/e2e/offline-behavior.test.js +++ b/implementations/react-native-sdk/e2e/offline-behavior.test.js @@ -18,15 +18,44 @@ async function getEventsCount() { return parseEventsCount(await getElementTextById('events-count')) } +// Time allowed after reconnecting for NetInfo to flip the SDK online signal and +// for the resulting queue flush (an Experience API round-trip) to land before +// the app is terminated. The mock API is local, but this stays generous so the +// flush is never raced by the terminate even on a loaded emulator. +const QUEUE_FLUSH_GRACE_MS = 10000 + +// Time allowed after an online identify for the Experience upsert round-trip to +// complete before the app is terminated. `handleIdentify` fires the SDK call +// with `void`, so the HTTP request can still be in flight when identify returns. +const IDENTIFY_SETTLE_MS = 3000 + +// Timeout for the post-relaunch variant assertions. A cold start has to boot +// the JS bundle, fetch every entry, and run resolution before the nested tree +// appears, which can exceed the standard element timeout on a loaded emulator. +const POST_RELAUNCH_TIMEOUT = 30000 + +// Nested level-0 entry ids. The host app never fetches these directly; the +// NestedContentItem testID is keyed off the SDK-resolved entry, so the variant +// id only appears once the SDK resolves the *identified* profile and the +// baseline id only appears for an *anonymous* profile. They are therefore an +// honest, SDK-coupled signal of which profile the SDK actually resolved after +// an offline/online cycle — something static text and local-state buttons +// (the old assertions) could never prove. +const NESTED_VARIANT_TEST_ID = 'entry-text-2KIWllNZJT205BwOSkMINg' +const NESTED_BASELINE_TEST_ID = 'entry-text-1JAU028vQ7v6nB2swl3NBo' + describe('Offline Behavior', () => { beforeAll(async () => { await device.launchApp() }) beforeEach(async () => { - await clearProfileState() - // Ensure network is enabled at start of each test + // Restore connectivity first, then relaunch from clean storage. Each test + // identifies and leaves the app in an identified state, so a fresh instance + // is required to guarantee the next test starts from a true anonymous + // profile with an unidentified variant resolution. await enableNetwork() + await clearProfileState({ requireFreshAppInstance: true }) }) afterEach(async () => { @@ -50,12 +79,30 @@ describe('Offline Behavior', () => { // Trigger an action that generates an Experience API event (identify) await element(by.id('identify-button')).tap() - // Verify the event counter increased while offline. + // The SDK must still emit the identify event to its in-process eventStream + // while offline — the analytics counter advances with no network. await waitForElementTextById( 'events-count', (text) => parseEventsCount(text) >= eventsBeforeIdentify + 1, ELEMENT_VISIBILITY_TIMEOUT, ) + + // Counter emission alone is not proof the event was *retained*: a queue + // that silently discards offline events would tick the counter exactly the + // same way. Reconnect so the Experience queue flushes, give the flush + // round-trip time to land, then relaunch. The identified-only nested + // variant id can only resolve if the offline identify was genuinely queued + // and delivered on reconnect — if it had been dropped, the relaunched app + // would resolve the anonymous baseline id instead. + await enableNetwork() + await pause(QUEUE_FLUSH_GRACE_MS) + await device.terminateApp() + await device.launchApp({ newInstance: true }) + + await waitFor(element(by.id(NESTED_VARIANT_TEST_ID))) + .toExist() + .withTimeout(POST_RELAUNCH_TIMEOUT) + await expect(element(by.id(NESTED_BASELINE_TEST_ID))).not.toExist() }) it('should recover gracefully when network is restored', async () => { @@ -63,21 +110,34 @@ describe('Offline Behavior', () => { const analyticsTitle = element(by.text('Analytics Events')) await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Go offline + // Take the SDK through an offline -> online transition. await disableNetwork() - - // Allow offline state to stabilize before reconnecting. await pause(1000) - - // Go back online await enableNetwork() - // App should still be functional - await expect(analyticsTitle).toBeVisible() + // "Recovered gracefully" must mean more than "static text is still on + // screen" — that is true even against a fully dead SDK. The real proof of + // recovery is that the SDK can still complete an end-to-end identify + // pipeline after the blip: identify -> Experience upsert -> + // selectedOptimizations -> variant resolution. The identify here runs + // while online, so let the connectivity transition settle first. + await pause(IDENTIFY_SETTLE_MS) + await element(by.id('identify-button')).tap() + await waitFor(element(by.id('reset-button'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Should be able to interact with the app after reconnection - const identifyButton = element(by.id('identify-button')) - await waitFor(identifyButton).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + // Give the Experience upsert round-trip time to land, then relaunch to + // observe the resolved profile. The identified-only nested variant id can + // only render if the post-recovery identify pipeline ran successfully. + await pause(IDENTIFY_SETTLE_MS) + await device.terminateApp() + await device.launchApp({ newInstance: true }) + + await waitFor(element(by.id(NESTED_VARIANT_TEST_ID))) + .toExist() + .withTimeout(POST_RELAUNCH_TIMEOUT) + await expect(element(by.id(NESTED_BASELINE_TEST_ID))).not.toExist() }) it('should handle rapid network state changes', async () => { @@ -85,7 +145,7 @@ describe('Offline Behavior', () => { const analyticsTitle = element(by.text('Analytics Events')) await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - // Rapidly toggle network state + // Rapidly toggle network state, ending online. await disableNetwork() await pause(500) await enableNetwork() @@ -94,12 +154,25 @@ describe('Offline Behavior', () => { await pause(500) await enableNetwork() - // App should still be stable - await waitFor(analyticsTitle).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + // The old test only checked that static text survived a burst of NetInfo + // events — a no-op SDK passed it trivially. Instead, prove the SDK is still + // fully operational after the churn: a complete identify pipeline must + // still resolve the identified-only nested variant after relaunch. A wedged + // SDK (stuck offline signal, broken queue) would resolve the baseline. + await pause(IDENTIFY_SETTLE_MS) + await element(by.id('identify-button')).tap() + await waitFor(element(by.id('reset-button'))) + .toBeVisible() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + await pause(IDENTIFY_SETTLE_MS) + await device.terminateApp() + await device.launchApp({ newInstance: true }) - // App should remain functional - const identifyButton = element(by.id('identify-button')) - await waitFor(identifyButton).toBeVisible().withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + await waitFor(element(by.id(NESTED_VARIANT_TEST_ID))) + .toExist() + .withTimeout(POST_RELAUNCH_TIMEOUT) + await expect(element(by.id(NESTED_BASELINE_TEST_ID))).not.toExist() }) it('should queue events offline and eventually flush when online', async () => { @@ -125,12 +198,29 @@ describe('Offline Behavior', () => { ELEMENT_VISIBILITY_TIMEOUT, ) - // Go back online - events should flush + // Go back online so the offline Experience queue flushes, and give the + // flush round-trip time to reach the server before the app is killed. await enableNetwork() - - // App should remain functional after reconnect and preserve identified state. + await pause(QUEUE_FLUSH_GRACE_MS) + + // Relaunch and verify the queued-then-flushed identify actually took + // effect end to end: + // - the identified-only nested variant id resolves, proving the flushed + // identify reached the Experience API and the resulting + // selectedOptimizations persisted across the cold start; + // - `reset-button` is visible, which App.tsx renders only when the + // rehydrated `sdk.states.profile` reports an identified profile — + // proving the identified profile state was preserved, not just the + // variant data. + await device.terminateApp() + await device.launchApp({ newInstance: true }) + + await waitFor(element(by.id(NESTED_VARIANT_TEST_ID))) + .toExist() + .withTimeout(POST_RELAUNCH_TIMEOUT) + await expect(element(by.id(NESTED_BASELINE_TEST_ID))).not.toExist() await waitFor(element(by.id('reset-button'))) .toBeVisible() - .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + .withTimeout(POST_RELAUNCH_TIMEOUT) }) }) diff --git a/implementations/react-native-sdk/e2e/preview-panel-overrides.test.js b/implementations/react-native-sdk/e2e/preview-panel-overrides.test.js index 7a1a2785..8390db03 100644 --- a/implementations/react-native-sdk/e2e/preview-panel-overrides.test.js +++ b/implementations/react-native-sdk/e2e/preview-panel-overrides.test.js @@ -6,13 +6,33 @@ * data identical so cross-platform regressions are visible in CI diff. */ -const { clearProfileState, ELEMENT_VISIBILITY_TIMEOUT, isVisibleById } = require('./helpers') +const { + clearProfileState, + ELEMENT_VISIBILITY_TIMEOUT, + isVisibleById, + tapAlertButton, +} = require('./helpers') const AUDIENCE_ID = '4yIqY7AWtzeehCZxtQSDB' const EXPERIENCE_ID = '7DyidZaPB7Jr1gWKjoogg0' const VARIANT_ENTRY_ID = '5a8ONfBdanJtlJ39WWnH1w' const BASELINE_ENTRY_ID = '5i4SdJXw9oDEY0vgO7CwF4' +// Scenario 1 reuses the Mobile Browser audience, which the identified user does +// NOT qualify for. The associated experience is the first entry in the +// `nt_experiences` array of baseline xFwgG3oNaOcjzWiGe4vXo (see +// lib/mocks/src/contentful/data/entries/xFwgG3oNaOcjzWiGe4vXo.json), so +// `OptimizedEntryResolver` picks it deterministically once activated. +// +// xFwgG3oNaOcjzWiGe4vXo renders through the demo app's top-level `ContentEntry` +// (sections/ContentEntry.tsx), whose testID is keyed on the *original* entry +// id and stays constant across resolution. So scenario 1 asserts on the +// resolved entry's accessibilityLabel (variant text + original baseline id) +// — that label changes when the override flips the resolved variant. +const UNQUALIFIED_AUDIENCE_ID = '3MRuZPQ5EdwDqzUDRgOo7c' +const MOBILE_VARIANT_LABEL = + 'This is a variant content entry for visitors using a mobile browser. [Entry: xFwgG3oNaOcjzWiGe4vXo]' + async function identifyAndRelaunch() { await waitFor(element(by.id('identify-button'))) .toBeVisible() @@ -49,7 +69,7 @@ async function openPanel() { async function scrollPanelToId(testId) { await waitFor(element(by.id(testId))) .toBeVisible() - .whileElement(by.type('android.widget.ScrollView')) + .whileElement(by.id('preview-panel-scroll')) .scroll(300, 'down') } @@ -75,6 +95,17 @@ describe('preview panel overrides', () => { await identifyAndRelaunch() }) + it('scenario 1: activating unqualified audience renders its variant', async () => { + await openPanel() + await scrollPanelToId(`audience-toggle-${UNQUALIFIED_AUDIENCE_ID}-on`) + await element(by.id(`audience-toggle-${UNQUALIFIED_AUDIENCE_ID}-on`)).tap() + await closePanel() + + await waitFor(element(by.label(MOBILE_VARIANT_LABEL))) + .toExist() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + }) + it('scenario 2: deactivating qualified audience renders baseline', async () => { await openPanel() await scrollPanelToId(`audience-toggle-${AUDIENCE_ID}-off`) @@ -114,6 +145,25 @@ describe('preview panel overrides', () => { .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) }) + it('scenario 5: resetting single variant override restores variant', async () => { + // Drive scenario 4 first so a variant override exists in the Overrides section. + await openPanel() + await scrollPanelToId(`audience-toggle-${AUDIENCE_ID}-off`) + await element(by.text('Identified Users')).tap() + await scrollPanelToId(`variant-picker-${EXPERIENCE_ID}-0`) + await element(by.id(`variant-picker-${EXPERIENCE_ID}-0`)).tap() + + // Tap the per-experience reset and confirm the native Alert. + await scrollPanelToId(`reset-variant-${EXPERIENCE_ID}`) + await element(by.id(`reset-variant-${EXPERIENCE_ID}`)).tap() + await tapAlertButton('Reset') + await closePanel() + + await waitFor(element(by.id(`entry-text-${VARIANT_ENTRY_ID}`))) + .toExist() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + }) + it('scenario 6: reset-all restores variant content', async () => { // Set a variant override, then reset all. await openPanel() @@ -123,11 +173,12 @@ describe('preview panel overrides', () => { await element(by.id(`variant-picker-${EXPERIENCE_ID}-0`)).tap() await scrollPanelToId('reset-all-overrides') await element(by.id('reset-all-overrides')).tap() - // Confirm alert. - await waitFor(element(by.text('Reset'))) + // Confirmation is now an inline view inside the panel modal, so the + // confirm button is reachable by testID on both platforms. + await waitFor(element(by.id('reset-all-confirm'))) .toBeVisible() .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) - await element(by.text('Reset')).atIndex(0).tap() + await element(by.id('reset-all-confirm')).tap() await closePanel() await waitFor(element(by.id(`entry-text-${VARIANT_ENTRY_ID}`))) @@ -135,9 +186,49 @@ describe('preview panel overrides', () => { .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) }) - // TODO: scenarios 1, 5, 7, 8 — see implementations/PREVIEW_PANEL_SCENARIOS.md. - // - 1 (activate unqualified audience) requires a mock audience the identified user does not qualify for. - // - 5 (reset single variant override) requires tapping the per-item reset button inside the Overrides section. - // - 7 (override survives API refresh) drives the existing preview-refresh-button. - // - 8 (destroy/remount) uses device.terminateApp() + device.launchApp({ delete: true }). + it('scenario 7: override survives API refresh', async () => { + // Apply scenario 2 (audience deactivated → baseline rendering), then drive + // the in-panel Refresh to force a re-hit of the experience API. The + // override interceptor must preserve the audience override across the push. + await openPanel() + await scrollPanelToId(`audience-toggle-${AUDIENCE_ID}-off`) + await element(by.id(`audience-toggle-${AUDIENCE_ID}-off`)).tap() + await scrollPanelToId('preview-refresh-button') + await element(by.id('preview-refresh-button')).tap() + await closePanel() + + await waitFor(element(by.id(`entry-text-${BASELINE_ENTRY_ID}`))) + .toExist() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + }) + + it('scenario 8: destroy/remount clears overrides', async () => { + // Apply scenario 2 override (deactivate audience → baseline rendering). + await openPanel() + await scrollPanelToId(`audience-toggle-${AUDIENCE_ID}-off`) + await element(by.id(`audience-toggle-${AUDIENCE_ID}-off`)).tap() + await closePanel() + await waitFor(element(by.id(`entry-text-${BASELINE_ENTRY_ID}`))) + .toExist() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // Cold relaunch with fresh storage and re-identify, mirroring beforeEach. + await device.terminateApp() + await device.launchApp({ newInstance: true, delete: true }) + await identifyAndRelaunch() + + // Override should be gone — variant renders again. + await waitFor(element(by.id(`entry-text-${VARIANT_ENTRY_ID}`))) + .toExist() + .withTimeout(ELEMENT_VISIBILITY_TIMEOUT) + + // And the Overrides section should be empty. The empty-state text sits + // below the fold in the panel ScrollView, so scroll the footer into view + // first — that pulls OverridesSection (rendered just above it) into the + // visible viewport. + await openPanel() + await scrollPanelToId('reset-all-overrides') + await expect(element(by.text('No active overrides'))).toExist() + await closePanel() + }) }) diff --git a/implementations/react-native-sdk/package.json b/implementations/react-native-sdk/package.json index 1a6eb246..bc15fa3b 100644 --- a/implementations/react-native-sdk/package.json +++ b/implementations/react-native-sdk/package.json @@ -70,5 +70,13 @@ "react-native-dotenv": "3.4.11", "react-test-renderer": "19.2.3", "typescript": "5.9.3" + }, + "pnpm": { + "overrides": { + "@contentful/optimization-api-client": "file:../../pkgs/contentful-optimization-api-client-0.0.0.tgz", + "@contentful/optimization-api-schemas": "file:../../pkgs/contentful-optimization-api-schemas-0.0.0.tgz", + "@contentful/optimization-core": "file:../../pkgs/contentful-optimization-core-0.0.0.tgz", + "@contentful/optimization-react-native": "file:../../pkgs/contentful-optimization-react-native-0.0.0.tgz" + } } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt similarity index 99% rename from packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt rename to packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt index eb2038d9..c2cab38d 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/ZiplineContextManager.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/bridge/QuickJsContextManager.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.concurrent.Executors -class ZiplineContextManager { +class QuickJsContextManager { private var quickJs: QuickJs? = null private val callbackManager = BridgeCallbackManager() private var timerStore: TimerStore? = null diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt index 0ffeb320..4ce93637 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizationRoot.kt @@ -13,13 +13,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.contentful.optimization.core.OptimizationClient import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.preview.PreviewPanelConfig +import com.contentful.optimization.preview.PreviewPanelOverlay +/** + * Top-level composable that initializes the [OptimizationClient] and provides it to descendant + * `OptimizedEntry` composables. + * + * Pass a [PreviewPanelConfig] to add the debug preview panel without manually wrapping content + * in [PreviewPanelOverlay]. + * + * @param previewPanel Optional preview panel configuration. When provided with `enabled = true`, + * the preview panel is available via a floating action button. + */ @Composable fun OptimizationRoot( config: OptimizationConfig, trackViews: Boolean = true, trackTaps: Boolean = false, liveUpdates: Boolean = false, + previewPanel: PreviewPanelConfig? = null, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { @@ -40,7 +53,13 @@ fun OptimizationRoot( ), ) { if (isInitialized) { - content() + if (previewPanel != null && previewPanel.enabled) { + PreviewPanelOverlay(contentfulClient = previewPanel.contentfulClient) { + content() + } + } else { + content() + } } else { Box(modifier = modifier, contentAlignment = Alignment.Center) { CircularProgressIndicator() diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt index 6ac6e141..81f3ce7e 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/compose/OptimizedEntry.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -31,7 +30,6 @@ fun OptimizedEntry( val client = LocalOptimizationClient.current val trackingConfig = LocalTrackingConfig.current - val selectedPersonalizations by client.selectedPersonalizations.collectAsState() val isPreviewPanelOpen by client.isPreviewPanelOpen.collectAsState() var lockedPersonalizations by remember { mutableStateOf>?>(null) } @@ -42,8 +40,10 @@ fun OptimizedEntry( fields?.containsKey("nt_experiences") == true } - val shouldLiveUpdate = liveUpdates ?: (trackingConfig.liveUpdates || isPreviewPanelOpen) - val effectivePersonalizations = if (shouldLiveUpdate) selectedPersonalizations else lockedPersonalizations + // An open preview panel always forces live updates, overriding an explicit + // `liveUpdates = false`. The global toggle is only the default when no + // explicit per-component value is set. Mirrors the iOS `shouldLiveUpdate`. + val shouldLiveUpdate = if (isPreviewPanelOpen) true else liveUpdates ?: trackingConfig.liveUpdates val viewsEnabled = trackViews ?: trackingConfig.trackViews val tapsEnabled = when { @@ -52,50 +52,46 @@ fun OptimizedEntry( else -> trackingConfig.trackTaps } - // Collect directly from the underlying StateFlow rather than keying a - // LaunchedEffect on `collectAsState`. Compose snapshot conflation can - // coalesce a rapid null → cached → anonymous → identified sequence into - // a single observed value, latching the lock onto the wrong emission. + var result by remember(entry) { + mutableStateOf(PersonalizedResult(entry = entry, personalization = null)) + } + + // Re-resolve by collecting the personalizations StateFlow directly rather + // than keying `produceState` on a `collectAsState()` snapshot. Compose + // snapshot conflation can coalesce the rapid emission sequence that + // `identify()` produces, which left a long-mounted live entry stuck on its + // first resolution. Collecting the flow observes every emission. // - // Until we see an identified profile (`profile.userId` non-null), follow - // the latest personalizations instead of permanently locking. This keeps - // anti-flashing behavior for identified sessions (the steady state) while - // letting transient anonymous/cached emissions be replaced once `identify` - // resolves. A late-mounting nested OptimizedEntry that subscribes after a - // mid-flight reset/identify cycle will therefore reflect the final state. - LaunchedEffect(Unit) { + // Live entries follow the latest personalizations on every emission. Locked + // entries freeze on the first non-null value — mirrors the iOS `onReceive` + // lock — and ignore later updates until the component is remounted. + LaunchedEffect(entry, shouldLiveUpdate) { + if (!isPersonalized) { + result = PersonalizedResult(entry = entry, personalization = null) + return@LaunchedEffect + } client.selectedPersonalizations.collect { newValue -> - if (isPersonalized && !shouldLiveUpdate && newValue != null && !isLocked) { - val isIdentified = (client.state.value.profile?.get("userId") as? String) != null + if (!shouldLiveUpdate && !isLocked && newValue != null) { lockedPersonalizations = newValue - if (isIdentified) { - isLocked = true - } + isLocked = true } + val personalizations = if (shouldLiveUpdate) newValue else lockedPersonalizations + result = client.personalizeEntry( + baseline = entry, + personalizations = personalizations, + ) } } + // When the preview panel closes, snapshot the current personalizations so + // the locked state reflects any overrides applied during the session. LaunchedEffect(isPreviewPanelOpen) { if (isPersonalized && !isPreviewPanelOpen && isLocked) { - // Read directly from the StateFlow rather than the Compose-state - // snapshot. The sync-bridge wrapper guarantees state has settled by - // the time this fires, so `.value` carries the post-action result. lockedPersonalizations = client.selectedPersonalizations.value - } - } - - val result by produceState( - initialValue = PersonalizedResult(entry = entry, personalization = null), - key1 = entry, - key2 = effectivePersonalizations, - ) { - value = if (isPersonalized) { - client.personalizeEntry( + result = client.personalizeEntry( baseline = entry, - personalizations = effectivePersonalizations, + personalizations = lockedPersonalizations, ) - } else { - PersonalizedResult(entry = entry, personalization = null) } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt index 887ef244..70e0126f 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt @@ -1,7 +1,7 @@ package com.contentful.optimization.core import android.content.Context -import com.contentful.optimization.bridge.ZiplineContextManager +import com.contentful.optimization.bridge.QuickJsContextManager import com.contentful.optimization.handlers.AppLifecycleHandler import com.contentful.optimization.handlers.NetworkMonitor import com.contentful.optimization.polyfills.escapeForJS @@ -40,10 +40,12 @@ class OptimizationClient(private val applicationContext: Context) { private val _previewState = MutableStateFlow(null) val previewState: StateFlow = _previewState.asStateFlow() - private val _events = MutableSharedFlow>(extraBufferCapacity = 64) + // `replay` lets a UI collector that subscribes slightly after startup still + // receive the one-shot flag-view event emitted synchronously by subscribeToFlag. + private val _events = MutableSharedFlow>(replay = 64, extraBufferCapacity = 64) val events: SharedFlow> = _events.asSharedFlow() - private val bridge = ZiplineContextManager() + private val bridge = QuickJsContextManager() private val store = SharedPreferencesStore(applicationContext) private var appLifecycleHandler: AppLifecycleHandler? = null private var networkMonitor: NetworkMonitor? = null @@ -177,6 +179,22 @@ class OptimizationClient(private val applicationContext: Context) { } } + /** Resolve a merge-tag entry's display value against the current profile. */ + suspend fun getMergeTagValue(mergeTagEntry: Map): String? { + if (!_isInitialized.value) return null + return try { + val result = bridge.callSync("getMergeTagValue", JSONObject(mergeTagEntry).toString()) + if (result == null || result == "null" || result == "undefined") null else result + } catch (_: Exception) { + null + } + } + + /** Subscribe to a feature flag by name. Emits a flag-view `component` event. */ + fun subscribeToFlag(name: String) { + bridgeCallSyncWhenInitialized("flag", "'${escapeForJS(name)}'") + } + suspend fun getProfile(): Map? { val result = bridge.callSync("getProfile") if (result == null || result == "null" || result == "undefined") return null @@ -366,7 +384,7 @@ class OptimizationClient(private val applicationContext: Context) { fun parseJSONDict(json: String): Map? { if (json == "null") return null return try { - ZiplineContextManager.jsonObjectToMap(JSONObject(json)) + QuickJsContextManager.jsonObjectToMap(JSONObject(json)) } catch (_: Exception) { DiagnosticLogger.warning { "[parse] JSON parse failed — input: ${json.take(200)}" } null diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelConfig.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelConfig.kt new file mode 100644 index 00000000..3f732174 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/preview/PreviewPanelConfig.kt @@ -0,0 +1,18 @@ +package com.contentful.optimization.preview + +/** + * Declarative configuration for the optimization preview panel. + * + * Pass an instance to [com.contentful.optimization.compose.OptimizationRoot] to add the debug + * preview panel without manually wrapping content in [PreviewPanelOverlay]. When [enabled] is + * `true`, a floating action button appears that opens the preview panel sheet. + * + * @property enabled Whether the preview panel is shown. + * @property contentfulClient Contentful client used to fetch `nt_audience` and `nt_experience` + * entries. When provided, the panel displays rich audience and experience definitions; when + * `null`, the panel falls back to basic data from the SDK. + */ +data class PreviewPanelConfig( + val enabled: Boolean = true, + val contentfulClient: PreviewContentfulClient? = null, +) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt index da998cd5..aedc6fa8 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/storage/SharedPreferencesStore.kt @@ -2,7 +2,7 @@ package com.contentful.optimization.storage import android.content.Context import android.content.SharedPreferences -import com.contentful.optimization.bridge.ZiplineContextManager +import com.contentful.optimization.bridge.QuickJsContextManager import org.json.JSONArray import org.json.JSONObject @@ -126,9 +126,9 @@ class SharedPreferencesStore(context: Context) : PersistentStore { private fun parseJSON(json: String): Any { return if (json.trimStart().startsWith("[")) { val arr = JSONArray(json) - (0 until arr.length()).map { ZiplineContextManager.jsonObjectToMap(arr.getJSONObject(it)) } + (0 until arr.length()).map { QuickJsContextManager.jsonObjectToMap(arr.getJSONObject(it)) } } else { - ZiplineContextManager.jsonObjectToMap(JSONObject(json)) + QuickJsContextManager.jsonObjectToMap(JSONObject(json)) } } } diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt index f39a2594..b2281382 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/tracking/ViewTrackingController.kt @@ -33,6 +33,12 @@ class ViewTrackingController( private var attempts: Int = 0 private var timerJob: Job? = null + // Last known visibility geometry, for re-evaluation after resume. + private var lastElementY: Float = 0f + private var lastElementHeight: Float = 0f + private var lastScrollY: Float = 0f + private var lastViewportHeight: Float = 0f + init { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } @@ -45,6 +51,11 @@ class ViewTrackingController( ) { if (elementHeight <= 0f) return + lastElementY = elementY + lastElementHeight = elementHeight + lastScrollY = scrollY + lastViewportHeight = viewportHeight + val visibleTop = maxOf(elementY, scrollY) val visibleBottom = minOf(elementY + elementHeight, scrollY + viewportHeight) val visibleHeight = maxOf(0f, visibleBottom - visibleTop) @@ -91,7 +102,11 @@ class ViewTrackingController( } private fun resume() { + // Re-evaluate visibility from the last known geometry so a still-visible + // element starts a fresh cycle without waiting for a scroll callback + // (which may never fire after foregrounding). Mirrors iOS `resume()`. isVisible = false + updateVisibility(lastElementY, lastElementHeight, lastScrollY, lastViewportHeight) } private fun onBecameVisible() { diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift index 35076097..717f8cc9 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Bridge/JSContextManager.swift @@ -1,6 +1,5 @@ import Foundation import JavaScriptCore -import os /// Manages the JSContext lifecycle: polyfill injection, UMD bundle loading, and bridge calls. /// @@ -10,11 +9,6 @@ final class JSContextManager { let callbackManager = BridgeCallbackManager() private var timerStore: NativePolyfills.TimerStore? - private static let signpostLog = OSLog( - subsystem: "com.contentful.optimization", - category: "Performance" - ) - var onLog: ((String, String) -> Void)? var onStateChange: (([String: Any]) -> Void)? var onEvent: (([String: Any]) -> Void)? @@ -22,13 +16,8 @@ final class JSContextManager { /// Creates the JSContext, loads polyfills and the UMD bundle, and calls `__bridge.initialize()`. func initialize(config: OptimizationConfig) throws { - let log = Self.signpostLog - let coldStartID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "Cold Start", signpostID: coldStartID) - // Create context guard let ctx = JSContext() else { - os_signpost(.end, log: log, name: "Cold Start", signpostID: coldStartID) throw OptimizationError.bridgeError("Failed to create JSContext") } @@ -37,7 +26,8 @@ final class JSContextManager { self?.onLog?("exception", msg) } - if #available(iOS 16.4, macOS 13.3, *) { + // Remote JS inspection is a debugging aid only; keep it out of release builds. + if config.debug, #available(iOS 16.4, macOS 13.3, *) { ctx.isInspectable = true } @@ -59,7 +49,6 @@ final class JSContextManager { // Verify __bridge exists let bridgeCheck = ctx.evaluateScript("typeof __bridge") guard bridgeCheck?.toString() == "object" else { - os_signpost(.end, log: log, name: "Cold Start", signpostID: coldStartID) throw OptimizationError.bridgeError( "__bridge not found after bundle evaluation (got: \(bridgeCheck?.toString() ?? "nil"))" ) @@ -91,14 +80,12 @@ final class JSContextManager { do { configJSON = try config.toJSON() } catch { - os_signpost(.end, log: log, name: "Cold Start", signpostID: coldStartID) throw OptimizationError.configError("Failed to serialize config: \(error)") } ctx.evaluateScript("__bridge.initialize(\(configJSON))") self.context = ctx - os_signpost(.end, log: log, name: "Cold Start", signpostID: coldStartID) } // MARK: - Bridge calls diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift index bb04b419..17a86832 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationClient.swift @@ -223,6 +223,35 @@ public final class OptimizationClient: ObservableObject { } } + /// Resolve a merge-tag entry's display value against the current profile. + /// + /// Pass the resolved `nt_mergetag` entry (the `embedded-entry-inline` node's + /// expanded `data.target`). Returns the resolved string, or `nil` when the + /// merge tag cannot be resolved against the current profile. + public func getMergeTagValue(mergeTagEntry: [String: Any]) -> String? { + guard isInitialized else { return nil } + do { + let json = try serializeJSON(mergeTagEntry) + guard let result = bridgeCallSyncWhenInitialized(method: "getMergeTagValue", args: json), + !result.isNull, !result.isUndefined, + let str = result.toString() + else { return nil } + return str + } catch { + return nil + } + } + + /// Subscribe to a feature flag by name. + /// + /// Subscribing emits a flag-view (`component`) analytics event through the + /// SDK event stream, and again on each distinct flag value change — mirroring + /// the React Native `sdk.states.flag(name).subscribe(...)` contract. + public func subscribeToFlag(_ name: String) { + let escaped = NativePolyfills.escapeForJS(name) + bridgeCallSyncWhenInitialized(method: "flag", args: "'\(escaped)'") + } + /// Get the current profile synchronously. public func getProfile() -> [String: Any]? { guard let result = bridge.callSync(method: "getProfile"), diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift index f8626d73..4344f127 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Polyfills/NativePolyfills.swift @@ -32,14 +32,20 @@ enum NativePolyfills { } /// Escapes a Swift string so it can be safely interpolated into a JS string literal. + /// + /// Covers backtick and the U+2028/U+2029 line terminators, both of which are valid + /// inside a JS string literal and would otherwise break out of it. static func escapeForJS(_ value: String) -> String { value .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "`", with: "\\`") .replacingOccurrences(of: "\n", with: "\\n") .replacingOccurrences(of: "\r", with: "\\r") .replacingOccurrences(of: "\t", with: "\\t") + .replacingOccurrences(of: "\u{2028}", with: "\\u2028") + .replacingOccurrences(of: "\u{2029}", with: "\\u2029") } private static let signpostLog = OSLog( @@ -124,9 +130,8 @@ enum NativePolyfills { guard let url = Foundation.URL(string: urlString) else { diagLog.error("[fetch] Invalid URL: \(urlString)") DispatchQueue.main.async { - let escaped = escapeForJS(urlString) - weakContext?.evaluateScript( - "__fetchComplete(\(callbackId), 0, \"{}\", \"\", \"Invalid URL: \(escaped)\")" + weakContext?.objectForKeyedSubscript("__fetchComplete")?.call( + withArguments: [callbackId, 0, "{}", "", "Invalid URL: \(urlString)"] ) os_signpost( .end, log: log, name: "Fetch Bridge Crossing", @@ -190,16 +195,14 @@ enum NativePolyfills { } if let error = error { - diagLog.error("[fetch] Network error for \(urlString): \(error.localizedDescription)") - let escaped = escapeForJS( - error.localizedDescription - ) - ctx.evaluateScript( - "__fetchComplete(\(callbackId), 0, \"{}\", \"\", \"\(escaped)\")" + let message = error.localizedDescription + diagLog.error("[fetch] Network error for \(urlString): \(message)") + ctx.objectForKeyedSubscript("__fetchComplete")?.call( + withArguments: [callbackId, 0, "{}", "", message] ) os_signpost( .end, log: log, name: "Fetch Bridge Crossing", - signpostID: fetchSignpostID, "error: %{public}s", escaped + signpostID: fetchSignpostID, "error: %{public}s", message ) return } @@ -222,11 +225,8 @@ enum NativePolyfills { let bodyText = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" - let escapedBody = escapeForJS(bodyText) - let escapedHeaders = escapeForJS(headersJSONStr) - - ctx.evaluateScript( - "__fetchComplete(\(callbackId), \(statusCode), \"\(escapedHeaders)\", \"\(escapedBody)\", \"\")" + ctx.objectForKeyedSubscript("__fetchComplete")?.call( + withArguments: [callbackId, statusCode, headersJSONStr, bodyText, ""] ) os_signpost( .end, log: log, name: "Fetch Bridge Crossing", diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewComponents.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewComponents.swift index 8d941985..3b4b6698 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewComponents.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewComponents.swift @@ -587,6 +587,7 @@ struct AudienceItemHeader: View { } .buttonStyle(.plain) .contentShape(Rectangle()) + .accessibilityIdentifier("audience-expand-\(audience.audience.id)") .onLongPressGesture(minimumDuration: 0.5, perform: onCopyId) // Description diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelConfig.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelConfig.swift new file mode 100644 index 00000000..6fb92e71 --- /dev/null +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelConfig.swift @@ -0,0 +1,32 @@ +/// Declarative configuration for the optimization preview panel. +/// +/// Pass an instance to ``OptimizationRoot`` to add the debug preview panel without +/// manually wrapping content in ``PreviewPanelOverlay``. When ``enabled`` is `true`, +/// a floating action button appears that opens the preview panel sheet. +/// +/// ```swift +/// OptimizationRoot( +/// config: OptimizationConfig(clientId: "my-id"), +/// previewPanel: PreviewPanelConfig(contentfulClient: myContentfulClient) +/// ) { +/// ContentView() +/// } +/// ``` +public struct PreviewPanelConfig { + /// Whether the preview panel is shown. + /// + /// When `true`, a floating action button appears that opens the preview panel sheet. + public let enabled: Bool + + /// Contentful client used to fetch `nt_audience` and `nt_experience` entries. + /// + /// When provided, the panel displays rich audience and experience definitions + /// (names, types, variant distributions). When `nil`, the panel falls back to + /// basic data from the SDK. + public let contentfulClient: PreviewContentfulClient? + + public init(enabled: Bool = true, contentfulClient: PreviewContentfulClient? = nil) { + self.enabled = enabled + self.contentfulClient = contentfulClient + } +} diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelContent.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelContent.swift index c8ee04ab..97820dd8 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelContent.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Preview/PreviewPanelContent.swift @@ -52,6 +52,7 @@ final class PreviewViewModel: ObservableObject { audiences: results.audiences.items, experiences: experienceEntriesWithIncludes ) + client.refreshPreviewState() hasLoadedDefinitions = true isLoadingDefinitions = false diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift index 98029dec..ee6b1f75 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/ViewTrackingController.swift @@ -157,10 +157,18 @@ public final class ViewTrackingController { resetCycle() } - /// Resume tracking after a pause. Resets visibility so it can be re-evaluated - /// by the next geometry callback. + /// Resume tracking after a pause. Resets visibility and immediately + /// re-evaluates it from the last known geometry so a still-visible element + /// starts a fresh cycle without waiting for an external geometry callback + /// (which may never fire if nothing scrolls after foregrounding). public func resume() { isVisible = false + updateVisibility( + elementY: lastElementY, + elementHeight: lastElementHeight, + scrollY: lastScrollY, + viewportHeight: lastViewportHeight + ) } // MARK: - Private diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift index 009659b4..aee73bc8 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizationRoot.swift @@ -10,11 +10,24 @@ import SwiftUI /// ContentView() /// } /// ``` +/// +/// Pass a ``PreviewPanelConfig`` to add the debug preview panel without manually wrapping +/// content in ``PreviewPanelOverlay``: +/// +/// ```swift +/// OptimizationRoot( +/// config: OptimizationConfig(clientId: "my-id"), +/// previewPanel: PreviewPanelConfig(contentfulClient: myContentfulClient) +/// ) { +/// ContentView() +/// } +/// ``` public struct OptimizationRoot: View { let config: OptimizationConfig let trackViews: Bool let trackTaps: Bool let liveUpdates: Bool + let previewPanel: PreviewPanelConfig? @ViewBuilder let content: () -> Content @StateObject private var client = OptimizationClient() @@ -24,19 +37,21 @@ public struct OptimizationRoot: View { trackViews: Bool = true, trackTaps: Bool = false, liveUpdates: Bool = false, + previewPanel: PreviewPanelConfig? = nil, @ViewBuilder content: @escaping () -> Content ) { self.config = config self.trackViews = trackViews self.trackTaps = trackTaps self.liveUpdates = liveUpdates + self.previewPanel = previewPanel self.content = content } public var body: some View { Group { if client.isInitialized { - content() + appContent } else { ProgressView() } @@ -51,4 +66,17 @@ public struct OptimizationRoot: View { try? client.initialize(config: config) } } + + /// App content, optionally wrapped in ``PreviewPanelOverlay`` when a + /// ``PreviewPanelConfig`` with `enabled == true` is provided. + @ViewBuilder + private var appContent: some View { + if let previewPanel = previewPanel, previewPanel.enabled { + PreviewPanelOverlay(contentfulClient: previewPanel.contentfulClient) { + content() + } + } else { + content() + } + } } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift index d6fe5c76..cea56513 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Views/OptimizedEntry.swift @@ -63,9 +63,13 @@ public struct OptimizedEntry: View { return fields["nt_experiences"] != nil } + // An open preview panel always forces live updates, overriding an explicit + // `liveUpdates: false`. The global toggle only acts as the default when no + // explicit per-component value is set. private var shouldLiveUpdate: Bool { + if client.isPreviewPanelOpen { return true } if let explicit = liveUpdates { return explicit } - return trackingConfig.liveUpdates || client.isPreviewPanelOpen + return trackingConfig.liveUpdates } private var effectivePersonalizations: [[String: Any]]? { @@ -111,6 +115,12 @@ public struct OptimizedEntry: View { onTap: onTap, client: client )) + // Expose the wrapper as an accessibility container rather than + // letting `accessibilityIdentifier` collapse onto — and override the + // identifier of — the single child element. This keeps the consumer's + // own nested identifiers (e.g. `entry-text-`) individually + // queryable alongside this wrapper identifier. + .accessibilityElement(children: .contain) .accessibilityIdentifier(accessibilityIdentifier ?? "") .onReceive(client.$selectedPersonalizations) { newValue in guard isPersonalized, !shouldLiveUpdate, !isLocked, newValue != nil else { return } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df3bc621..875d7970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,31 +235,6 @@ importers: specifier: ^5.8.3 version: 5.9.3 - packages/ios/ios-jsc-bridge: - dependencies: - '@contentful/optimization-core': - specifier: workspace:* - version: link:../../universal/core-sdk - devDependencies: - '@rslib/core': - specifier: 'catalog:' - version: 0.19.6(@microsoft/api-extractor@7.57.7(@types/node@24.10.13))(typescript@5.9.3) - '@types/node': - specifier: ^24.0.13 - version: 24.10.13 - build-tools: - specifier: workspace:* - version: link:../../../lib/build-tools - rimraf: - specifier: 'catalog:' - version: 6.1.3 - tslib: - specifier: 'catalog:' - version: 2.8.1 - typescript: - specifier: ^5.8.3 - version: 5.9.3 - packages/node/node-sdk: dependencies: '@contentful/optimization-core': @@ -616,6 +591,31 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/universal/optimization-js-bridge: + dependencies: + '@contentful/optimization-core': + specifier: workspace:* + version: link:../core-sdk + devDependencies: + '@rslib/core': + specifier: 'catalog:' + version: 0.19.6(@microsoft/api-extractor@7.57.7(@types/node@24.10.13))(typescript@5.9.3) + '@types/node': + specifier: ^24.0.13 + version: 24.10.13 + build-tools: + specifier: workspace:* + version: link:../../../lib/build-tools + rimraf: + specifier: 'catalog:' + version: 6.1.3 + tslib: + specifier: 'catalog:' + version: 2.8.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/web/frameworks/react-web-sdk: dependencies: '@contentful/optimization-web': @@ -662,8 +662,8 @@ importers: specifier: 'catalog:' version: 20.8.9 next: - specifier: ^16.2.6 - version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^16.2.3 + version: 16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1291,8 +1291,8 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -2002,57 +2002,57 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - '@next/env@16.2.6': - resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + '@next/env@16.2.3': + resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} - '@next/swc-darwin-arm64@16.2.6': - resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + '@next/swc-darwin-arm64@16.2.3': + resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.6': - resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + '@next/swc-darwin-x64@16.2.3': + resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.6': - resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + '@next/swc-linux-arm64-gnu@16.2.3': + resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.6': - resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + '@next/swc-linux-arm64-musl@16.2.3': + resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.6': - resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + '@next/swc-linux-x64-gnu@16.2.3': + resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.6': - resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + '@next/swc-linux-x64-musl@16.2.3': + resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.6': - resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + '@next/swc-win32-arm64-msvc@16.2.3': + resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.6': - resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + '@next/swc-win32-x64-msvc@16.2.3': + resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2768,7 +2768,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3148,8 +3147,11 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -3215,14 +3217,15 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.29: - resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.3.1: - resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + basic-ftp@5.1.0: + resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade bfj@9.1.3: resolution: {integrity: sha512-1ythbcNNAd2UjTYW6M+MAHd9KM/m3g4mQ+3a4Vom16WgmUa4GsisdmXAYfpAjkObY5zdpgzaBh1ctZOEcJipuQ==} @@ -3333,8 +3336,8 @@ packages: camelo@1.2.2: resolution: {integrity: sha512-4HmYiO74VXQM6NFE2euk2p/6hBJeYDDf6k3ZMIvTCWhoAm6RvUiWkhxjkJsztcqEYt7g/jgr5en6R/TPaFccXw==} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} cardinal@2.1.1: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} @@ -4361,8 +4364,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} fast-xml-parser@4.5.4: resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==} @@ -4482,8 +4485,8 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -4895,10 +4898,6 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5932,8 +5931,8 @@ packages: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -5966,8 +5965,8 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - next@16.2.6: - resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + next@16.2.3: + resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -6768,11 +6767,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -7130,8 +7124,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - systeminformation@5.31.6: - resolution: {integrity: sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==} + systeminformation@5.31.0: + resolution: {integrity: sha512-z5pjzvC8UnQJ/iu34z+mo3lAeMzTGdArjPQoG5uPyV5XY4BY+M6ZcRTl4XnZqudz6sP713LhWMKv6e0kGFGCgQ==} engines: {node: '>=8.0.0'} os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true @@ -7420,7 +7414,6 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -7429,7 +7422,6 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -7579,8 +7571,8 @@ packages: utf-8-validate: optional: true - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -8287,7 +8279,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 optional: true @@ -8566,7 +8558,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.9.2 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -9045,41 +9037,41 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.6': {} + '@next/env@16.2.3': {} - '@next/swc-darwin-arm64@16.2.6': + '@next/swc-darwin-arm64@16.2.3': optional: true - '@next/swc-darwin-x64@16.2.6': + '@next/swc-darwin-x64@16.2.3': optional: true - '@next/swc-linux-arm64-gnu@16.2.6': + '@next/swc-linux-arm64-gnu@16.2.3': optional: true - '@next/swc-linux-arm64-musl@16.2.6': + '@next/swc-linux-arm64-musl@16.2.3': optional: true - '@next/swc-linux-x64-gnu@16.2.6': + '@next/swc-linux-x64-gnu@16.2.3': optional: true - '@next/swc-linux-x64-musl@16.2.6': + '@next/swc-linux-x64-musl@16.2.3': optional: true - '@next/swc-win32-arm64-msvc@16.2.6': + '@next/swc-win32-arm64-msvc@16.2.3': optional: true - '@next/swc-win32-x64-msvc@16.2.6': + '@next/swc-win32-x64-msvc@16.2.3': optional: true '@nodelib/fs.scandir@2.1.5': @@ -10069,7 +10061,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.8.0 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -10233,7 +10225,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -10408,15 +10400,21 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.16.1: + axios@1.13.6: dependencies: - follow-redirects: 1.16.0(debug@4.3.7) + follow-redirects: 1.15.11(debug@4.3.7) + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axios@1.14.0: + dependencies: + follow-redirects: 1.15.11(debug@4.3.7) form-data: 4.0.5 - https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - - supports-color babel-jest@29.7.0(@babel/core@7.29.0): dependencies: @@ -10527,9 +10525,9 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.29: {} + baseline-browser-mapping@2.10.17: {} - basic-ftp@5.3.1: {} + basic-ftp@5.1.0: {} bfj@9.1.3: dependencies: @@ -10625,8 +10623,8 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.29 - caniuse-lite: 1.0.30001792 + baseline-browser-mapping: 2.10.17 + caniuse-lite: 1.0.30001787 electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -10676,7 +10674,7 @@ snapshots: regex-escape: 3.4.11 uc-first-array: 1.1.11 - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001787: {} cardinal@2.1.1: dependencies: @@ -11048,7 +11046,7 @@ snapshots: contentful-export@7.22.5: dependencies: - axios: 1.16.1 + axios: 1.14.0 bfj: 9.1.3 bluebird: 3.7.2 cli-table3: 0.6.5 @@ -11099,18 +11097,17 @@ snapshots: contentful-management@11.75.0: dependencies: '@contentful/rich-text-types': 16.8.5 - axios: 1.16.1 + axios: 1.14.0 contentful-sdk-core: 9.4.5 fast-copy: 3.0.2 globals: 15.15.0 transitivePeerDependencies: - debug - - supports-color contentful-migration@4.32.0(@types/node@24.10.13)(enquirer@2.3.6): dependencies: '@hapi/hoek': 11.0.7 - axios: 1.16.1 + axios: 1.14.0 bluebird: 3.7.2 callsites: 3.1.0 cardinal: 2.1.1 @@ -11158,7 +11155,7 @@ snapshots: dependencies: '@contentful/content-source-maps': 0.11.44 '@contentful/rich-text-types': 16.8.5 - axios: 1.16.1 + axios: 1.13.6 contentful-resolve-response: 1.9.6 contentful-sdk-core: 9.4.4 json-stringify-safe: 5.0.1 @@ -11166,7 +11163,6 @@ snapshots: type-fest: 4.41.0 transitivePeerDependencies: - debug - - supports-color conventional-commit-types@3.0.0: {} @@ -11682,12 +11678,12 @@ snapshots: eslint-compat-utils@0.5.1(eslint@10.0.0(jiti@2.6.1)): dependencies: eslint: 10.0.0(jiti@2.6.1) - semver: 7.8.0 + semver: 7.7.4 eslint-compat-utils@0.5.1(eslint@8.57.1): dependencies: eslint: 8.57.1 - semver: 7.8.0 + semver: 7.7.4 eslint-config-love@121.0.0(@typescript-eslint/parser@8.56.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3): dependencies: @@ -12145,7 +12141,7 @@ snapshots: extrareqp2@1.0.0(debug@4.3.7): dependencies: - follow-redirects: 1.16.0(debug@4.3.7) + follow-redirects: 1.15.11(debug@4.3.7) transitivePeerDependencies: - debug @@ -12169,7 +12165,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.2: {} + fast-uri@3.1.0: {} fast-xml-parser@4.5.4: dependencies: @@ -12308,7 +12304,7 @@ snapshots: flow-enums-runtime@0.0.6: {} - follow-redirects@1.16.0(debug@4.3.7): + follow-redirects@1.15.11(debug@4.3.7): optionalDependencies: debug: 4.3.7 @@ -12429,7 +12425,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.3.1 + basic-ftp: 5.1.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -12520,7 +12516,7 @@ snapshots: '@types/ws': 8.18.1 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.20.1 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -12786,8 +12782,6 @@ snapshots: ip-address@10.1.0: {} - ip-address@10.2.0: {} - ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -12991,7 +12985,7 @@ snapshots: '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.8.0 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -13430,7 +13424,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.8.0 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -13629,7 +13623,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.0 + semver: 7.7.4 jwa@2.0.1: dependencies: @@ -13889,7 +13883,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.0 + semver: 7.7.4 makeerror@1.0.12: dependencies: @@ -14237,7 +14231,7 @@ snapshots: nano-spawn@2.0.0: {} - nanoid@3.3.12: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -14259,25 +14253,25 @@ snapshots: netmask@2.0.2: {} - next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@next/env': 16.2.6 + '@next/env': 16.2.3 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.29 - caniuse-lite: 1.0.30001792 + baseline-browser-mapping: 2.10.17 + caniuse-lite: 1.0.30001787 postcss: 8.4.31 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) styled-jsx: 5.1.6(react@19.2.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 + '@next/swc-darwin-arm64': 16.2.3 + '@next/swc-darwin-x64': 16.2.3 + '@next/swc-linux-arm64-gnu': 16.2.3 + '@next/swc-linux-arm64-musl': 16.2.3 + '@next/swc-linux-x64-gnu': 16.2.3 + '@next/swc-linux-x64-musl': 16.2.3 + '@next/swc-win32-arm64-msvc': 16.2.3 + '@next/swc-win32-x64-msvc': 16.2.3 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -14619,7 +14613,7 @@ snapshots: async: 3.2.6 debug: 4.4.3 pidusage: 2.0.21 - systeminformation: 5.31.6 + systeminformation: 5.31.0 tx2: 1.0.5 transitivePeerDependencies: - supports-color @@ -14667,7 +14661,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -15126,8 +15120,6 @@ snapshots: semver@7.7.4: {} - semver@7.8.0: {} - send@0.19.2: dependencies: debug: 2.6.9 @@ -15220,7 +15212,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -15365,7 +15357,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.2.0 + ip-address: 10.1.0 smart-buffer: 4.2.0 source-map-js@1.2.1: {} @@ -15563,7 +15555,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - systeminformation@5.31.6: + systeminformation@5.31.0: optional: true tagged-tag@1.0.0: {} @@ -16017,7 +16009,7 @@ snapshots: ws@8.18.3: {} - ws@8.20.1: {} + ws@8.19.0: {} y18n@4.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1dafec7..f355365f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/web/* - packages/web/frameworks/* - packages/ios/* + - packages/android/* - packages/node/node-sdk - packages/universal/* From 48f103a6c039aaeeaeee771232381788ccffceba Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 22 May 2026 15:49:29 +0200 Subject: [PATCH 02/66] Consolidate the duplicated iOS and Android JS bridge packages into a single @contentful/optimization-js-bridge under packages/universal that builds one shared src/index.ts into both native UMD bundles --- .../android/android-zipline-bridge/AGENTS.md | 23 - .../android-zipline-bridge/package.json | 30 -- .../android-zipline-bridge/rslib.config.ts | 66 --- .../tsconfig.build.json | 19 - packages/ios/ios-jsc-bridge/AGENTS.md | 40 -- packages/ios/ios-jsc-bridge/README.md | 61 --- packages/ios/ios-jsc-bridge/rslib.config.ts | 66 --- packages/ios/ios-jsc-bridge/src/index.ts | 488 ------------------ packages/ios/ios-jsc-bridge/tsconfig.json | 8 - .../optimization-js-bridge/AGENTS.md | 44 ++ .../optimization-js-bridge/README.md | 57 ++ .../optimization-js-bridge}/package.json | 4 +- .../optimization-js-bridge/rslib.config.ts | 89 ++++ .../optimization-js-bridge}/src/index.ts | 0 .../tsconfig.build.json | 2 +- .../optimization-js-bridge}/tsconfig.json | 0 pnpm-lock.yaml | 228 ++++---- 17 files changed, 311 insertions(+), 914 deletions(-) delete mode 100644 packages/android/android-zipline-bridge/AGENTS.md delete mode 100644 packages/android/android-zipline-bridge/package.json delete mode 100644 packages/android/android-zipline-bridge/rslib.config.ts delete mode 100644 packages/android/android-zipline-bridge/tsconfig.build.json delete mode 100644 packages/ios/ios-jsc-bridge/AGENTS.md delete mode 100644 packages/ios/ios-jsc-bridge/README.md delete mode 100644 packages/ios/ios-jsc-bridge/rslib.config.ts delete mode 100644 packages/ios/ios-jsc-bridge/src/index.ts delete mode 100644 packages/ios/ios-jsc-bridge/tsconfig.json create mode 100644 packages/universal/optimization-js-bridge/AGENTS.md create mode 100644 packages/universal/optimization-js-bridge/README.md rename packages/{ios/ios-jsc-bridge => universal/optimization-js-bridge}/package.json (71%) create mode 100644 packages/universal/optimization-js-bridge/rslib.config.ts rename packages/{android/android-zipline-bridge => universal/optimization-js-bridge}/src/index.ts (100%) rename packages/{ios/ios-jsc-bridge => universal/optimization-js-bridge}/tsconfig.build.json (85%) rename packages/{android/android-zipline-bridge => universal/optimization-js-bridge}/tsconfig.json (100%) diff --git a/packages/android/android-zipline-bridge/AGENTS.md b/packages/android/android-zipline-bridge/AGENTS.md deleted file mode 100644 index 43e47880..00000000 --- a/packages/android/android-zipline-bridge/AGENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -# AGENTS.md - -Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, then `packages/android/AGENTS.md`, -before this file. - -## Scope - -This package compiles the shared TypeScript bridge source into a UMD bundle for the Android SDK. The -bridge source (`src/index.ts`) is identical to `packages/ios/ios-jsc-bridge/src/index.ts` and must -stay in sync. - -## Local rules - -- Do not diverge bridge source from the iOS bridge without documenting the reason in this file. -- The postbuild script copies the UMD bundle into `../ContentfulOptimization/src/main/assets/`. Do - not hand-edit the asset copy. -- If a QuickJS-specific workaround is ever needed, isolate it here rather than in the shared bridge - source. - -## Commands - -- `pnpm build` — clean + build the UMD bundle and copy to Android assets -- `pnpm typecheck` — type-check bridge source diff --git a/packages/android/android-zipline-bridge/package.json b/packages/android/android-zipline-bridge/package.json deleted file mode 100644 index a03e8afd..00000000 --- a/packages/android/android-zipline-bridge/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@contentful/optimization-android-bridge", - "version": "0.0.0", - "license": "MIT", - "type": "module", - "main": "./dist/optimization-android-bridge.umd.js", - "files": [ - "dist/**/*" - ], - "scripts": { - "build": "pnpm clean && pnpm build:dist", - "build:ci": "pnpm build:dist", - "build:dist": "rslib build", - "postbuild": "cp dist/optimization-android-bridge.umd.js ../ContentfulOptimization/src/main/assets/", - "clean": "rimraf ./.rslib ./dist ./coverage .tsbuildinfo", - "test:unit": "echo 'No tests yet'", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@contentful/optimization-core": "workspace:*" - }, - "devDependencies": { - "@rslib/core": "catalog:", - "@types/node": "catalog:", - "build-tools": "workspace:*", - "rimraf": "catalog:", - "tslib": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/android/android-zipline-bridge/rslib.config.ts b/packages/android/android-zipline-bridge/rslib.config.ts deleted file mode 100644 index fcd67be9..00000000 --- a/packages/android/android-zipline-bridge/rslib.config.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { defineConfig } from '@rslib/core' -import { ensureUmdDefaultExport, getPackageName } from 'build-tools' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -/* eslint-disable @typescript-eslint/naming-convention -- standardized var names */ -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const packageName = getPackageName(__dirname, '@contentful/optimization-android-bridge') -/* eslint-enable @typescript-eslint/naming-convention -- standardized var names */ - -export default defineConfig({ - source: { - tsconfigPath: './tsconfig.build.json', - define: { - __OPTIMIZATION_VERSION__: JSON.stringify(process.env.RELEASE_VERSION ?? '0.0.0'), - __OPTIMIZATION_PACKAGE_NAME__: JSON.stringify(packageName), - }, - }, - - resolve: { - alias: { - '@contentful/optimization-api-client': path.resolve( - __dirname, - '../../universal/api-client/src/', - ), - '@contentful/optimization-api-schemas': path.resolve( - __dirname, - '../../universal/api-schemas/src/', - ), - '@contentful/optimization-core': path.resolve(__dirname, '../../universal/core-sdk/src/'), - }, - }, - - output: { - target: 'web', - }, - - lib: [ - { - bundle: true, - autoExtension: false, - autoExternal: false, - format: 'umd', - umdName: 'OptimizationBridge', - source: { - entry: { - 'optimization-android-bridge.umd': './src/index.ts', - }, - }, - output: { - distPath: { root: 'dist' }, - filename: { js: '[name].js' }, - sourceMap: true, - cleanDistPath: true, - minify: true, - }, - dts: false, - tools: { - rspack: (config) => { - ensureUmdDefaultExport(config) - }, - }, - }, - ], -}) diff --git a/packages/android/android-zipline-bridge/tsconfig.build.json b/packages/android/android-zipline-bridge/tsconfig.build.json deleted file mode 100644 index 61d32eb5..00000000 --- a/packages/android/android-zipline-bridge/tsconfig.build.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "incremental": true, - "noEmit": false, - "baseUrl": "./src", - "emitDeclarationOnly": true, - "lib": ["ESNext", "DOM"], - "module": "ESNext", - "moduleResolution": "bundler", - "outDir": "dist", - "rootDir": "./src", - "tsBuildInfoFile": "./.tsbuildinfo" - }, - "include": ["./src/**/*"], - "exclude": ["./src/**/*.test.*"], - "extends": "../../../tsconfig.base.json", - "references": [{ "path": "../../universal/core-sdk/tsconfig.build.json" }] -} diff --git a/packages/ios/ios-jsc-bridge/AGENTS.md b/packages/ios/ios-jsc-bridge/AGENTS.md deleted file mode 100644 index be786171..00000000 --- a/packages/ios/ios-jsc-bridge/AGENTS.md +++ /dev/null @@ -1,40 +0,0 @@ -# AGENTS.md - -Read the repository root `AGENTS.md`, `packages/AGENTS.md`, and `packages/ios/AGENTS.md` before this -file. - -## Scope - -This package owns the TypeScript adapter compiled into the JavaScriptCore bridge UMD consumed by the -native Swift package. - -## Key paths - -- `src/` -- `package.json` -- `rslib.config.ts` - -## Local rules - -- Keep the bridge API JavaScriptCore-friendly: JSON strings, callback pairs, and no browser-only or - Node-only assumptions unless the Swift polyfill layer explicitly provides them. -- Keep bridge state shapes aligned with Swift models in - `../ContentfulOptimization/Sources/ContentfulOptimization/Core/`. -- Keep preview override calls aligned with `@contentful/optimization-core/preview-support`. -- Do not hand-edit `dist/` or the copied Swift resource bundle output. Build this package to refresh - generated bridge artifacts. -- If bridge public behavior changes, validate the Swift package and native iOS reference app flows - that exercise that bridge behavior. - -## Commands - -- `pnpm --filter @contentful/optimization-ios-bridge typecheck` -- `pnpm --filter @contentful/optimization-ios-bridge build` - -## Usually validate - -- Run `typecheck` for TypeScript source changes. -- Run `build` for runtime, export, dependency, bundler config, or bridge contract changes. -- Run Swift package tests after bridge contract or payload-shape changes. -- Run targeted `implementations/ios-sdk` XCUITest coverage for preview-panel, tracking, or - JavaScriptCore lifecycle changes. diff --git a/packages/ios/ios-jsc-bridge/README.md b/packages/ios/ios-jsc-bridge/README.md deleted file mode 100644 index fd59253e..00000000 --- a/packages/ios/ios-jsc-bridge/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# iOS JavaScriptCore bridge (internal) - -> [!CAUTION] -> -> `@contentful/optimization-ios-bridge` is internal bridge infrastructure for the native iOS SDK. It -> is not an application-facing SDK package. iOS application integrations must use the Swift Package -> surface in [`../ContentfulOptimization`](../ContentfulOptimization/) instead. - -This package owns the TypeScript adapter compiled into the JavaScriptCore UMD bundle consumed by the -native Swift package. The bridge wraps `@contentful/optimization-core`, exposes a -JavaScriptCore-friendly callback API, and keeps the shared optimization state machine available to -Swift runtime code. - -## When to use this package - -Use this package when changing the JavaScriptCore bridge contract, callback payloads, preview -override calls, or the generated bridge bundle consumed by the native Swift SDK. Keep bridge shapes -aligned with Swift models under `../ContentfulOptimization/Sources/ContentfulOptimization/Core/`. - -## Package surface - -| Surface | Purpose | -| ----------------------- | ---------------------------------------------------------------------------- | -| TypeScript bridge entry | Wraps Core stateful behavior behind JavaScriptCore-friendly callbacks | -| Generated UMD bundle | Runtime artifact copied into Swift Package resources by the build flow | -| Swift model handoff | JSON payload shapes consumed by the native Swift SDK | -| Preview bridge calls | First-party preview override calls aligned with Core preview-support helpers | - -Application developers must not depend on this package. The public iOS integration surface is the -Swift Package under `../ContentfulOptimization/`. - -## Build flow - -Edit TypeScript source in this package, then build the bridge. The package `postbuild` step copies -`dist/optimization-ios-bridge.umd.js` into the Swift Package resources directory: - -```sh -pnpm --filter @contentful/optimization-ios-bridge build -``` - -Do not hand-edit `dist/` output or the copied Swift resource bundle. Regenerate it through the build -flow. - -## Commands - -Run commands from the monorepo root: - -```sh -pnpm --filter @contentful/optimization-ios-bridge typecheck -pnpm --filter @contentful/optimization-ios-bridge build -``` - -For bridge contract, payload-shape, preview, or lifecycle changes, also validate the Swift Package -or targeted iOS reference app flows that exercise the changed behavior. - -## Related - -- [iOS SDK package](../README.md) - Native iOS SDK status and package layout -- [iOS code map](../CODE_MAP.md) - Current native iOS architecture map -- [Core preview support](../../universal/core-sdk/src/preview-support/README.md) - Shared preview - override toolkit used by first-party preview surfaces diff --git a/packages/ios/ios-jsc-bridge/rslib.config.ts b/packages/ios/ios-jsc-bridge/rslib.config.ts deleted file mode 100644 index c59cae79..00000000 --- a/packages/ios/ios-jsc-bridge/rslib.config.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { defineConfig } from '@rslib/core' -import { ensureUmdDefaultExport, getPackageName } from 'build-tools' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -/* eslint-disable @typescript-eslint/naming-convention -- standardized var names */ -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const packageName = getPackageName(__dirname, '@contentful/optimization-ios-bridge') -/* eslint-enable @typescript-eslint/naming-convention -- standardized var names */ - -export default defineConfig({ - source: { - tsconfigPath: './tsconfig.build.json', - define: { - __OPTIMIZATION_VERSION__: JSON.stringify(process.env.RELEASE_VERSION ?? '0.0.0'), - __OPTIMIZATION_PACKAGE_NAME__: JSON.stringify(packageName), - }, - }, - - resolve: { - alias: { - '@contentful/optimization-api-client': path.resolve( - __dirname, - '../../universal/api-client/src/', - ), - '@contentful/optimization-api-schemas': path.resolve( - __dirname, - '../../universal/api-schemas/src/', - ), - '@contentful/optimization-core': path.resolve(__dirname, '../../universal/core-sdk/src/'), - }, - }, - - output: { - target: 'web', - }, - - lib: [ - { - bundle: true, - autoExtension: false, - autoExternal: false, - format: 'umd', - umdName: 'OptimizationBridge', - source: { - entry: { - 'optimization-ios-bridge.umd': './src/index.ts', - }, - }, - output: { - distPath: { root: 'dist' }, - filename: { js: '[name].js' }, - sourceMap: true, - cleanDistPath: true, - minify: true, - }, - dts: false, - tools: { - rspack: (config) => { - ensureUmdDefaultExport(config) - }, - }, - }, - ], -}) diff --git a/packages/ios/ios-jsc-bridge/src/index.ts b/packages/ios/ios-jsc-bridge/src/index.ts deleted file mode 100644 index 4a4f20a4..00000000 --- a/packages/ios/ios-jsc-bridge/src/index.ts +++ /dev/null @@ -1,488 +0,0 @@ -import type { Traits } from '@contentful/optimization-api-client/api-schemas' -import { - CoreStateful, - type CoreStatefulConfig, - effect, - signals, -} from '@contentful/optimization-core' -import { - type AudienceDefinition, - type ContentfulEntry, - type ExperienceDefinition, - PreviewOverrideManager, - buildPreviewModel, - createAudienceDefinitions, - createExperienceDefinitions, - createExperienceNameMap, -} from '@contentful/optimization-core/preview-support' - -type ResolveOptimizedEntryParams = Parameters - -interface BridgeConfig { - clientId: string - environment: string - experienceBaseUrl?: string - insightsBaseUrl?: string - defaults?: { - consent?: boolean - profile?: unknown - changes?: unknown - optimizations?: unknown - } -} - -interface BridgeState { - profile: unknown - consent: boolean | undefined - canPersonalize: boolean - changes: unknown - selectedPersonalizations: unknown -} - -interface TrackViewPayload { - componentId: string - viewId: string - experienceId?: string - variantIndex: number - viewDurationMs: number - sticky?: boolean -} - -interface TrackClickPayload { - componentId: string - experienceId?: string - variantIndex: number -} - -interface Bridge { - initialize(config: BridgeConfig): void - identify( - payload: { userId: string; traits?: Traits }, - onSuccess: (json: string) => void, - onError: (error: string) => void, - ): void - page( - payload: Record, - onSuccess: (json: string) => void, - onError: (error: string) => void, - ): void - getProfile(): string | null - getState(): string - destroy(): void - - // Async with callbacks - screen( - payload: { name: string; properties?: Record }, - onSuccess: (json: string) => void, - onError: (error: string) => void, - ): void - flush(onSuccess: (json: string) => void, onError: (error: string) => void): void - trackView( - payload: TrackViewPayload, - onSuccess: (json: string) => void, - onError: (error: string) => void, - ): void - trackClick( - payload: TrackClickPayload, - onSuccess: (json: string) => void, - onError: (error: string) => void, - ): void - - // Synchronous - consent(accept: boolean): void - reset(): void - personalizeEntry( - baseline: Record, - personalizations?: Array>, - ): string - setOnline(isOnline: boolean): void - - // Preview panel - setPreviewPanelOpen(open: boolean): void - overrideAudience(audienceId: string, qualified: boolean, experienceIds: string[]): void - overrideVariant(experienceId: string, variantIndex: number): void - resetAudienceOverride(audienceId: string): void - resetVariantOverride(experienceId: string): void - resetAllOverrides(): void - loadDefinitions(audienceEntries: unknown[], experienceEntries: unknown[]): string - getPreviewState(): string -} - -let instance: CoreStateful | null = null -let disposeEffect: (() => void) | null = null -let disposeEventEffect: (() => void) | null = null -let overrideManager: PreviewOverrideManager | null = null -let audienceDefinitions: AudienceDefinition[] | null = null -let experienceDefinitions: ExperienceDefinition[] | null = null -let audienceNameMap: Record = {} -let experienceNameMap: Record = {} - -const bridge: Bridge = { - initialize(config: BridgeConfig) { - if (instance) { - bridge.destroy() - } - - audienceDefinitions = null - experienceDefinitions = null - audienceNameMap = {} - experienceNameMap = {} - - const coreConfig: CoreStatefulConfig = { - clientId: config.clientId, - environment: config.environment, - api: { - experienceBaseUrl: config.experienceBaseUrl, - insightsBaseUrl: config.insightsBaseUrl, - }, - } - - instance = new CoreStateful(coreConfig) - - // Apply stored defaults before any other operations - if (config.defaults) { - if (config.defaults.consent !== undefined) { - instance.consent(config.defaults.consent) - } - if (config.defaults.profile !== undefined) { - signals.profile.value = config.defaults.profile as typeof signals.profile.value - } - if (config.defaults.changes !== undefined) { - signals.changes.value = config.defaults.changes as typeof signals.changes.value - } - if (config.defaults.optimizations !== undefined) { - signals.selectedOptimizations.value = config.defaults - .optimizations as typeof signals.selectedOptimizations.value - } - } - instance.consent(true) - - // Create the override manager — registers a state interceptor that - // preserves overrides across API refreshes and correctly appends - // new experience entries when overriding audiences the user was never in. - const g = globalThis as Record - - overrideManager = new PreviewOverrideManager({ - selectedOptimizations: signals.selectedOptimizations, - profile: signals.profile, - stateInterceptors: instance.interceptors.state, - onOverridesChanged: () => { - if (typeof g.__nativeOnOverridesChanged === 'function') { - ;(g.__nativeOnOverridesChanged as (json: string) => void)(bridge.getPreviewState()) - } - }, - }) - - disposeEffect = effect(() => { - const state: BridgeState = { - profile: signals.profile.value ?? null, - consent: signals.consent.value, - canPersonalize: signals.canOptimize.value, - changes: signals.changes.value ?? null, - selectedPersonalizations: signals.selectedOptimizations.value ?? null, - } - - if (typeof g.__nativeOnStateChange === 'function') { - ;(g.__nativeOnStateChange as (json: string) => void)(JSON.stringify(state)) - } - }) - - disposeEventEffect = effect(() => { - const evt = signals.event.value - if (evt && typeof g.__nativeOnEventEmitted === 'function') { - ;(g.__nativeOnEventEmitted as (json: string) => void)(JSON.stringify(evt)) - } - }) - }, - - identify(payload, onSuccess, onError) { - if (!instance) { - onError('SDK not initialized. Call initialize() first.') - return - } - - instance - .identify(payload) - .then((data) => { - onSuccess(JSON.stringify(data ?? null)) - }) - .catch((err: unknown) => { - onError(err instanceof Error ? err.message : String(err)) - }) - }, - - page(payload, onSuccess, onError) { - if (!instance) { - onError('SDK not initialized. Call initialize() first.') - return - } - - instance - .page(payload) - .then((data) => { - onSuccess(JSON.stringify(data ?? null)) - }) - .catch((err: unknown) => { - onError(err instanceof Error ? err.message : String(err)) - }) - }, - - screen(payload, onSuccess, onError) { - if (!instance) { - onError('SDK not initialized. Call initialize() first.') - return - } - - instance - .screen({ - name: payload.name, - properties: (payload.properties ?? {}) as Record, - }) - .then((data) => { - onSuccess(JSON.stringify(data ?? null)) - }) - .catch((err: unknown) => { - onError(err instanceof Error ? err.message : String(err)) - }) - }, - - flush(onSuccess, onError) { - if (!instance) { - onError('SDK not initialized. Call initialize() first.') - return - } - - instance - .flush() - .then(() => { - onSuccess(JSON.stringify(null)) - }) - .catch((err: unknown) => { - onError(err instanceof Error ? err.message : String(err)) - }) - }, - - trackView(payload, onSuccess, onError) { - if (!instance) { - onError('SDK not initialized. Call initialize() first.') - return - } - - instance - .trackView(payload) - .then((data) => { - onSuccess(JSON.stringify(data ?? null)) - }) - .catch((err: unknown) => { - onError(err instanceof Error ? err.message : String(err)) - }) - }, - - trackClick(payload, onSuccess, onError) { - if (!instance) { - onError('SDK not initialized. Call initialize() first.') - return - } - - instance - .trackClick(payload) - .then(() => { - onSuccess(JSON.stringify(null)) - }) - .catch((err: unknown) => { - onError(err instanceof Error ? err.message : String(err)) - }) - }, - - consent(accept: boolean) { - if (!instance) return - instance.consent(accept) - }, - - reset() { - if (!instance) return - overrideManager?.resetAll() - instance.reset() - }, - - setOnline(isOnline: boolean) { - signals.online.value = isOnline - }, - - personalizeEntry( - baseline: Record, - personalizations?: Array>, - ): string { - if (!instance) return JSON.stringify({ entry: baseline }) - const result = instance.resolveOptimizedEntry( - baseline as unknown as ResolveOptimizedEntryParams[0], - personalizations as unknown as ResolveOptimizedEntryParams[1], - ) - return JSON.stringify(result) - }, - - setPreviewPanelOpen(open: boolean) { - if (!instance) return - signals.previewPanelOpen.value = open - }, - - overrideAudience(audienceId: string, qualified: boolean, experienceIds: string[]) { - if (!overrideManager) return - if (qualified) { - overrideManager.activateAudience(audienceId, experienceIds) - } else { - overrideManager.deactivateAudience(audienceId, experienceIds) - } - }, - - overrideVariant(experienceId: string, variantIndex: number) { - overrideManager?.setVariantOverride(experienceId, variantIndex) - }, - - resetAudienceOverride(audienceId: string) { - overrideManager?.resetAudienceOverride(audienceId) - }, - - resetVariantOverride(experienceId: string) { - overrideManager?.resetOptimizationOverride(experienceId) - }, - - resetAllOverrides() { - overrideManager?.resetAll() - }, - - loadDefinitions(audienceEntries: unknown[], experienceEntries: unknown[]): string { - try { - const audEntries = audienceEntries as ContentfulEntry[] - const expEntries = experienceEntries as ContentfulEntry[] - - audienceDefinitions = createAudienceDefinitions(audEntries) - experienceDefinitions = createExperienceDefinitions(expEntries) - experienceNameMap = createExperienceNameMap(expEntries) - audienceNameMap = {} - for (const def of audienceDefinitions) { - audienceNameMap[def.id] = def.name - } - - return JSON.stringify({ - audienceCount: audienceDefinitions.length, - experienceCount: experienceDefinitions.length, - }) - } catch (err: unknown) { - audienceDefinitions = null - experienceDefinitions = null - audienceNameMap = {} - experienceNameMap = {} - return JSON.stringify({ - error: err instanceof Error ? err.message : String(err), - }) - } - }, - - getPreviewState(): string { - const overrides = overrideManager?.getOverrides() ?? { - audiences: {}, - selectedOptimizations: {}, - } - const baselineOptimizations = overrideManager?.getBaselineSelectedOptimizations() - - // Transform audience overrides to the shape Swift expects: Record - const audienceOverrides: Record = {} - for (const [id, aud] of Object.entries(overrides.audiences)) { - audienceOverrides[id] = aud.isActive - } - - // Transform variant overrides to the shape Swift expects: Record - const variantOverrides: Record = {} - for (const [id, opt] of Object.entries(overrides.selectedOptimizations)) { - variantOverrides[id] = opt.variantIndex - } - - // Derive default variant indices from the baseline - const defaultVariantIndices: Record = {} - if (baselineOptimizations) { - for (const sel of baselineOptimizations) { - if (variantOverrides[sel.experienceId] !== undefined) { - defaultVariantIndices[sel.experienceId] = sel.variantIndex - } - } - } - - // Compute the pre-baked UI model when definitions have been loaded by the host. - // Null when loadDefinitions() has not yet been called — iOS renders an empty state. - const previewModel = - audienceDefinitions && experienceDefinitions - ? { - ...buildPreviewModel({ - audienceDefinitions, - experienceDefinitions, - signals: { - profile: signals.profile.value, - selectedOptimizations: signals.selectedOptimizations.value, - consent: signals.consent.value, - isLoading: false, - }, - overrides, - baselineSelectedOptimizations: baselineOptimizations, - }), - audienceNameMap, - experienceNameMap, - } - : null - - return JSON.stringify({ - profile: signals.profile.value ?? null, - consent: signals.consent.value, - canPersonalize: signals.canOptimize.value, - changes: signals.changes.value ?? null, - selectedPersonalizations: signals.selectedOptimizations.value ?? null, - previewPanelOpen: signals.previewPanelOpen.value, - audienceOverrides, - variantOverrides, - defaultAudienceQualifications: overrideManager?.getBaselineAudienceQualifications() ?? {}, - defaultVariantIndices, - previewModel, - }) - }, - - getProfile(): string | null { - const p = signals.profile.value - return p ? JSON.stringify(p) : null - }, - - getState(): string { - const state: BridgeState = { - profile: signals.profile.value ?? null, - consent: signals.consent.value, - canPersonalize: signals.canOptimize.value, - changes: signals.changes.value ?? null, - selectedPersonalizations: signals.selectedOptimizations.value ?? null, - } - return JSON.stringify(state) - }, - - destroy() { - overrideManager?.destroy() - overrideManager = null - audienceDefinitions = null - experienceDefinitions = null - audienceNameMap = {} - experienceNameMap = {} - if (disposeEventEffect) { - disposeEventEffect() - disposeEventEffect = null - } - if (disposeEffect) { - disposeEffect() - disposeEffect = null - } - if (instance) { - instance.destroy() - instance = null - } - }, -} - -;(globalThis as Record).__bridge = bridge - -export default bridge diff --git a/packages/ios/ios-jsc-bridge/tsconfig.json b/packages/ios/ios-jsc-bridge/tsconfig.json deleted file mode 100644 index d557830f..00000000 --- a/packages/ios/ios-jsc-bridge/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "lib": ["ESNext", "DOM"], - "types": [] - }, - "extends": "../../../tsconfig.base.json", - "include": ["./src/**/*"] -} diff --git a/packages/universal/optimization-js-bridge/AGENTS.md b/packages/universal/optimization-js-bridge/AGENTS.md new file mode 100644 index 00000000..c473d960 --- /dev/null +++ b/packages/universal/optimization-js-bridge/AGENTS.md @@ -0,0 +1,44 @@ +# AGENTS.md + +Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, then +`packages/universal/AGENTS.md`, before this file. + +## Scope + +This package owns the shared TypeScript bridge source compiled into the UMD bundles consumed by both +native SDKs: the iOS Swift package (JavaScriptCore) and the Android Kotlin SDK (QuickJS). One +`src/index.ts` is the single source of truth — there is no separate per-platform bridge. + +## Key paths + +- `src/index.ts` — the shared bridge adapter over `@contentful/optimization-core` +- `rslib.config.ts` — builds one UMD bundle per native platform +- `package.json` — `postbuild` copies each bundle into its native SDK + +## Local rules + +- Keep the bridge engine-agnostic: JSON strings, callback pairs, and no browser-only or Node-only + assumptions. Both JavaScriptCore and QuickJS host this bundle; runtime gaps are filled by each + native polyfill layer, not here. +- The build emits two UMD bundles from the one source — `optimization-ios-bridge.umd.js` and + `optimization-android-bridge.umd.js`. They differ only in the `__OPTIMIZATION_PACKAGE_NAME__` + define, which Core stamps into the analytics `library.name`. Keep both platform names in + `rslib.config.ts` so iOS and Android events stay distinguishable. +- Keep bridge state shapes and method contracts aligned with both native model layers, under + `packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/` and + `packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/`. +- Keep preview override calls aligned with `@contentful/optimization-core/preview-support`. +- Do not hand-edit `dist/` or the copied native bundle outputs. Build this package to refresh them. + +## Commands + +- `pnpm --filter @contentful/optimization-js-bridge typecheck` +- `pnpm --filter @contentful/optimization-js-bridge build` + +## Usually validate + +- Run `typecheck` for TypeScript source changes. +- Run `build` for runtime, export, dependency, bundler config, or bridge contract changes — it + refreshes both native bundles. +- After bridge contract or payload-shape changes, validate the iOS Swift package and the Android + Kotlin SDK, plus targeted reference-app coverage that exercises the changed behavior. diff --git a/packages/universal/optimization-js-bridge/README.md b/packages/universal/optimization-js-bridge/README.md new file mode 100644 index 00000000..ad9166d1 --- /dev/null +++ b/packages/universal/optimization-js-bridge/README.md @@ -0,0 +1,57 @@ +# Shared JavaScript bridge (internal) + +> [!CAUTION] +> +> `@contentful/optimization-js-bridge` is internal bridge infrastructure for the native iOS and +> Android SDKs. It is not an application-facing SDK package. Native application integrations must +> use the iOS Swift Package or the Android Kotlin library instead. + +This package owns the TypeScript adapter compiled into the UMD bundles consumed by the native iOS +SDK (JavaScriptCore) and the native Android SDK (QuickJS). One bridge source wraps +`@contentful/optimization-core`, exposes an engine-friendly callback API, and keeps the shared +optimization state machine available to native runtime code. + +## When to use this package + +Use this package when changing the native bridge contract, callback payloads, preview override +calls, or the generated bridge bundles consumed by the native SDKs. A single `src/index.ts` serves +both platforms — there is no separate per-platform bridge to keep in sync. + +## Build flow + +Edit the TypeScript source, then build. `rslib` compiles `src/index.ts` into two UMD bundles, and +the `postbuild` step copies each into its native SDK: + +| Bundle | Destination | +| ------------------------------------ | ---------------------------------- | +| `optimization-ios-bridge.umd.js` | iOS Swift Package resources | +| `optimization-android-bridge.umd.js` | Android library `src/main/assets/` | + +The two bundles are identical apart from the `library.name` analytics identifier, kept +platform-specific so iOS and Android events remain distinguishable. + +```sh +pnpm --filter @contentful/optimization-js-bridge build +``` + +Do not hand-edit `dist/` output or the copied native bundles. Regenerate them through the build +flow. + +## Commands + +Run commands from the monorepo root: + +```sh +pnpm --filter @contentful/optimization-js-bridge typecheck +pnpm --filter @contentful/optimization-js-bridge build +``` + +For bridge contract, payload-shape, preview, or lifecycle changes, also validate the native iOS and +Android SDKs and the reference apps that exercise the changed behavior. + +## Related + +- [iOS SDK package](../../ios/README.md) - Native iOS SDK status and package layout +- [Android SDK package](../../android/README.md) - Native Android SDK status and package layout +- [Core preview support](../core-sdk/src/preview-support/README.md) - Shared preview override + toolkit diff --git a/packages/ios/ios-jsc-bridge/package.json b/packages/universal/optimization-js-bridge/package.json similarity index 71% rename from packages/ios/ios-jsc-bridge/package.json rename to packages/universal/optimization-js-bridge/package.json index 721cc81e..1ada5564 100644 --- a/packages/ios/ios-jsc-bridge/package.json +++ b/packages/universal/optimization-js-bridge/package.json @@ -1,5 +1,5 @@ { - "name": "@contentful/optimization-ios-bridge", + "name": "@contentful/optimization-js-bridge", "version": "0.0.0", "license": "MIT", "type": "module", @@ -11,7 +11,7 @@ "build": "pnpm clean && pnpm build:dist", "build:ci": "pnpm build:dist", "build:dist": "rslib build", - "postbuild": "cp dist/optimization-ios-bridge.umd.js ../ContentfulOptimization/Sources/ContentfulOptimization/Resources/", + "postbuild": "cp dist/optimization-ios-bridge.umd.js ../../ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/ && cp dist/optimization-android-bridge.umd.js ../../android/ContentfulOptimization/src/main/assets/", "clean": "rimraf ./.rslib ./dist ./coverage .tsbuildinfo", "test:unit": "echo 'No tests yet'", "typecheck": "tsc --noEmit" diff --git a/packages/universal/optimization-js-bridge/rslib.config.ts b/packages/universal/optimization-js-bridge/rslib.config.ts new file mode 100644 index 00000000..9898f664 --- /dev/null +++ b/packages/universal/optimization-js-bridge/rslib.config.ts @@ -0,0 +1,89 @@ +import { defineConfig } from '@rslib/core' +import { ensureUmdDefaultExport } from 'build-tools' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/* eslint-disable @typescript-eslint/naming-convention -- standardized var names */ +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +/* eslint-enable @typescript-eslint/naming-convention -- standardized var names */ + +const optimizationVersion = JSON.stringify(process.env.RELEASE_VERSION ?? '0.0.0') + +// One shared bridge source compiles to a UMD bundle per native platform. The two +// bundles are identical apart from __OPTIMIZATION_PACKAGE_NAME__, which Core stamps +// into the analytics `library.name` field — kept platform-specific so iOS and +// Android events stay distinguishable downstream. +const commonLib = { + bundle: true, + autoExtension: false, + autoExternal: false, + format: 'umd', + umdName: 'OptimizationBridge', + dts: false, +} as const + +const commonOutput = { + distPath: { root: 'dist' }, + filename: { js: '[name].js' }, + sourceMap: true, + cleanDistPath: false, + minify: true, +} as const + +export default defineConfig({ + source: { + tsconfigPath: './tsconfig.build.json', + }, + + resolve: { + alias: { + '@contentful/optimization-api-client': path.resolve(__dirname, '../api-client/src/'), + '@contentful/optimization-api-schemas': path.resolve(__dirname, '../api-schemas/src/'), + '@contentful/optimization-core': path.resolve(__dirname, '../core-sdk/src/'), + }, + }, + + output: { + target: 'web', + }, + + lib: [ + { + ...commonLib, + source: { + entry: { + 'optimization-ios-bridge.umd': './src/index.ts', + }, + define: { + __OPTIMIZATION_VERSION__: optimizationVersion, + __OPTIMIZATION_PACKAGE_NAME__: JSON.stringify('@contentful/optimization-ios-bridge'), + }, + }, + output: { ...commonOutput }, + tools: { + rspack: (config) => { + ensureUmdDefaultExport(config) + }, + }, + }, + { + ...commonLib, + source: { + entry: { + 'optimization-android-bridge.umd': './src/index.ts', + }, + define: { + __OPTIMIZATION_VERSION__: optimizationVersion, + __OPTIMIZATION_PACKAGE_NAME__: JSON.stringify('@contentful/optimization-android-bridge'), + }, + }, + output: { ...commonOutput }, + tools: { + rspack: (config) => { + ensureUmdDefaultExport(config) + }, + }, + }, + ], +}) diff --git a/packages/android/android-zipline-bridge/src/index.ts b/packages/universal/optimization-js-bridge/src/index.ts similarity index 100% rename from packages/android/android-zipline-bridge/src/index.ts rename to packages/universal/optimization-js-bridge/src/index.ts diff --git a/packages/ios/ios-jsc-bridge/tsconfig.build.json b/packages/universal/optimization-js-bridge/tsconfig.build.json similarity index 85% rename from packages/ios/ios-jsc-bridge/tsconfig.build.json rename to packages/universal/optimization-js-bridge/tsconfig.build.json index 61d32eb5..887db5ba 100644 --- a/packages/ios/ios-jsc-bridge/tsconfig.build.json +++ b/packages/universal/optimization-js-bridge/tsconfig.build.json @@ -15,5 +15,5 @@ "include": ["./src/**/*"], "exclude": ["./src/**/*.test.*"], "extends": "../../../tsconfig.base.json", - "references": [{ "path": "../../universal/core-sdk/tsconfig.build.json" }] + "references": [{ "path": "../core-sdk/tsconfig.build.json" }] } diff --git a/packages/android/android-zipline-bridge/tsconfig.json b/packages/universal/optimization-js-bridge/tsconfig.json similarity index 100% rename from packages/android/android-zipline-bridge/tsconfig.json rename to packages/universal/optimization-js-bridge/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 875d7970..bd96d503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -662,8 +662,8 @@ importers: specifier: 'catalog:' version: 20.8.9 next: - specifier: ^16.2.3 - version: 16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^16.2.6 + version: 16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-router-dom: specifier: ^7.14.2 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1291,8 +1291,8 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -2002,57 +2002,57 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - '@next/env@16.2.3': - resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/swc-darwin-arm64@16.2.3': - resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.3': - resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.3': - resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.3': - resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.3': - resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.3': - resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.3': - resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.3': - resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2768,6 +2768,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3147,11 +3148,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} - - axios@1.14.0: - resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -3217,15 +3215,14 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.17: - resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.1.0: - resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.1, please upgrade bfj@9.1.3: resolution: {integrity: sha512-1ythbcNNAd2UjTYW6M+MAHd9KM/m3g4mQ+3a4Vom16WgmUa4GsisdmXAYfpAjkObY5zdpgzaBh1ctZOEcJipuQ==} @@ -3336,8 +3333,8 @@ packages: camelo@1.2.2: resolution: {integrity: sha512-4HmYiO74VXQM6NFE2euk2p/6hBJeYDDf6k3ZMIvTCWhoAm6RvUiWkhxjkJsztcqEYt7g/jgr5en6R/TPaFccXw==} - caniuse-lite@1.0.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} cardinal@2.1.1: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} @@ -4364,8 +4361,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-xml-parser@4.5.4: resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==} @@ -4485,8 +4482,8 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -4898,6 +4895,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5931,8 +5932,8 @@ packages: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -5965,8 +5966,8 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - next@16.2.3: - resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -6767,6 +6768,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -7124,8 +7130,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - systeminformation@5.31.0: - resolution: {integrity: sha512-z5pjzvC8UnQJ/iu34z+mo3lAeMzTGdArjPQoG5uPyV5XY4BY+M6ZcRTl4XnZqudz6sP713LhWMKv6e0kGFGCgQ==} + systeminformation@5.31.6: + resolution: {integrity: sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==} engines: {node: '>=8.0.0'} os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true @@ -7414,6 +7420,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -7422,6 +7429,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -7571,8 +7579,8 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -8279,7 +8287,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.2': + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true @@ -8558,7 +8566,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.9.2 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -9037,41 +9045,41 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.9.2 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.9.2 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.3': {} + '@next/env@16.2.6': {} - '@next/swc-darwin-arm64@16.2.3': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@16.2.3': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.3': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.2.3': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.2.3': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.2.3': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.3': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.2.3': + '@next/swc-win32-x64-msvc@16.2.6': optional: true '@nodelib/fs.scandir@2.1.5': @@ -10061,7 +10069,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -10225,7 +10233,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -10400,21 +10408,15 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.13.6: + axios@1.16.1: dependencies: - follow-redirects: 1.15.11(debug@4.3.7) - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axios@1.14.0: - dependencies: - follow-redirects: 1.15.11(debug@4.3.7) + follow-redirects: 1.16.0(debug@4.3.7) form-data: 4.0.5 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color babel-jest@29.7.0(@babel/core@7.29.0): dependencies: @@ -10525,9 +10527,9 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.17: {} + baseline-browser-mapping@2.10.29: {} - basic-ftp@5.1.0: {} + basic-ftp@5.3.1: {} bfj@9.1.3: dependencies: @@ -10623,8 +10625,8 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.17 - caniuse-lite: 1.0.30001787 + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -10674,7 +10676,7 @@ snapshots: regex-escape: 3.4.11 uc-first-array: 1.1.11 - caniuse-lite@1.0.30001787: {} + caniuse-lite@1.0.30001792: {} cardinal@2.1.1: dependencies: @@ -11046,7 +11048,7 @@ snapshots: contentful-export@7.22.5: dependencies: - axios: 1.14.0 + axios: 1.16.1 bfj: 9.1.3 bluebird: 3.7.2 cli-table3: 0.6.5 @@ -11097,17 +11099,18 @@ snapshots: contentful-management@11.75.0: dependencies: '@contentful/rich-text-types': 16.8.5 - axios: 1.14.0 + axios: 1.16.1 contentful-sdk-core: 9.4.5 fast-copy: 3.0.2 globals: 15.15.0 transitivePeerDependencies: - debug + - supports-color contentful-migration@4.32.0(@types/node@24.10.13)(enquirer@2.3.6): dependencies: '@hapi/hoek': 11.0.7 - axios: 1.14.0 + axios: 1.16.1 bluebird: 3.7.2 callsites: 3.1.0 cardinal: 2.1.1 @@ -11155,7 +11158,7 @@ snapshots: dependencies: '@contentful/content-source-maps': 0.11.44 '@contentful/rich-text-types': 16.8.5 - axios: 1.13.6 + axios: 1.16.1 contentful-resolve-response: 1.9.6 contentful-sdk-core: 9.4.4 json-stringify-safe: 5.0.1 @@ -11163,6 +11166,7 @@ snapshots: type-fest: 4.41.0 transitivePeerDependencies: - debug + - supports-color conventional-commit-types@3.0.0: {} @@ -11678,12 +11682,12 @@ snapshots: eslint-compat-utils@0.5.1(eslint@10.0.0(jiti@2.6.1)): dependencies: eslint: 10.0.0(jiti@2.6.1) - semver: 7.7.4 + semver: 7.8.0 eslint-compat-utils@0.5.1(eslint@8.57.1): dependencies: eslint: 8.57.1 - semver: 7.7.4 + semver: 7.8.0 eslint-config-love@121.0.0(@typescript-eslint/parser@8.56.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3): dependencies: @@ -12141,7 +12145,7 @@ snapshots: extrareqp2@1.0.0(debug@4.3.7): dependencies: - follow-redirects: 1.15.11(debug@4.3.7) + follow-redirects: 1.16.0(debug@4.3.7) transitivePeerDependencies: - debug @@ -12165,7 +12169,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fast-xml-parser@4.5.4: dependencies: @@ -12304,7 +12308,7 @@ snapshots: flow-enums-runtime@0.0.6: {} - follow-redirects@1.15.11(debug@4.3.7): + follow-redirects@1.16.0(debug@4.3.7): optionalDependencies: debug: 4.3.7 @@ -12425,7 +12429,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.1.0 + basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -12516,7 +12520,7 @@ snapshots: '@types/ws': 8.18.1 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.19.0 + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -12782,6 +12786,8 @@ snapshots: ip-address@10.1.0: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -12985,7 +12991,7 @@ snapshots: '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 + semver: 7.8.0 transitivePeerDependencies: - supports-color @@ -13424,7 +13430,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.7.4 + semver: 7.8.0 transitivePeerDependencies: - supports-color @@ -13623,7 +13629,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.4 + semver: 7.8.0 jwa@2.0.1: dependencies: @@ -13883,7 +13889,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 makeerror@1.0.12: dependencies: @@ -14231,7 +14237,7 @@ snapshots: nano-spawn@2.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} napi-postinstall@0.3.4: {} @@ -14253,25 +14259,25 @@ snapshots: netmask@2.0.2: {} - next@16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@next/env': 16.2.3 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.17 - caniuse-lite: 1.0.30001787 + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 postcss: 8.4.31 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) styled-jsx: 5.1.6(react@19.2.5) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.3 - '@next/swc-darwin-x64': 16.2.3 - '@next/swc-linux-arm64-gnu': 16.2.3 - '@next/swc-linux-arm64-musl': 16.2.3 - '@next/swc-linux-x64-gnu': 16.2.3 - '@next/swc-linux-x64-musl': 16.2.3 - '@next/swc-win32-arm64-msvc': 16.2.3 - '@next/swc-win32-x64-msvc': 16.2.3 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -14613,7 +14619,7 @@ snapshots: async: 3.2.6 debug: 4.4.3 pidusage: 2.0.21 - systeminformation: 5.31.0 + systeminformation: 5.31.6 tx2: 1.0.5 transitivePeerDependencies: - supports-color @@ -14661,7 +14667,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -15120,6 +15126,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -15212,7 +15220,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -15357,7 +15365,7 @@ snapshots: socks@2.8.7: dependencies: - ip-address: 10.1.0 + ip-address: 10.2.0 smart-buffer: 4.2.0 source-map-js@1.2.1: {} @@ -15555,7 +15563,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - systeminformation@5.31.0: + systeminformation@5.31.6: optional: true tagged-tag@1.0.0: {} @@ -16009,7 +16017,7 @@ snapshots: ws@8.18.3: {} - ws@8.19.0: {} + ws@8.20.1: {} y18n@4.0.3: {} From 0144b89c8efe54ed91629437d2c9966fa289eb9e Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 22 May 2026 15:49:42 +0200 Subject: [PATCH 03/66] Repoint the iOS build script and the iOS and Android SDK docs at the merged @contentful/optimization-js-bridge package --- packages/android/AGENTS.md | 12 ++++++------ packages/android/ContentfulOptimization/AGENTS.md | 8 ++++---- packages/android/README.md | 2 +- packages/ios/AGENTS.md | 7 +++---- packages/ios/CODE_MAP.md | 10 +++++----- packages/ios/ContentfulOptimization/AGENTS.md | 11 ++++++----- .../ContentfulOptimization/Core/PreviewState.swift | 2 +- packages/ios/README.md | 9 +++++---- .../universal/core-sdk/src/preview-support/README.md | 3 +-- scripts/build-ios-sdk.sh | 2 +- 10 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/android/AGENTS.md b/packages/android/AGENTS.md index 33000cd8..513f28c0 100644 --- a/packages/android/AGENTS.md +++ b/packages/android/AGENTS.md @@ -4,20 +4,20 @@ Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, before this fil ## Scope -This directory owns native Android package work, including the Kotlin Android library module under -`ContentfulOptimization/` and the Zipline (QuickJS) bridge package under `android-zipline-bridge/`. +This directory owns native Android package work: the Kotlin Android library module under +`ContentfulOptimization/`. The shared JS bridge it consumes lives under +`packages/universal/optimization-js-bridge/`. ## Key paths - `ContentfulOptimization/` — Android library module (AAR), public Kotlin API, native runtime, Compose UI, preview panel, assets, and tests -- `android-zipline-bridge/` — TypeScript bridge compiled to a QuickJS-compatible UMD bundle - `README.md` — package status and public-facing notes ## Local rules - Keep Kotlin bridge calls, JSON payload shapes, and callback behavior aligned with - `android-zipline-bridge/src/index.ts`. + `packages/universal/optimization-js-bridge/src/index.ts`. - Keep the bridge bundle flow one-way: edit TypeScript bridge source, build the bridge package, and let its build copy the generated UMD into Android assets. - Do not hand-edit `ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js`. @@ -30,5 +30,5 @@ This directory owns native Android package work, including the Kotlin Android li - Use the nearest child `AGENTS.md` for bridge or Kotlin module commands. - Rebuild the bridge before relying on Kotlin or Android test results when bridge source changed. -- Keep the bridge source (`android-zipline-bridge/src/index.ts`) in sync with - `packages/ios/ios-jsc-bridge/src/index.ts`. +- The bridge source is shared at `packages/universal/optimization-js-bridge/src/index.ts` — one + `src/index.ts` builds both native bundles, so there is no per-platform bridge to keep in sync. diff --git a/packages/android/ContentfulOptimization/AGENTS.md b/packages/android/ContentfulOptimization/AGENTS.md index 0009a2ef..c90dbab7 100644 --- a/packages/android/ContentfulOptimization/AGENTS.md +++ b/packages/android/ContentfulOptimization/AGENTS.md @@ -22,8 +22,8 @@ public API surface. (OptimizationRoot, OptimizedEntry, LazyColumn tracking, screen/click/view tracking) - `src/main/kotlin/com/contentful/optimization/preview/` — preview panel UI (theme, components, overlay, ViewModel, Contentful client, Activity) -- `src/main/assets/` — JS bridge bundle and polyfill scripts (copied from android-zipline-bridge - build) +- `src/main/assets/` — JS bridge bundle and polyfill scripts (copied from the + `@contentful/optimization-js-bridge` build) ## Local rules @@ -34,7 +34,7 @@ public API surface. - Do not hand-edit files in `src/main/assets/`. They are copied from the bridge build and iOS polyfill sources. - Keep bridge call signatures and JSON payload shapes aligned with - `android-zipline-bridge/src/index.ts`. + `packages/universal/optimization-js-bridge/src/index.ts`. - Keep Compose UI components aligned with iOS SwiftUI views when changing shared tracking or preview contracts. - `PreviewPanelActivity` uses static client references for View-based app integration. Keep this @@ -45,5 +45,5 @@ public API surface. ## Commands - Gradle build commands require Android SDK. Use `./gradlew build` from this directory. -- Run `pnpm --filter @contentful/optimization-android-bridge build` to rebuild the JS bridge bundle +- Run `pnpm --filter @contentful/optimization-js-bridge build` to rebuild the JS bridge bundle before Gradle build. diff --git a/packages/android/README.md b/packages/android/README.md index 4341a296..b891dee7 100644 --- a/packages/android/README.md +++ b/packages/android/README.md @@ -11,7 +11,7 @@ batching, and preview overrides. - Kotlin Android library module under `ContentfulOptimization/` - Zipline (QuickJS) JavaScript engine integration -- Shared TypeScript bridge under `android-zipline-bridge/` +- Shared TypeScript bridge under `packages/universal/optimization-js-bridge/` - Jetpack Compose UI layer (OptimizationRoot, OptimizedEntry, scroll/view/click tracking) - Preview panel with audience/experience overrides, variant selection, and Contentful integration - View-based app support via `PreviewPanelActivity` diff --git a/packages/ios/AGENTS.md b/packages/ios/AGENTS.md index a78d9b51..3ad13ce6 100644 --- a/packages/ios/AGENTS.md +++ b/packages/ios/AGENTS.md @@ -4,20 +4,19 @@ Read the repository root `AGENTS.md`, then `packages/AGENTS.md`, before this fil ## Scope -This directory owns native iOS package work, including the Swift Package under -`ContentfulOptimization/` and the JavaScriptCore bridge package under `ios-jsc-bridge/`. +This directory owns native iOS package work: the Swift Package under `ContentfulOptimization/`. The +shared JavaScriptCore bridge it consumes lives under `packages/universal/optimization-js-bridge/`. ## Key paths - `ContentfulOptimization/` - Swift Package, public Swift API, native runtime, resources, and tests -- `ios-jsc-bridge/` - TypeScript bridge compiled to the JavaScriptCore UMD bundle - `CODE_MAP.md` - current architecture map for native iOS work - `README.md` - package status and public-facing notes ## Local rules - Keep Swift bridge calls, JSON payload shapes, and callback behavior aligned with - `ios-jsc-bridge/src/index.ts`. + `packages/universal/optimization-js-bridge/src/index.ts`. - Keep the bridge bundle flow one-way: edit TypeScript bridge source, build the bridge package, and let its build copy the generated UMD into Swift package resources. - Do not hand-edit diff --git a/packages/ios/CODE_MAP.md b/packages/ios/CODE_MAP.md index 180187cc..83377046 100644 --- a/packages/ios/CODE_MAP.md +++ b/packages/ios/CODE_MAP.md @@ -11,10 +11,10 @@ management, and analytics batching. The architecture has two main sub-packages: -| Sub-package | Language | Purpose | -| ------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------- | -| `ios-jsc-bridge/` | TypeScript | Thin adapter wrapping `CoreStateful` from the optimization library, exposing a callback-based API for JavaScriptCore | -| `ContentfulOptimization/` | Swift | SPM library providing public API, SwiftUI views, tracking, persistence, and the JSContext lifecycle | +| Sub-package | Language | Purpose | +| -------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------- | +| `packages/universal/optimization-js-bridge/` | TypeScript | Thin adapter wrapping `CoreStateful` from the optimization library, exposing a callback-based API for JavaScriptCore | +| `ContentfulOptimization/` | Swift | SPM library providing public API, SwiftUI views, tracking, persistence, and the JSContext lifecycle | --- @@ -50,7 +50,7 @@ graph TB UMD["optimization-ios-bridge.umd.js\nCompiled TS bridge bundle —\nexposes globalThis.__bridge"] end - subgraph "TypeScript Bridge (ios-jsc-bridge/)" + subgraph "TypeScript Bridge (packages/universal/optimization-js-bridge/)" TSB["Bridge (index.ts)\nWraps CoreStateful, exposes\ncallback-based API on globalThis.__bridge"] end diff --git a/packages/ios/ContentfulOptimization/AGENTS.md b/packages/ios/ContentfulOptimization/AGENTS.md index 0287a408..3905a6eb 100644 --- a/packages/ios/ContentfulOptimization/AGENTS.md +++ b/packages/ios/ContentfulOptimization/AGENTS.md @@ -18,12 +18,13 @@ Swift tests. ## Local rules -- Keep native runtime concerns here. TypeScript bridge behavior belongs in `../ios-jsc-bridge/`; - shared optimization behavior belongs in `packages/universal/core-sdk`. +- Keep native runtime concerns here. TypeScript bridge behavior belongs in + `../../universal/optimization-js-bridge/`; shared optimization behavior belongs in + `packages/universal/core-sdk`. - Treat `Resources/optimization-ios-bridge.umd.js` as generated bridge output. Update it by building - `@contentful/optimization-ios-bridge`, not by hand-editing the copied file. + `@contentful/optimization-js-bridge`, not by hand-editing the copied file. - Keep Swift payload models and bridge method expectations aligned with - `../ios-jsc-bridge/src/index.ts`. + `../../universal/optimization-js-bridge/src/index.ts`. - Keep resource additions reflected in `Package.swift` when they must ship with the Swift package. - Preserve the package platform constraints in `Package.swift` unless the task explicitly changes supported platforms. @@ -33,7 +34,7 @@ Swift tests. ## Commands - From `packages/ios/ContentfulOptimization/`: `swift test` -- `pnpm --filter @contentful/optimization-ios-bridge build` +- `pnpm --filter @contentful/optimization-js-bridge build` ## Usually validate diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/PreviewState.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/PreviewState.swift index d003452c..ee3b2f88 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/PreviewState.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/PreviewState.swift @@ -3,7 +3,7 @@ import Foundation /// Typed snapshot of the preview/debug state from the JS bridge. /// /// Decoded from the JSON string returned by the bridge's `getPreviewState()` method. -/// The shape mirrors the object built in `ios-jsc-bridge/src/index.ts`. +/// The shape mirrors the object built in `optimization-js-bridge/src/index.ts`. public struct PreviewState: Codable, Sendable { public let profile: JSONValue? public let consent: Bool? diff --git a/packages/ios/README.md b/packages/ios/README.md index a9adb3a7..baef6166 100644 --- a/packages/ios/README.md +++ b/packages/ios/README.md @@ -39,8 +39,9 @@ persistence, networking, lifecycle handling, SwiftUI views, and preview-panel UI ## Current status - The native Swift package exists under [`ContentfulOptimization`](./ContentfulOptimization/). -- The JavaScriptCore adapter package lives under [`ios-jsc-bridge`](./ios-jsc-bridge/README.md) and - compiles the bridge bundle consumed by Swift. +- The shared JavaScriptCore adapter package lives under + [`optimization-js-bridge`](../universal/optimization-js-bridge/README.md) and compiles the bridge + bundle consumed by Swift. - The native [iOS reference app](../../implementations/ios-sdk/README.md) validates current bridge and preview-panel behavior against the shared mock API. - This surface is alpha implementation work. Treat the API, setup flow, and bridge contract as @@ -57,8 +58,8 @@ stable mobile integration can start with the JavaScript - [`ContentfulOptimization/`](./ContentfulOptimization/) - Swift Package source, public Swift API, native runtime, resources, and tests -- [`ios-jsc-bridge/`](./ios-jsc-bridge/README.md) - internal TypeScript bridge compiled into the - JavaScriptCore UMD bundle consumed by the Swift Package +- [`optimization-js-bridge/`](../universal/optimization-js-bridge/README.md) - shared internal + TypeScript bridge compiled into the JavaScriptCore UMD bundle consumed by the Swift Package - [`CODE_MAP.md`](./CODE_MAP.md) - architecture map for the current native iOS implementation ## Related diff --git a/packages/universal/core-sdk/src/preview-support/README.md b/packages/universal/core-sdk/src/preview-support/README.md index 61c502d0..9266616d 100644 --- a/packages/universal/core-sdk/src/preview-support/README.md +++ b/packages/universal/core-sdk/src/preview-support/README.md @@ -77,8 +77,7 @@ snapshots into the preview-support helpers. - [Core SDK README](../../README.md) - package-level Core orientation - [React Native SDK README](../../../../react-native-sdk/README.md) - mobile preview-panel surface -- [iOS JavaScriptCore bridge README](../../../../ios/ios-jsc-bridge/README.md) - native bridge - consumer +- [Shared JS bridge README](../../../optimization-js-bridge/README.md) - native bridge consumer - [Core SDK generated reference](https://contentful.github.io/optimization/modules/_contentful_optimization-core.html) - exported Core reference, including preview-support types that are re-exported through first-party packages diff --git a/scripts/build-ios-sdk.sh b/scripts/build-ios-sdk.sh index 6e6e6ac8..cb384624 100755 --- a/scripts/build-ios-sdk.sh +++ b/scripts/build-ios-sdk.sh @@ -3,7 +3,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -BRIDGE_DIR="$ROOT_DIR/packages/ios/ios-jsc-bridge" +BRIDGE_DIR="$ROOT_DIR/packages/universal/optimization-js-bridge" PACKAGE_DIR="$ROOT_DIR/packages/ios/ContentfulOptimization" RESOURCES_DIR="$PACKAGE_DIR/Sources/ContentfulOptimization/Resources" From fd8665d535ce5012edd3c497612cd25594256200 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 22 May 2026 15:49:45 +0200 Subject: [PATCH 04/66] Restore the Android JS bridge bundle to its minified build output, regenerated by the merged bridge package --- .../assets/optimization-android-bridge.umd.js | 4205 +---------------- 1 file changed, 2 insertions(+), 4203 deletions(-) diff --git a/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js b/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js index d450135d..2e661cdf 100644 --- a/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js +++ b/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js @@ -1,4203 +1,2 @@ -!(function (e, t) { - 'object' == typeof exports && 'object' == typeof module - ? (module.exports = t()) - : 'function' == typeof define && define.amd - ? define([], t) - : 'object' == typeof exports - ? (exports.OptimizationBridge = t()) - : (e.OptimizationBridge = t()) -})(globalThis, () => - (() => { - 'use strict' - let e, t, i, n, r, s, o - var a, - l = {} - ;((l.d = (e, t) => { - for (var i in t) - l.o(t, i) && !l.o(e, i) && Object.defineProperty(e, i, { enumerable: !0, get: t[i] }) - }), - (l.o = (e, t) => Object.prototype.hasOwnProperty.call(e, t))) - var u = {} - l.d(u, { default: () => rk }) - var c = Symbol.for('preact-signals') - function d() { - if (g > 1) g-- - else { - for (var e, t = !1; void 0 !== y; ) { - var i = y - for (y = void 0, m++; void 0 !== i; ) { - var n = i.o - if (((i.o = void 0), (i.f &= -3), !(8 & i.f) && O(i))) - try { - i.c() - } catch (i) { - t || ((e = i), (t = !0)) - } - i = n - } - } - if (((m = 0), g--, t)) throw e - } - } - function p(e) { - if (g > 0) return e() - g++ - try { - return e() - } finally { - d() - } - } - var h = void 0 - function f(e) { - var t = h - h = void 0 - try { - return e() - } finally { - h = t - } - } - var v, - y = void 0, - g = 0, - m = 0, - b = 0 - function w(e) { - if (void 0 !== h) { - var t = e.n - if (void 0 === t || t.t !== h) - return ( - (t = { i: 0, S: e, p: h.s, n: void 0, t: h, e: void 0, x: void 0, r: t }), - void 0 !== h.s && (h.s.n = t), - (h.s = t), - (e.n = t), - 32 & h.f && e.S(t), - t - ) - if (-1 === t.i) - return ( - (t.i = 0), - void 0 !== t.n && - ((t.n.p = t.p), - void 0 !== t.p && (t.p.n = t.n), - (t.p = h.s), - (t.n = void 0), - (h.s.n = t), - (h.s = t)), - t - ) - } - } - function _(e, t) { - ;((this.v = e), - (this.i = 0), - (this.n = void 0), - (this.t = void 0), - (this.W = null == t ? void 0 : t.watched), - (this.Z = null == t ? void 0 : t.unwatched), - (this.name = null == t ? void 0 : t.name)) - } - function z(e, t) { - return new _(e, t) - } - function O(e) { - for (var t = e.s; void 0 !== t; t = t.n) - if (t.S.i !== t.i || !t.S.h() || t.S.i !== t.i) return !0 - return !1 - } - function S(e) { - for (var t = e.s; void 0 !== t; t = t.n) { - var i = t.S.n - if ((void 0 !== i && (t.r = i), (t.S.n = t), (t.i = -1), void 0 === t.n)) { - e.s = t - break - } - } - } - function E(e) { - for (var t = e.s, i = void 0; void 0 !== t; ) { - var n = t.p - ;(-1 === t.i - ? (t.S.U(t), void 0 !== n && (n.n = t.n), void 0 !== t.n && (t.n.p = n)) - : (i = t), - (t.S.n = t.r), - void 0 !== t.r && (t.r = void 0), - (t = n)) - } - e.s = i - } - function x(e, t) { - ;(_.call(this, void 0), - (this.x = e), - (this.s = void 0), - (this.g = b - 1), - (this.f = 4), - (this.W = null == t ? void 0 : t.watched), - (this.Z = null == t ? void 0 : t.unwatched), - (this.name = null == t ? void 0 : t.name)) - } - function k(e, t) { - return new x(e, t) - } - function I(e) { - var t = e.u - if (((e.u = void 0), 'function' == typeof t)) { - g++ - var i = h - h = void 0 - try { - t() - } catch (t) { - throw ((e.f &= -2), (e.f |= 8), $(e), t) - } finally { - ;((h = i), d()) - } - } - } - function $(e) { - for (var t = e.s; void 0 !== t; t = t.n) t.S.U(t) - ;((e.x = void 0), (e.s = void 0), I(e)) - } - function P(e) { - if (h !== this) throw Error('Out-of-order effect') - ;(E(this), (h = e), (this.f &= -2), 8 & this.f && $(this), d()) - } - function j(e, t) { - ;((this.x = e), - (this.u = void 0), - (this.s = void 0), - (this.o = void 0), - (this.f = 32), - (this.name = null == t ? void 0 : t.name), - v && v.push(this)) - } - function A(e, t) { - var i = new j(e, t) - try { - i.c() - } catch (e) { - throw (i.d(), e) - } - var n = i.d.bind(i) - return ((n[Symbol.dispose] = n), n) - } - function T(e) { - return Object.getOwnPropertySymbols(e).filter((t) => - Object.prototype.propertyIsEnumerable.call(e, t), - ) - } - function F(e) { - return null == e - ? void 0 === e - ? '[object Undefined]' - : '[object Null]' - : Object.prototype.toString.call(e) - } - ;((_.prototype.brand = c), - (_.prototype.h = function () { - return !0 - }), - (_.prototype.S = function (e) { - var t = this, - i = this.t - i !== e && - void 0 === e.e && - ((e.x = i), - (this.t = e), - void 0 !== i - ? (i.e = e) - : f(function () { - var e - null == (e = t.W) || e.call(t) - })) - }), - (_.prototype.U = function (e) { - var t = this - if (void 0 !== this.t) { - var i = e.e, - n = e.x - ;(void 0 !== i && ((i.x = n), (e.e = void 0)), - void 0 !== n && ((n.e = i), (e.x = void 0)), - e === this.t && - ((this.t = n), - void 0 === n && - f(function () { - var e - null == (e = t.Z) || e.call(t) - }))) - } - }), - (_.prototype.subscribe = function (e) { - var t = this - return A( - function () { - var i = t.value, - n = h - h = void 0 - try { - e(i) - } finally { - h = n - } - }, - { name: 'sub' }, - ) - }), - (_.prototype.valueOf = function () { - return this.value - }), - (_.prototype.toString = function () { - return this.value + '' - }), - (_.prototype.toJSON = function () { - return this.value - }), - (_.prototype.peek = function () { - var e = h - h = void 0 - try { - return this.value - } finally { - h = e - } - }), - Object.defineProperty(_.prototype, 'value', { - get: function () { - var e = w(this) - return (void 0 !== e && (e.i = this.i), this.v) - }, - set: function (e) { - if (e !== this.v) { - if (m > 100) throw Error('Cycle detected') - ;((this.v = e), this.i++, b++, g++) - try { - for (var t = this.t; void 0 !== t; t = t.x) t.t.N() - } finally { - d() - } - } - }, - }), - (x.prototype = new _()), - (x.prototype.h = function () { - if (((this.f &= -3), 1 & this.f)) return !1 - if (32 == (36 & this.f) || ((this.f &= -5), this.g === b)) return !0 - if (((this.g = b), (this.f |= 1), this.i > 0 && !O(this))) return ((this.f &= -2), !0) - var e = h - try { - ;(S(this), (h = this)) - var t = this.x() - ;(16 & this.f || this.v !== t || 0 === this.i) && - ((this.v = t), (this.f &= -17), this.i++) - } catch (e) { - ;((this.v = e), (this.f |= 16), this.i++) - } - return ((h = e), E(this), (this.f &= -2), !0) - }), - (x.prototype.S = function (e) { - if (void 0 === this.t) { - this.f |= 36 - for (var t = this.s; void 0 !== t; t = t.n) t.S.S(t) - } - _.prototype.S.call(this, e) - }), - (x.prototype.U = function (e) { - if (void 0 !== this.t && (_.prototype.U.call(this, e), void 0 === this.t)) { - this.f &= -33 - for (var t = this.s; void 0 !== t; t = t.n) t.S.U(t) - } - }), - (x.prototype.N = function () { - if (!(2 & this.f)) { - this.f |= 6 - for (var e = this.t; void 0 !== e; e = e.x) e.t.N() - } - }), - Object.defineProperty(x.prototype, 'value', { - get: function () { - if (1 & this.f) throw Error('Cycle detected') - var e = w(this) - if ((this.h(), void 0 !== e && (e.i = this.i), 16 & this.f)) throw this.v - return this.v - }, - }), - (j.prototype.c = function () { - var e = this.S() - try { - if (8 & this.f || void 0 === this.x) return - var t = this.x() - 'function' == typeof t && (this.u = t) - } finally { - e() - } - }), - (j.prototype.S = function () { - if (1 & this.f) throw Error('Cycle detected') - ;((this.f |= 1), (this.f &= -9), I(this), S(this), g++) - var e = h - return ((h = this), P.bind(this, e)) - }), - (j.prototype.N = function () { - 2 & this.f || ((this.f |= 2), (this.o = y), (y = this)) - }), - (j.prototype.d = function () { - ;((this.f |= 8), 1 & this.f || $(this)) - }), - (j.prototype.dispose = function () { - this.d() - })) - let C = '[object RegExp]', - R = '[object String]', - M = '[object Number]', - B = '[object Boolean]', - q = '[object Arguments]', - U = '[object Symbol]', - V = '[object Date]', - N = '[object Map]', - D = '[object Set]', - Z = '[object Array]', - Q = '[object ArrayBuffer]', - L = '[object Object]', - J = '[object DataView]', - H = '[object Uint8Array]', - K = '[object Uint8ClampedArray]', - W = '[object Uint16Array]', - G = '[object Uint32Array]', - X = '[object Int8Array]', - Y = '[object Int16Array]', - ee = '[object Int32Array]', - et = '[object Float32Array]', - ei = '[object Float64Array]', - en = - ('object' == typeof globalThis && globalThis) || - ('object' == typeof window && window) || - ('object' == typeof self && self) || - ('object' == typeof global && global) || - (function () { - return this - })() || - Function('return this')() - function er(e) { - return void 0 !== en.Buffer && en.Buffer.isBuffer(e) - } - function es(e, t, i, n = new Map(), r) { - let s = r?.(e, t, i, n) - if (void 0 !== s) return s - if (null == e || ('object' != typeof e && 'function' != typeof e)) return e - if (n.has(e)) return n.get(e) - if (Array.isArray(e)) { - let t = Array(e.length) - n.set(e, t) - for (let s = 0; s < e.length; s++) t[s] = es(e[s], s, i, n, r) - return ( - Object.hasOwn(e, 'index') && (t.index = e.index), - Object.hasOwn(e, 'input') && (t.input = e.input), - t - ) - } - if (e instanceof Date) return new Date(e.getTime()) - if (e instanceof RegExp) { - let t = new RegExp(e.source, e.flags) - return ((t.lastIndex = e.lastIndex), t) - } - if (e instanceof Map) { - let t = new Map() - for (let [s, o] of (n.set(e, t), e)) t.set(s, es(o, s, i, n, r)) - return t - } - if (e instanceof Set) { - let t = new Set() - for (let s of (n.set(e, t), e)) t.add(es(s, void 0, i, n, r)) - return t - } - if (er(e)) return e.subarray() - if (ArrayBuffer.isView(e) && !(e instanceof DataView)) { - let t = new (Object.getPrototypeOf(e).constructor)(e.length) - n.set(e, t) - for (let s = 0; s < e.length; s++) t[s] = es(e[s], s, i, n, r) - return t - } - if ( - e instanceof ArrayBuffer || - ('u' > typeof SharedArrayBuffer && e instanceof SharedArrayBuffer) - ) - return e.slice(0) - if (e instanceof DataView) { - let t = new DataView(e.buffer.slice(0), e.byteOffset, e.byteLength) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - if ('u' > typeof File && e instanceof File) { - let t = new File([e], e.name, { type: e.type }) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - if ('u' > typeof Blob && e instanceof Blob) { - let t = new Blob([e], { type: e.type }) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - if (e instanceof Error) { - let t = structuredClone(e) - return ( - n.set(e, t), - (t.message = e.message), - (t.name = e.name), - (t.stack = e.stack), - (t.cause = e.cause), - (t.constructor = e.constructor), - eo(t, e, i, n, r), - t - ) - } - if (e instanceof Boolean) { - let t = new Boolean(e.valueOf()) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - if (e instanceof Number) { - let t = new Number(e.valueOf()) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - if (e instanceof String) { - let t = new String(e.valueOf()) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - if ( - 'object' == typeof e && - (function (e) { - switch (F(e)) { - case q: - case Z: - case Q: - case J: - case B: - case V: - case et: - case ei: - case X: - case Y: - case ee: - case N: - case M: - case L: - case C: - case D: - case R: - case U: - case H: - case K: - case W: - case G: - return !0 - default: - return !1 - } - })(e) - ) { - let t = Object.create(Object.getPrototypeOf(e)) - return (n.set(e, t), eo(t, e, i, n, r), t) - } - return e - } - function eo(e, t, i = e, n, r) { - let s = [...Object.keys(t), ...T(t)] - for (let o = 0; o < s.length; o++) { - let a = s[o], - l = Object.getOwnPropertyDescriptor(e, a) - ;(null == l || l.writable) && (e[a] = es(t[a], a, i, n, r)) - } - } - function ea(e) { - return es(e, void 0, e, new Map(), void 0) - } - function el(e) { - return { - get current() { - return ea(e.value) - }, - subscribe: (t) => ({ - unsubscribe: A(() => { - t(ea(e.value)) - }), - }), - subscribeOnce(t) { - let i = !1, - n = !1, - r = () => void 0 - return ( - (r = A(() => { - if (i) return - let { value: s } = e - if (null == s) return - i = !0 - let o = null - try { - t(ea(s)) - } catch (e) { - o = e instanceof Error ? e : Error(`Subscriber threw non-Error value: ${String(e)}`) - } - if ((n ? r() : queueMicrotask(r), o)) throw o - })), - (n = !0), - { - unsubscribe: () => { - !i && ((i = !0), n && r()) - }, - } - ) - }, - } - } - let eu = z(), - ec = z(), - ed = z(), - ep = z(), - eh = z(!0), - ef = z(!1), - ev = z(!1), - ey = z(), - eg = k(() => void 0 !== ey.value), - em = z(), - eb = { - blockedEvent: ec, - changes: eu, - consent: ed, - event: ep, - online: eh, - previewPanelAttached: ef, - previewPanelOpen: ev, - selectedOptimizations: ey, - canOptimize: eg, - profile: em, - }, - ew = { batch: p, computed: k, effect: A, untracked: f } - function e_(e, t, i) { - function n(i, n) { - if ( - (i._zod || - Object.defineProperty(i, '_zod', { - value: { def: n, constr: o, traits: new Set() }, - enumerable: !1, - }), - i._zod.traits.has(e)) - ) - return - ;(i._zod.traits.add(e), t(i, n)) - let r = o.prototype, - s = Object.keys(r) - for (let e = 0; e < s.length; e++) { - let t = s[e] - t in i || (i[t] = r[t].bind(i)) - } - } - let r = i?.Parent ?? Object - class s extends r {} - function o(e) { - var t - let r = i?.Parent ? new s() : this - for (let i of (n(r, e), (t = r._zod).deferred ?? (t.deferred = []), r._zod.deferred)) i() - return r - } - return ( - Object.defineProperty(s, 'name', { value: e }), - Object.defineProperty(o, 'init', { value: n }), - Object.defineProperty(o, Symbol.hasInstance, { - value: (t) => (!!i?.Parent && t instanceof i.Parent) || t?._zod?.traits?.has(e), - }), - Object.defineProperty(o, 'name', { value: e }), - o - ) - } - ;(Object.freeze({ status: 'aborted' }), Symbol('zod_brand')) - class ez extends Error { - constructor() { - super('Encountered Promise during synchronous parse. Use .parseAsync() instead.') - } - } - let eO = {} - function eS(e) { - return (e && Object.assign(eO, e), eO) - } - function eE(e, t = '|') { - return e.map((e) => eU(e)).join(t) - } - function ex(e, t) { - return 'bigint' == typeof t ? t.toString() : t - } - function ek(e) { - return { - get value() { - { - let t = e() - return (Object.defineProperty(this, 'value', { value: t }), t) - } - }, - } - } - function eI(e) { - let t = +!!e.startsWith('^'), - i = e.endsWith('$') ? e.length - 1 : e.length - return e.slice(t, i) - } - let e$ = Symbol('evaluating') - function eP(e, t, i) { - let n - Object.defineProperty(e, t, { - get() { - if (n !== e$) return (void 0 === n && ((n = e$), (n = i())), n) - }, - set(i) { - Object.defineProperty(e, t, { value: i }) - }, - configurable: !0, - }) - } - function ej(e, t, i) { - Object.defineProperty(e, t, { value: i, writable: !0, enumerable: !0, configurable: !0 }) - } - function eA(...e) { - let t = {} - for (let i of e) Object.assign(t, Object.getOwnPropertyDescriptors(i)) - return Object.defineProperties({}, t) - } - let eT = 'captureStackTrace' in Error ? Error.captureStackTrace : (...e) => {} - function eF(e) { - return 'object' == typeof e && null !== e && !Array.isArray(e) - } - function eC(e) { - if (!1 === eF(e)) return !1 - let t = e.constructor - if (void 0 === t || 'function' != typeof t) return !0 - let i = t.prototype - return !1 !== eF(i) && !1 !== Object.prototype.hasOwnProperty.call(i, 'isPrototypeOf') - } - ek(() => { - if ('u' > typeof navigator && navigator?.userAgent?.includes('Cloudflare')) return !1 - try { - return (Function(''), !0) - } catch (e) { - return !1 - } - }) - let eR = new Set(['string', 'number', 'symbol']) - function eM(e) { - return e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - } - function eB(e, t, i) { - let n = new e._zod.constr(t ?? e._zod.def) - return ((!t || i?.parent) && (n._zod.parent = e), n) - } - function eq(e) { - if (!e) return {} - if ('string' == typeof e) return { error: () => e } - if (e?.message !== void 0) { - if (e?.error !== void 0) throw Error('Cannot specify both `message` and `error` params') - e.error = e.message - } - return (delete e.message, 'string' == typeof e.error) ? { ...e, error: () => e.error } : e - } - function eU(e) { - return 'bigint' == typeof e ? e.toString() + 'n' : 'string' == typeof e ? `"${e}"` : `${e}` - } - function eV(e, t = 0) { - if (!0 === e.aborted) return !0 - for (let i = t; i < e.issues.length; i++) if (e.issues[i]?.continue !== !0) return !0 - return !1 - } - function eN(e, t) { - return t.map((t) => (t.path ?? (t.path = []), t.path.unshift(e), t)) - } - function eD(e) { - return 'string' == typeof e ? e : e?.message - } - function eZ(e, t, i) { - let n = { ...e, path: e.path ?? [] } - return ( - e.message || - (n.message = - eD(e.inst?._zod.def?.error?.(e)) ?? - eD(t?.error?.(e)) ?? - eD(i.customError?.(e)) ?? - eD(i.localeError?.(e)) ?? - 'Invalid input'), - delete n.inst, - delete n.continue, - t?.reportInput || delete n.input, - n - ) - } - function eQ(e) { - return Array.isArray(e) ? 'array' : 'string' == typeof e ? 'string' : 'unknown' - } - let eL = (e, t) => { - ;((e.name = '$ZodError'), - Object.defineProperty(e, '_zod', { value: e._zod, enumerable: !1 }), - Object.defineProperty(e, 'issues', { value: t, enumerable: !1 }), - (e.message = JSON.stringify(t, ex, 2)), - Object.defineProperty(e, 'toString', { value: () => e.message, enumerable: !1 })) - }, - eJ = e_('$ZodError', eL), - eH = e_('$ZodError', eL, { Parent: Error }), - eK = - ((e = eH), - (t, i, n, r) => { - let s = n ? Object.assign(n, { async: !1 }) : { async: !1 }, - o = t._zod.run({ value: i, issues: [] }, s) - if (o instanceof Promise) throw new ez() - if (o.issues.length) { - let t = new (r?.Err ?? e)(o.issues.map((e) => eZ(e, s, eS()))) - throw (eT(t, r?.callee), t) - } - return o.value - }), - eW = - ((t = eH), - async (e, i, n, r) => { - let s = n ? Object.assign(n, { async: !0 }) : { async: !0 }, - o = e._zod.run({ value: i, issues: [] }, s) - if ((o instanceof Promise && (o = await o), o.issues.length)) { - let e = new (r?.Err ?? t)(o.issues.map((e) => eZ(e, s, eS()))) - throw (eT(e, r?.callee), e) - } - return o.value - }), - eG = - ((i = eH), - (e, t, n) => { - let r = n ? { ...n, async: !1 } : { async: !1 }, - s = e._zod.run({ value: t, issues: [] }, r) - if (s instanceof Promise) throw new ez() - return s.issues.length - ? { success: !1, error: new (i ?? eJ)(s.issues.map((e) => eZ(e, r, eS()))) } - : { success: !0, data: s.value } - }), - eX = - ((n = eH), - async (e, t, i) => { - let r = i ? Object.assign(i, { async: !0 }) : { async: !0 }, - s = e._zod.run({ value: t, issues: [] }, r) - return ( - s instanceof Promise && (s = await s), - s.issues.length - ? { success: !1, error: new n(s.issues.map((e) => eZ(e, r, eS()))) } - : { success: !0, data: s.value } - ) - }), - eY = - /^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/, - e0 = - '(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))', - e1 = RegExp(`^${e0}$`) - function e2(e) { - let t = '(?:[01]\\d|2[0-3]):[0-5]\\d' - return 'number' == typeof e.precision - ? -1 === e.precision - ? `${t}` - : 0 === e.precision - ? `${t}:[0-5]\\d` - : `${t}:[0-5]\\d\\.\\d{${e.precision}}` - : `${t}(?::[0-5]\\d(?:\\.\\d+)?)?` - } - let e6 = /^-?\d+(?:\.\d+)?$/, - e3 = /^(?:true|false)$/i, - e4 = /^null$/i, - e8 = e_('$ZodCheck', (e, t) => { - var i - ;(e._zod ?? (e._zod = {}), (e._zod.def = t), (i = e._zod).onattach ?? (i.onattach = [])) - }), - e5 = e_('$ZodCheckMinLength', (e, t) => { - var i - ;(e8.init(e, t), - (i = e._zod.def).when ?? - (i.when = (e) => { - let t = e.value - return null != t && void 0 !== t.length - }), - e._zod.onattach.push((e) => { - let i = e._zod.bag.minimum ?? -1 / 0 - t.minimum > i && (e._zod.bag.minimum = t.minimum) - }), - (e._zod.check = (i) => { - let n = i.value - if (n.length >= t.minimum) return - let r = eQ(n) - i.issues.push({ - origin: r, - code: 'too_small', - minimum: t.minimum, - inclusive: !0, - input: n, - inst: e, - continue: !t.abort, - }) - })) - }), - e9 = e_('$ZodCheckLengthEquals', (e, t) => { - var i - ;(e8.init(e, t), - (i = e._zod.def).when ?? - (i.when = (e) => { - let t = e.value - return null != t && void 0 !== t.length - }), - e._zod.onattach.push((e) => { - let i = e._zod.bag - ;((i.minimum = t.length), (i.maximum = t.length), (i.length = t.length)) - }), - (e._zod.check = (i) => { - let n = i.value, - r = n.length - if (r === t.length) return - let s = eQ(n), - o = r > t.length - i.issues.push({ - origin: s, - ...(o - ? { code: 'too_big', maximum: t.length } - : { code: 'too_small', minimum: t.length }), - inclusive: !0, - exact: !0, - input: i.value, - inst: e, - continue: !t.abort, - }) - })) - }), - e7 = e_('$ZodCheckStringFormat', (e, t) => { - var i, n - ;(e8.init(e, t), - e._zod.onattach.push((e) => { - let i = e._zod.bag - ;((i.format = t.format), - t.pattern && (i.patterns ?? (i.patterns = new Set()), i.patterns.add(t.pattern))) - }), - t.pattern - ? ((i = e._zod).check ?? - (i.check = (i) => { - ;((t.pattern.lastIndex = 0), - t.pattern.test(i.value) || - i.issues.push({ - origin: 'string', - code: 'invalid_format', - format: t.format, - input: i.value, - ...(t.pattern ? { pattern: t.pattern.toString() } : {}), - inst: e, - continue: !t.abort, - })) - })) - : ((n = e._zod).check ?? (n.check = () => {}))) - }), - te = { major: 4, minor: 3, patch: 6 }, - tt = e_('$ZodType', (e, t) => { - var i - ;(e ?? (e = {}), (e._zod.def = t), (e._zod.bag = e._zod.bag || {}), (e._zod.version = te)) - let n = [...(e._zod.def.checks ?? [])] - for (let t of (e._zod.traits.has('$ZodCheck') && n.unshift(e), n)) - for (let i of t._zod.onattach) i(e) - if (0 === n.length) - ((i = e._zod).deferred ?? (i.deferred = []), - e._zod.deferred?.push(() => { - e._zod.run = e._zod.parse - })) - else { - let t = (e, t, i) => { - let n, - r = eV(e) - for (let s of t) { - if (s._zod.def.when) { - if (!s._zod.def.when(e)) continue - } else if (r) continue - let t = e.issues.length, - o = s._zod.check(e) - if (o instanceof Promise && i?.async === !1) throw new ez() - if (n || o instanceof Promise) - n = (n ?? Promise.resolve()).then(async () => { - ;(await o, e.issues.length !== t && (r || (r = eV(e, t)))) - }) - else { - if (e.issues.length === t) continue - r || (r = eV(e, t)) - } - } - return n ? n.then(() => e) : e - }, - i = (i, r, s) => { - if (eV(i)) return ((i.aborted = !0), i) - let o = t(r, n, s) - if (o instanceof Promise) { - if (!1 === s.async) throw new ez() - return o.then((t) => e._zod.parse(t, s)) - } - return e._zod.parse(o, s) - } - e._zod.run = (r, s) => { - if (s.skipChecks) return e._zod.parse(r, s) - if ('backward' === s.direction) { - let t = e._zod.parse({ value: r.value, issues: [] }, { ...s, skipChecks: !0 }) - return t instanceof Promise ? t.then((e) => i(e, r, s)) : i(t, r, s) - } - let o = e._zod.parse(r, s) - if (o instanceof Promise) { - if (!1 === s.async) throw new ez() - return o.then((e) => t(e, n, s)) - } - return t(o, n, s) - } - } - eP(e, '~standard', () => ({ - validate: (t) => { - try { - let i = eG(e, t) - return i.success ? { value: i.data } : { issues: i.error?.issues } - } catch (i) { - return eX(e, t).then((e) => - e.success ? { value: e.data } : { issues: e.error?.issues }, - ) - } - }, - vendor: 'zod', - version: 1, - })) - }), - ti = e_('$ZodString', (e, t) => { - var i - let n - ;(tt.init(e, t), - (e._zod.pattern = - [...(e?._zod.bag?.patterns ?? [])].pop() ?? - ((n = (i = e._zod.bag) - ? `[\\s\\S]{${i?.minimum ?? 0},${i?.maximum ?? ''}}` - : '[\\s\\S]*'), - RegExp(`^${n}$`))), - (e._zod.parse = (i, n) => { - if (t.coerce) - try { - i.value = String(i.value) - } catch (e) {} - return ( - 'string' == typeof i.value || - i.issues.push({ - expected: 'string', - code: 'invalid_type', - input: i.value, - inst: e, - }), - i - ) - })) - }), - tn = e_('$ZodStringFormat', (e, t) => { - ;(e7.init(e, t), ti.init(e, t)) - }), - tr = e_('$ZodISODateTime', (e, t) => { - let i, n, r - ;(t.pattern ?? - ((i = e2({ precision: t.precision })), - (n = ['Z']), - t.local && n.push(''), - t.offset && n.push('([+-](?:[01]\\d|2[0-3]):[0-5]\\d)'), - (r = `${i}(?:${n.join('|')})`), - (t.pattern = RegExp(`^${e0}T(?:${r})$`))), - tn.init(e, t)) - }), - ts = - ((e, t) => { - ;(t.pattern ?? (t.pattern = e1), tn.init(e, t)) - }, - e_('$ZodNumber', (e, t) => { - ;(tt.init(e, t), - (e._zod.pattern = e._zod.bag.pattern ?? e6), - (e._zod.parse = (i, n) => { - if (t.coerce) - try { - i.value = Number(i.value) - } catch (e) {} - let r = i.value - if ('number' == typeof r && !Number.isNaN(r) && Number.isFinite(r)) return i - let s = - 'number' == typeof r - ? Number.isNaN(r) - ? 'NaN' - : Number.isFinite(r) - ? void 0 - : 'Infinity' - : void 0 - return ( - i.issues.push({ - expected: 'number', - code: 'invalid_type', - input: r, - inst: e, - ...(s ? { received: s } : {}), - }), - i - ) - })) - })), - to = e_('$ZodBoolean', (e, t) => { - ;(tt.init(e, t), - (e._zod.pattern = e3), - (e._zod.parse = (i, n) => { - if (t.coerce) - try { - i.value = !!i.value - } catch (e) {} - let r = i.value - return ( - 'boolean' == typeof r || - i.issues.push({ expected: 'boolean', code: 'invalid_type', input: r, inst: e }), - i - ) - })) - }), - ta = e_('$ZodNull', (e, t) => { - ;(tt.init(e, t), - (e._zod.pattern = e4), - (e._zod.values = new Set([null])), - (e._zod.parse = (t, i) => { - let n = t.value - return ( - null === n || - t.issues.push({ expected: 'null', code: 'invalid_type', input: n, inst: e }), - t - ) - })) - }), - tl = e_('$ZodAny', (e, t) => { - ;(tt.init(e, t), (e._zod.parse = (e) => e)) - }), - tu = e_('$ZodUnknown', (e, t) => { - ;(tt.init(e, t), (e._zod.parse = (e) => e)) - }) - function tc(e, t, i) { - ;(e.issues.length && t.issues.push(...eN(i, e.issues)), (t.value[i] = e.value)) - } - let td = e_('$ZodArray', (e, t) => { - ;(tt.init(e, t), - (e._zod.parse = (i, n) => { - let r = i.value - if (!Array.isArray(r)) - return ( - i.issues.push({ expected: 'array', code: 'invalid_type', input: r, inst: e }), - i - ) - i.value = Array(r.length) - let s = [] - for (let e = 0; e < r.length; e++) { - let o = r[e], - a = t.element._zod.run({ value: o, issues: [] }, n) - a instanceof Promise ? s.push(a.then((t) => tc(t, i, e))) : tc(a, i, e) - } - return s.length ? Promise.all(s).then(() => i) : i - })) - }) - function tp(e, t, i, n, r) { - if (e.issues.length) { - if (r && !(i in n)) return - t.issues.push(...eN(i, e.issues)) - } - void 0 === e.value ? i in n && (t.value[i] = void 0) : (t.value[i] = e.value) - } - let th = e_('$ZodObject', (e, t) => { - let i - tt.init(e, t) - let n = Object.getOwnPropertyDescriptor(t, 'shape') - if (!n?.get) { - let e = t.shape - Object.defineProperty(t, 'shape', { - get: () => { - let i = { ...e } - return (Object.defineProperty(t, 'shape', { value: i }), i) - }, - }) - } - let r = ek(() => - (function (e) { - var t - let i = Object.keys(e.shape) - for (let t of i) - if (!e.shape?.[t]?._zod?.traits?.has('$ZodType')) - throw Error(`Invalid element at key "${t}": expected a Zod schema`) - let n = Object.keys((t = e.shape)).filter( - (e) => 'optional' === t[e]._zod.optin && 'optional' === t[e]._zod.optout, - ) - return { ...e, keys: i, keySet: new Set(i), numKeys: i.length, optionalKeys: new Set(n) } - })(t), - ) - eP(e._zod, 'propValues', () => { - let e = t.shape, - i = {} - for (let t in e) { - let n = e[t]._zod - if (n.values) for (let e of (i[t] ?? (i[t] = new Set()), n.values)) i[t].add(e) - } - return i - }) - let s = t.catchall - e._zod.parse = (t, n) => { - i ?? (i = r.value) - let o = t.value - if (!eF(o)) - return (t.issues.push({ expected: 'object', code: 'invalid_type', input: o, inst: e }), t) - t.value = {} - let a = [], - l = i.shape - for (let e of i.keys) { - let i = l[e], - r = 'optional' === i._zod.optout, - s = i._zod.run({ value: o[e], issues: [] }, n) - s instanceof Promise ? a.push(s.then((i) => tp(i, t, e, o, r))) : tp(s, t, e, o, r) - } - return s - ? (function (e, t, i, n, r, s) { - let o = [], - a = r.keySet, - l = r.catchall._zod, - u = l.def.type, - c = 'optional' === l.optout - for (let r in t) { - if (a.has(r)) continue - if ('never' === u) { - o.push(r) - continue - } - let s = l.run({ value: t[r], issues: [] }, n) - s instanceof Promise ? e.push(s.then((e) => tp(e, i, r, t, c))) : tp(s, i, r, t, c) - } - return (o.length && - i.issues.push({ code: 'unrecognized_keys', keys: o, input: t, inst: s }), - e.length) - ? Promise.all(e).then(() => i) - : i - })(a, o, t, n, r.value, e) - : a.length - ? Promise.all(a).then(() => t) - : t - } - }) - function tf(e, t, i, n) { - for (let i of e) if (0 === i.issues.length) return ((t.value = i.value), t) - let r = e.filter((e) => !eV(e)) - return 1 === r.length - ? ((t.value = r[0].value), r[0]) - : (t.issues.push({ - code: 'invalid_union', - input: t.value, - inst: i, - errors: e.map((e) => e.issues.map((e) => eZ(e, n, eS()))), - }), - t) - } - let tv = e_('$ZodUnion', (e, t) => { - ;(tt.init(e, t), - eP(e._zod, 'optin', () => - t.options.some((e) => 'optional' === e._zod.optin) ? 'optional' : void 0, - ), - eP(e._zod, 'optout', () => - t.options.some((e) => 'optional' === e._zod.optout) ? 'optional' : void 0, - ), - eP(e._zod, 'values', () => { - if (t.options.every((e) => e._zod.values)) - return new Set(t.options.flatMap((e) => Array.from(e._zod.values))) - }), - eP(e._zod, 'pattern', () => { - if (t.options.every((e) => e._zod.pattern)) { - let e = t.options.map((e) => e._zod.pattern) - return RegExp(`^(${e.map((e) => eI(e.source)).join('|')})$`) - } - })) - let i = 1 === t.options.length, - n = t.options[0]._zod.run - e._zod.parse = (r, s) => { - if (i) return n(r, s) - let o = !1, - a = [] - for (let e of t.options) { - let t = e._zod.run({ value: r.value, issues: [] }, s) - if (t instanceof Promise) (a.push(t), (o = !0)) - else { - if (0 === t.issues.length) return t - a.push(t) - } - } - return o ? Promise.all(a).then((t) => tf(t, r, e, s)) : tf(a, r, e, s) - } - }), - ty = e_('$ZodDiscriminatedUnion', (e, t) => { - ;((t.inclusive = !1), tv.init(e, t)) - let i = e._zod.parse - eP(e._zod, 'propValues', () => { - let e = {} - for (let i of t.options) { - let n = i._zod.propValues - if (!n || 0 === Object.keys(n).length) - throw Error(`Invalid discriminated union option at index "${t.options.indexOf(i)}"`) - for (let [t, i] of Object.entries(n)) - for (let n of (e[t] || (e[t] = new Set()), i)) e[t].add(n) - } - return e - }) - let n = ek(() => { - let e = t.options, - i = new Map() - for (let n of e) { - let e = n._zod.propValues?.[t.discriminator] - if (!e || 0 === e.size) - throw Error(`Invalid discriminated union option at index "${t.options.indexOf(n)}"`) - for (let t of e) { - if (i.has(t)) throw Error(`Duplicate discriminator value "${String(t)}"`) - i.set(t, n) - } - } - return i - }) - e._zod.parse = (r, s) => { - let o = r.value - if (!eF(o)) - return ( - r.issues.push({ code: 'invalid_type', expected: 'object', input: o, inst: e }), - r - ) - let a = n.value.get(o?.[t.discriminator]) - return a - ? a._zod.run(r, s) - : t.unionFallback - ? i(r, s) - : (r.issues.push({ - code: 'invalid_union', - errors: [], - note: 'No matching discriminator', - discriminator: t.discriminator, - input: o, - path: [t.discriminator], - inst: e, - }), - r) - } - }), - tg = e_('$ZodRecord', (e, t) => { - ;(tt.init(e, t), - (e._zod.parse = (i, n) => { - let r = i.value - if (!eC(r)) - return ( - i.issues.push({ expected: 'record', code: 'invalid_type', input: r, inst: e }), - i - ) - let s = [], - o = t.keyType._zod.values - if (o) { - let a - i.value = {} - let l = new Set() - for (let e of o) - if ('string' == typeof e || 'number' == typeof e || 'symbol' == typeof e) { - l.add('number' == typeof e ? e.toString() : e) - let o = t.valueType._zod.run({ value: r[e], issues: [] }, n) - o instanceof Promise - ? s.push( - o.then((t) => { - ;(t.issues.length && i.issues.push(...eN(e, t.issues)), - (i.value[e] = t.value)) - }), - ) - : (o.issues.length && i.issues.push(...eN(e, o.issues)), (i.value[e] = o.value)) - } - for (let e in r) l.has(e) || (a = a ?? []).push(e) - a && - a.length > 0 && - i.issues.push({ code: 'unrecognized_keys', input: r, inst: e, keys: a }) - } else - for (let o of ((i.value = {}), Reflect.ownKeys(r))) { - if ('__proto__' === o) continue - let a = t.keyType._zod.run({ value: o, issues: [] }, n) - if (a instanceof Promise) - throw Error('Async schemas not supported in object keys currently') - if ('string' == typeof o && e6.test(o) && a.issues.length) { - let e = t.keyType._zod.run({ value: Number(o), issues: [] }, n) - if (e instanceof Promise) - throw Error('Async schemas not supported in object keys currently') - 0 === e.issues.length && (a = e) - } - if (a.issues.length) { - 'loose' === t.mode - ? (i.value[o] = r[o]) - : i.issues.push({ - code: 'invalid_key', - origin: 'record', - issues: a.issues.map((e) => eZ(e, n, eS())), - input: o, - path: [o], - inst: e, - }) - continue - } - let l = t.valueType._zod.run({ value: r[o], issues: [] }, n) - l instanceof Promise - ? s.push( - l.then((e) => { - ;(e.issues.length && i.issues.push(...eN(o, e.issues)), - (i.value[a.value] = e.value)) - }), - ) - : (l.issues.length && i.issues.push(...eN(o, l.issues)), - (i.value[a.value] = l.value)) - } - return s.length ? Promise.all(s).then(() => i) : i - })) - }), - tm = e_('$ZodEnum', (e, t) => { - var i - let n - tt.init(e, t) - let r = - ((n = Object.values((i = t.entries)).filter((e) => 'number' == typeof e)), - Object.entries(i) - .filter(([e, t]) => -1 === n.indexOf(+e)) - .map(([e, t]) => t)), - s = new Set(r) - ;((e._zod.values = s), - (e._zod.pattern = RegExp( - `^(${r - .filter((e) => eR.has(typeof e)) - .map((e) => ('string' == typeof e ? eM(e) : e.toString())) - .join('|')})$`, - )), - (e._zod.parse = (t, i) => { - let n = t.value - return ( - s.has(n) || t.issues.push({ code: 'invalid_value', values: r, input: n, inst: e }), - t - ) - })) - }), - tb = e_('$ZodLiteral', (e, t) => { - if ((tt.init(e, t), 0 === t.values.length)) - throw Error('Cannot create literal schema with no valid values') - let i = new Set(t.values) - ;((e._zod.values = i), - (e._zod.pattern = RegExp( - `^(${t.values.map((e) => ('string' == typeof e ? eM(e) : e ? eM(e.toString()) : String(e))).join('|')})$`, - )), - (e._zod.parse = (n, r) => { - let s = n.value - return ( - i.has(s) || - n.issues.push({ code: 'invalid_value', values: t.values, input: s, inst: e }), - n - ) - })) - }) - function tw(e, t) { - return e.issues.length && void 0 === t ? { issues: [], value: void 0 } : e - } - let t_ = e_('$ZodOptional', (e, t) => { - ;(tt.init(e, t), - (e._zod.optin = 'optional'), - (e._zod.optout = 'optional'), - eP(e._zod, 'values', () => - t.innerType._zod.values ? new Set([...t.innerType._zod.values, void 0]) : void 0, - ), - eP(e._zod, 'pattern', () => { - let e = t.innerType._zod.pattern - return e ? RegExp(`^(${eI(e.source)})?$`) : void 0 - }), - (e._zod.parse = (e, i) => { - if ('optional' === t.innerType._zod.optin) { - let n = t.innerType._zod.run(e, i) - return n instanceof Promise ? n.then((t) => tw(t, e.value)) : tw(n, e.value) - } - return void 0 === e.value ? e : t.innerType._zod.run(e, i) - })) - }), - tz = e_('$ZodNullable', (e, t) => { - ;(tt.init(e, t), - eP(e._zod, 'optin', () => t.innerType._zod.optin), - eP(e._zod, 'optout', () => t.innerType._zod.optout), - eP(e._zod, 'pattern', () => { - let e = t.innerType._zod.pattern - return e ? RegExp(`^(${eI(e.source)}|null)$`) : void 0 - }), - eP(e._zod, 'values', () => - t.innerType._zod.values ? new Set([...t.innerType._zod.values, null]) : void 0, - ), - (e._zod.parse = (e, i) => (null === e.value ? e : t.innerType._zod.run(e, i)))) - }), - tO = e_('$ZodPrefault', (e, t) => { - ;(tt.init(e, t), - (e._zod.optin = 'optional'), - eP(e._zod, 'values', () => t.innerType._zod.values), - (e._zod.parse = (e, i) => ( - 'backward' === i.direction || (void 0 === e.value && (e.value = t.defaultValue)), - t.innerType._zod.run(e, i) - ))) - }), - tS = e_('$ZodLazy', (e, t) => { - ;(tt.init(e, t), - eP(e._zod, 'innerType', () => t.getter()), - eP(e._zod, 'pattern', () => e._zod.innerType?._zod?.pattern), - eP(e._zod, 'propValues', () => e._zod.innerType?._zod?.propValues), - eP(e._zod, 'optin', () => e._zod.innerType?._zod?.optin ?? void 0), - eP(e._zod, 'optout', () => e._zod.innerType?._zod?.optout ?? void 0), - (e._zod.parse = (t, i) => e._zod.innerType._zod.run(t, i))) - }) - ;(Symbol('ZodOutput'), Symbol('ZodInput')) - function tE(e, t) { - return new e5({ check: 'min_length', ...eq(t), minimum: e }) - } - ;(a = globalThis).__zod_globalRegistry ?? - (a.__zod_globalRegistry = new (class e { - constructor() { - ;((this._map = new WeakMap()), (this._idmap = new Map())) - } - add(e, ...t) { - let i = t[0] - return ( - this._map.set(e, i), - i && 'object' == typeof i && 'id' in i && this._idmap.set(i.id, e), - this - ) - } - clear() { - return ((this._map = new WeakMap()), (this._idmap = new Map()), this) - } - remove(e) { - let t = this._map.get(e) - return ( - t && 'object' == typeof t && 'id' in t && this._idmap.delete(t.id), - this._map.delete(e), - this - ) - } - get(e) { - let t = e._zod.parent - if (t) { - let i = { ...(this.get(t) ?? {}) } - delete i.id - let n = { ...i, ...this._map.get(e) } - return Object.keys(n).length ? n : void 0 - } - return this._map.get(e) - } - has(e) { - return this._map.has(e) - } - })()) - let tx = e_('ZodMiniType', (e, t) => { - if (!e._zod) throw Error('Uninitialized schema in ZodMiniType.') - ;(tt.init(e, t), - (e.def = t), - (e.type = t.type), - (e.parse = (t, i) => eK(e, t, i, { callee: e.parse })), - (e.safeParse = (t, i) => eG(e, t, i)), - (e.parseAsync = async (t, i) => eW(e, t, i, { callee: e.parseAsync })), - (e.safeParseAsync = async (t, i) => eX(e, t, i)), - (e.check = (...i) => - e.clone( - { - ...t, - checks: [ - ...(t.checks ?? []), - ...i.map((e) => - 'function' == typeof e - ? { _zod: { check: e, def: { check: 'custom' }, onattach: [] } } - : e, - ), - ], - }, - { parent: !0 }, - )), - (e.with = e.check), - (e.clone = (t, i) => eB(e, t, i)), - (e.brand = () => e), - (e.register = (t, i) => (t.add(e, i), e)), - (e.apply = (t) => t(e))) - }), - tk = e_('ZodMiniString', (e, t) => { - ;(ti.init(e, t), tx.init(e, t)) - }) - function tI(e) { - return new tk({ type: 'string', ...eq(e) }) - } - let t$ = e_('ZodMiniStringFormat', (e, t) => { - ;(tn.init(e, t), tk.init(e, t)) - }), - tP = e_('ZodMiniNumber', (e, t) => { - ;(ts.init(e, t), tx.init(e, t)) - }) - function tj(e) { - return new tP({ type: 'number', checks: [], ...eq(e) }) - } - let tA = e_('ZodMiniBoolean', (e, t) => { - ;(to.init(e, t), tx.init(e, t)) - }) - function tT(e) { - return new tA({ type: 'boolean', ...eq(e) }) - } - let tF = e_('ZodMiniNull', (e, t) => { - ;(ta.init(e, t), tx.init(e, t)) - }) - function tC(e) { - return new tF({ type: 'null', ...eq(e) }) - } - let tR = e_('ZodMiniAny', (e, t) => { - ;(tl.init(e, t), tx.init(e, t)) - }) - function tM() { - return new tR({ type: 'any' }) - } - let tB = e_('ZodMiniUnknown', (e, t) => { - ;(tu.init(e, t), tx.init(e, t)) - }), - tq = e_('ZodMiniArray', (e, t) => { - ;(td.init(e, t), tx.init(e, t)) - }) - function tU(e, t) { - return new tq({ type: 'array', element: e, ...eq(t) }) - } - let tV = e_('ZodMiniObject', (e, t) => { - ;(th.init(e, t), tx.init(e, t), eP(e, 'shape', () => t.shape)) - }) - function tN(e, t) { - return new tV({ type: 'object', shape: e ?? {}, ...eq(t) }) - } - function tD(e, t) { - if (!eC(t)) throw Error('Invalid input to extend: expected a plain object') - let i = e._zod.def.checks - if (i && i.length > 0) { - let i = e._zod.def.shape - for (let e in t) - if (void 0 !== Object.getOwnPropertyDescriptor(i, e)) - throw Error( - 'Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.', - ) - } - let n = eA(e._zod.def, { - get shape() { - let i = { ...e._zod.def.shape, ...t } - return (ej(this, 'shape', i), i) - }, - }) - return eB(e, n) - } - function tZ(e, t) { - return e.clone({ ...e._zod.def, catchall: t }) - } - let tQ = e_('ZodMiniUnion', (e, t) => { - ;(tv.init(e, t), tx.init(e, t)) - }) - function tL(e, t) { - return new tQ({ type: 'union', options: e, ...eq(t) }) - } - let tJ = e_('ZodMiniDiscriminatedUnion', (e, t) => { - ;(ty.init(e, t), tx.init(e, t)) - }) - function tH(e, t, i) { - return new tJ({ type: 'union', options: t, discriminator: e, ...eq(i) }) - } - let tK = e_('ZodMiniRecord', (e, t) => { - ;(tg.init(e, t), tx.init(e, t)) - }) - function tW(e, t, i) { - return new tK({ type: 'record', keyType: e, valueType: t, ...eq(i) }) - } - let tG = e_('ZodMiniEnum', (e, t) => { - ;(tm.init(e, t), tx.init(e, t), (e.options = Object.values(t.entries))) - }) - function tX(e, t) { - return new tG({ - type: 'enum', - entries: Array.isArray(e) ? Object.fromEntries(e.map((e) => [e, e])) : e, - ...eq(t), - }) - } - let tY = e_('ZodMiniLiteral', (e, t) => { - ;(tb.init(e, t), tx.init(e, t)) - }) - function t0(e, t) { - return new tY({ type: 'literal', values: Array.isArray(e) ? e : [e], ...eq(t) }) - } - let t1 = e_('ZodMiniOptional', (e, t) => { - ;(t_.init(e, t), tx.init(e, t)) - }) - function t2(e) { - return new t1({ type: 'optional', innerType: e }) - } - let t6 = e_('ZodMiniNullable', (e, t) => { - ;(tz.init(e, t), tx.init(e, t)) - }) - function t3(e) { - return new t6({ type: 'nullable', innerType: e }) - } - let t4 = e_('ZodMiniPrefault', (e, t) => { - ;(tO.init(e, t), tx.init(e, t)) - }) - function t8(e, t) { - return new t4({ - type: 'prefault', - innerType: e, - get defaultValue() { - return 'function' == typeof t ? t() : eC(t) ? { ...t } : Array.isArray(t) ? [...t] : t - }, - }) - } - let t5 = e_('ZodMiniLazy', (e, t) => { - ;(tS.init(e, t), tx.init(e, t)) - }) - function t9() { - let e = new t5({ - type: 'lazy', - getter: () => tL([tI(), tj(), tT(), tC(), tU(e), tW(tI(), e)]), - }) - return e - } - let t7 = e_('ZodMiniISODateTime', (e, t) => { - ;(tr.init(e, t), t$.init(e, t)) - }) - function ie(e) { - return new t7({ - type: 'string', - format: 'datetime', - check: 'string_format', - offset: !1, - local: !1, - precision: null, - ...eq(e), - }) - } - let it = tZ(tN({}), t9()), - ii = tN({ sys: tN({ type: t0('Link'), linkType: tI(), id: tI() }) }), - ir = tN({ sys: tN({ type: t0('Link'), linkType: t0('ContentType'), id: tI() }) }), - is = tN({ sys: tN({ type: t0('Link'), linkType: t0('Environment'), id: tI() }) }), - io = tN({ sys: tN({ type: t0('Link'), linkType: t0('Space'), id: tI() }) }), - ia = tN({ sys: tN({ type: t0('Link'), linkType: t0('TaxonomyConcept'), id: tI() }) }), - il = tN({ sys: tN({ type: t0('Link'), linkType: t0('Tag'), id: tI() }) }), - iu = tN({ - type: t0('Entry'), - contentType: ir, - publishedVersion: tj(), - id: tI(), - createdAt: tM(), - updatedAt: tM(), - locale: t2(tI()), - revision: tj(), - space: io, - environment: is, - }), - ic = tN({ fields: it, metadata: tN({ tags: tU(il), concepts: t2(tU(ia)) }), sys: iu }), - id = tD(it, { nt_audience_id: tI(), nt_name: t2(tI()), nt_description: t2(tI()) }), - ip = tD(ic, { fields: id }) - tN({ contentTypeId: t0('nt_audience'), fields: id }) - let ih = tD(ic, { - fields: tN({ nt_name: tI(), nt_fallback: t2(tI()), nt_mergetag_id: tI() }), - sys: tD(iu, { - contentType: tN({ - sys: tN({ type: t0('Link'), linkType: t0('ContentType'), id: t0('nt_mergetag') }), - }), - }), - }), - iv = tN({ id: tI(), hidden: t2(tT()) }), - iy = tN({ type: t2(t0('EntryReplacement')), baseline: iv, variants: tU(iv) }), - ig = tN({ value: tL([tI(), tT(), tC(), tj(), tW(tI(), t9())]) }), - im = tX(['Boolean', 'Number', 'Object', 'String']), - ib = tH('type', [ - iy, - tN({ - type: t0('InlineVariable'), - key: tI(), - valueType: im, - baseline: ig, - variants: tU(ig), - }), - ]), - iw = tU(ib), - i_ = tN({ - distribution: t2(tU(tj())), - traffic: t2(tj()), - components: t2(iw), - sticky: t2(tT()), - }), - iz = tL([t0('nt_experiment'), t0('nt_personalization')]), - iO = tD(it, { - nt_name: tI(), - nt_description: t2(t3(tI())), - nt_type: iz, - nt_config: t2(t3(i_)), - nt_audience: t2(t3(ip)), - nt_variants: t2(tU(tL([ii, ic]))), - nt_experience_id: tI(), - }), - iS = tD(ic, { fields: iO }) - tN({ contentTypeId: t0('nt_experience'), fields: iO }) - let iE = tD(ic, { fields: tD(it, { nt_experiences: tU(tL([ii, iS])) }) }) - function ix(e) { - return iS.safeParse(e).success - } - function ik(e) { - return iE.safeParse(e).success - } - let iI = t2(tN({ name: tI(), version: tI() })), - i$ = tN({ - name: t2(tI()), - source: t2(tI()), - medium: t2(tI()), - term: t2(tI()), - content: t2(tI()), - }), - iP = tL([t0('mobile'), t0('server'), t0('web')]), - ij = tW(tI(), tI()), - iA = tN({ latitude: tj(), longitude: tj() }), - iT = tN({ - coordinates: t2(iA), - city: t2(tI()), - postalCode: t2(tI()), - region: t2(tI()), - regionCode: t2(tI()), - country: t2(tI()), - countryCode: t2(tI().check(new e9({ check: 'length_equals', ...eq(void 0), length: 2 }))), - continent: t2(tI()), - timezone: t2(tI()), - }), - iF = tN({ name: tI(), version: tI() }), - iC = tZ( - tN({ path: tI(), query: ij, referrer: tI(), search: tI(), title: t2(tI()), url: tI() }), - t9(), - ), - iR = tW(tI(), t9()), - iM = tZ(tN({ name: tI() }), t9()), - iB = tW(tI(), t9()), - iq = tN({ - app: iI, - campaign: i$, - gdpr: tN({ isConsentGiven: tT() }), - library: iF, - locale: tI(), - location: t2(iT), - userAgent: t2(tI()), - }), - iU = tN({ - channel: iP, - context: tD(iq, { page: t2(iC), screen: t2(iM) }), - messageId: tI(), - originalTimestamp: ie(), - sentAt: ie(), - timestamp: ie(), - userId: t2(tI()), - }), - iV = tD(iU, { type: t0('alias') }), - iN = tD(iU, { type: t0('group') }), - iD = tD(iU, { type: t0('identify'), traits: iB }), - iZ = tD(iq, { page: iC }), - iQ = tD(iU, { type: t0('page'), name: t2(tI()), properties: iC, context: iZ }), - iL = tD(iq, { screen: iM }), - iJ = tD(iU, { type: t0('screen'), name: tI(), properties: t2(iR), context: iL }), - iH = tD(iU, { type: t0('track'), event: tI(), properties: iR }), - iK = tD(iU, { - componentType: tL([t0('Entry'), t0('Variable')]), - componentId: tI(), - experienceId: t2(tI()), - variantIndex: tj(), - }), - iW = tD(iK, { type: t0('component'), viewDurationMs: t2(tj()), viewId: t2(tI()) }), - iG = { anonymousId: tI() }, - iX = tU( - tH('type', [ - tD(iV, iG), - tD(iW, iG), - tD(iN, iG), - tD(iD, iG), - tD(iQ, iG), - tD(iJ, iG), - tD(iH, iG), - ]), - ), - iY = tH('type', [iV, iW, iN, iD, iQ, iJ, iH]), - i0 = tU(iY), - i1 = tN({ features: t2(tU(tI())) }), - i2 = tN({ events: i0.check(tE(1)), options: t2(i1) }), - i6 = tN({ events: iX.check(tE(1)), options: t2(i1) }), - i3 = tN({ - id: tI(), - isReturningVisitor: tT(), - landingPage: iC, - count: tj(), - activeSessionLength: tj(), - averageSessionLength: tj(), - }), - i4 = tN({ - id: tI(), - stableId: tI(), - random: tj(), - audiences: tU(tI()), - traits: iB, - location: iT, - session: i3, - }), - i8 = tZ(tN({ id: tI() }), t9()), - i5 = tN({ data: tN(), message: tI(), error: t3(tT()) }), - i9 = tD(i5, { data: tN({ profiles: t2(tU(i4)) }) }), - i7 = tN({ - key: tI(), - type: tL([tX(['Variable']), tI()]), - meta: tN({ experienceId: tI(), variantIndex: tj() }), - }), - ne = tL([tI(), tT(), tC(), tj(), tW(tI(), t9())]) - tD(i7, { type: tI(), value: new tB({ type: 'unknown' }) }) - let nt = tU(tH('type', [tD(i7, { type: t0('Variable'), value: ne })])), - ni = tU( - tN({ - experienceId: tI(), - variantIndex: tj(), - variants: tW(tI(), tI()), - sticky: t2(t8(tT(), !1)), - }), - ), - nn = tD(i5, { data: tN({ profile: i4, experiences: ni, changes: nt }) }), - nr = tH('type', [ - iW, - tD(iK, { type: t0('component_click') }), - tD(iK, { type: t0('component_hover'), hoverDurationMs: tj(), hoverId: tI() }), - ]), - ns = tN({ profile: i8, events: tU(nr) }), - no = tU(ns) - function na(e, t) { - let i = e.safeParse(t) - if (i.success) return i.data - throw Error( - (function (e) { - let t = [] - for (let i of [...e.issues].sort((e, t) => (e.path ?? []).length - (t.path ?? []).length)) - (t.push(`✖ ${i.message}`), - i.path?.length && - t.push( - ` → at ${(function (e) { - let t = [] - for (let i of e.map((e) => ('object' == typeof e ? e.key : e))) - 'number' == typeof i - ? t.push(`[${i}]`) - : 'symbol' == typeof i - ? t.push(`[${JSON.stringify(String(i))}]`) - : /[^\w$]/.test(i) - ? t.push(`[${JSON.stringify(i)}]`) - : (t.length && t.push('.'), t.push(i)) - return t.join('') - })(i.path)}`, - )) - return t.join('\n') - })(i.error), - ) - } - eS({ - localeError: - ((r = { - string: { unit: 'characters', verb: 'to have' }, - file: { unit: 'bytes', verb: 'to have' }, - array: { unit: 'items', verb: 'to have' }, - set: { unit: 'items', verb: 'to have' }, - map: { unit: 'entries', verb: 'to have' }, - }), - (s = { - regex: 'input', - email: 'email address', - url: 'URL', - emoji: 'emoji', - uuid: 'UUID', - uuidv4: 'UUIDv4', - uuidv6: 'UUIDv6', - nanoid: 'nanoid', - guid: 'GUID', - cuid: 'cuid', - cuid2: 'cuid2', - ulid: 'ULID', - xid: 'XID', - ksuid: 'KSUID', - datetime: 'ISO datetime', - date: 'ISO date', - time: 'ISO time', - duration: 'ISO duration', - ipv4: 'IPv4 address', - ipv6: 'IPv6 address', - mac: 'MAC address', - cidrv4: 'IPv4 range', - cidrv6: 'IPv6 range', - base64: 'base64-encoded string', - base64url: 'base64url-encoded string', - json_string: 'JSON string', - e164: 'E.164 number', - jwt: 'JWT', - template_literal: 'input', - }), - (o = { nan: 'NaN' }), - (e) => { - switch (e.code) { - case 'invalid_type': { - let t = o[e.expected] ?? e.expected, - i = (function (e) { - let t = typeof e - switch (t) { - case 'number': - return Number.isNaN(e) ? 'nan' : 'number' - case 'object': - if (null === e) return 'null' - if (Array.isArray(e)) return 'array' - if ( - e && - Object.getPrototypeOf(e) !== Object.prototype && - 'constructor' in e && - e.constructor - ) - return e.constructor.name - } - return t - })(e.input), - n = o[i] ?? i - return `Invalid input: expected ${t}, received ${n}` - } - case 'invalid_value': - if (1 === e.values.length) return `Invalid input: expected ${eU(e.values[0])}` - return `Invalid option: expected one of ${eE(e.values, '|')}` - case 'too_big': { - let t = e.inclusive ? '<=' : '<', - i = r[e.origin] ?? null - if (i) - return `Too big: expected ${e.origin ?? 'value'} to have ${t}${e.maximum.toString()} ${i.unit ?? 'elements'}` - return `Too big: expected ${e.origin ?? 'value'} to be ${t}${e.maximum.toString()}` - } - case 'too_small': { - let t = e.inclusive ? '>=' : '>', - i = r[e.origin] ?? null - if (i) - return `Too small: expected ${e.origin} to have ${t}${e.minimum.toString()} ${i.unit}` - return `Too small: expected ${e.origin} to be ${t}${e.minimum.toString()}` - } - case 'invalid_format': - if ('starts_with' === e.format) return `Invalid string: must start with "${e.prefix}"` - if ('ends_with' === e.format) return `Invalid string: must end with "${e.suffix}"` - if ('includes' === e.format) return `Invalid string: must include "${e.includes}"` - if ('regex' === e.format) return `Invalid string: must match pattern ${e.pattern}` - return `Invalid ${s[e.format] ?? e.format}` - case 'not_multiple_of': - return `Invalid number: must be a multiple of ${e.divisor}` - case 'unrecognized_keys': - return `Unrecognized key${e.keys.length > 1 ? 's' : ''}: ${eE(e.keys, ', ')}` - case 'invalid_key': - return `Invalid key in ${e.origin}` - case 'invalid_union': - default: - return 'Invalid input' - case 'invalid_element': - return `Invalid value in ${e.origin}` - } - }), - }) - let nl = new (class { - name = '@contentful/optimization' - PREFIX_PARTS = ['Ctfl', 'O10n'] - DELIMITER = ':' - sinks = [] - assembleLocationPrefix(e) { - return `[${[...this.PREFIX_PARTS, e].join(this.DELIMITER)}]` - } - addSink(e) { - this.sinks = [...this.sinks.filter((t) => t.name !== e.name), e] - } - removeSink(e) { - this.sinks = this.sinks.filter((t) => t.name !== e) - } - removeSinks() { - this.sinks = [] - } - debug(e, t, ...i) { - this.emit('debug', e, t, ...i) - } - info(e, t, ...i) { - this.emit('info', e, t, ...i) - } - log(e, t, ...i) { - this.emit('log', e, t, ...i) - } - warn(e, t, ...i) { - this.emit('warn', e, t, ...i) - } - error(e, t, ...i) { - this.emit('error', e, t, ...i) - } - fatal(e, t, ...i) { - this.emit('fatal', e, t, ...i) - } - emit(e, t, i, ...n) { - this.onLogEvent({ - name: this.name, - level: e, - messages: [`${this.assembleLocationPrefix(t)} ${String(i)}`, ...n], - }) - } - onLogEvent(e) { - this.sinks.forEach((t) => { - t.ingest(e) - }) - } - })() - function nu(e) { - return { - debug: (t, ...i) => { - nl.debug(e, t, ...i) - }, - info: (t, ...i) => { - nl.info(e, t, ...i) - }, - log: (t, ...i) => { - nl.log(e, t, ...i) - }, - warn: (t, ...i) => { - nl.warn(e, t, ...i) - }, - error: (t, ...i) => { - nl.error(e, t, ...i) - }, - fatal: (t, ...i) => { - nl.fatal(e, t, ...i) - }, - } - } - let nc = { fatal: 60, error: 50, warn: 40, info: 30, debug: 20, log: 10 }, - nd = class {}, - np = { - debug: (...e) => { - console.debug(...e) - }, - info: (...e) => { - console.info(...e) - }, - log: (...e) => { - console.log(...e) - }, - warn: (...e) => { - console.warn(...e) - }, - error: (...e) => { - console.error(...e) - }, - fatal: (...e) => { - console.error(...e) - }, - } - class nh extends nd { - name = 'ConsoleLogSink' - verbosity - constructor(e) { - ;(super(), (this.verbosity = e ?? 'error')) - } - ingest(e) { - nc[e.level] < nc[this.verbosity] || np[e.level](...e.messages) - } - } - let nf = nu('ApiClient:Retry') - class nv extends Error { - status - constructor(e, t = 500) { - ;(super(e), Object.setPrototypeOf(this, nv.prototype), (this.status = t)) - } - } - async function ny(e) { - if (e <= 0) return - let { promise: t, resolve: i } = Promise.withResolvers() - ;(setTimeout(() => { - i(void 0) - }, e), - await t) - } - let ng = nu('ApiClient:Timeout'), - nm = nu('ApiClient:Fetch'), - nb = function (e) { - try { - let t = (function ({ - apiName: e = 'Optimization', - fetchMethod: t = fetch, - onRequestTimeout: i, - requestTimeout: n = 3e3, - } = {}) { - return async (r, s) => { - let o = new AbortController(), - a = setTimeout(() => { - ;('function' == typeof i - ? i({ apiName: e }) - : ng.error(`Request to "${r.toString()}" timed out`, Error('Request timeout')), - o.abort()) - }, n), - l = await t(r, { ...s, signal: o.signal }) - return (clearTimeout(a), l) - } - })(e) - return (function ({ - apiName: e = 'Optimization', - fetchMethod: t = fetch, - intervalTimeout: i = 0, - onFailedAttempt: n, - retries: r = 1, - } = {}) { - return async (s, o) => { - let a = new AbortController(), - l = r + 1, - u = (function ({ - apiName: e = 'Optimization', - controller: t, - fetchMethod: i = fetch, - init: n, - url: r, - }) { - return async () => { - try { - let s = await i(r, n) - if (503 === s.status) - throw new nv( - `${e} API request to "${r.toString()}" failed with status: "[${s.status}] ${s.statusText}".`, - 503, - ) - if (!s.ok) { - let e = Error( - `Request to "${r.toString()}" failed with status: [${s.status}] ${s.statusText} - traceparent: ${s.headers.get('traceparent')}`, - ) - ;(nf.error('Request failed with non-OK status:', e), t.abort()) - return - } - return (nf.debug(`Response from "${r.toString()}":`, s), s) - } catch (e) { - if (e instanceof nv && 503 === e.status) throw e - ;(nf.error(`Request to "${r.toString()}" failed:`, e), t.abort()) - } - } - })({ apiName: e, controller: a, fetchMethod: t, init: o, url: s }) - for (let t = 1; t <= l; t++) - try { - let e = await u() - if (e) return e - break - } catch (s) { - if (!(s instanceof nv) || 503 !== s.status) throw s - let r = l - t - if ((n?.({ apiName: e, error: s, attemptNumber: t, retriesLeft: r }), 0 === r)) - throw s - await ny(i) - } - throw Error(`${e} API request to "${s.toString()}" may not be retried.`) - } - })({ ...e, fetchMethod: t }) - } catch (e) { - throw ( - e instanceof Error && - ('AbortError' === e.name - ? nm.warn('Request aborted due to network issues. This request may not be retried.') - : nm.error('Request failed:', e)), - e - ) - } - }, - nw = nu('ApiClient'), - n_ = class { - name - clientId - environment - fetch - constructor(e, { fetchOptions: t, clientId: i, environment: n }) { - ;((this.clientId = i), - (this.environment = n ?? 'main'), - (this.name = e), - (this.fetch = nb({ ...(t ?? {}), apiName: e }))) - } - logRequestError(e, { requestName: t }) { - e instanceof Error && - ('AbortError' === e.name - ? nw.warn( - `[${this.name}] "${t}" request aborted due to network issues. This request may not be retried.`, - ) - : nw.error(`[${this.name}] "${t}" request failed:`, e)) - } - }, - nz = nu('ApiClient:Experience') - class nO extends n_ { - baseUrl - enabledFeatures - ip - locale - plainText - preflight - constructor(e) { - super('Experience', e) - const { baseUrl: t, enabledFeatures: i, ip: n, locale: r, plainText: s, preflight: o } = e - ;((this.baseUrl = t || 'https://experience.ninetailed.co/'), - (this.enabledFeatures = i), - (this.ip = n), - (this.locale = r), - (this.plainText = s), - (this.preflight = o)) - } - async getProfile(e, t = {}) { - if (!e) throw Error('Valid profile ID required.') - let i = 'Get Profile' - nz.info(`Sending "${i}" request`) - try { - let n = await this.fetch( - this.constructUrl( - `v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`, - t, - ), - { method: 'GET' }, - ), - { - data: { changes: r, experiences: s, profile: o }, - } = na(nn, await n.json()) - return ( - nz.debug(`"${i}" request successfully completed`), - { changes: r, selectedOptimizations: s, profile: o } - ) - } catch (e) { - throw (this.logRequestError(e, { requestName: i }), e) - } - } - async makeProfileMutationRequest({ url: e, body: t, options: i }) { - return await this.fetch(this.constructUrl(e, i), { - method: 'POST', - headers: this.constructHeaders(i), - body: JSON.stringify(t), - keepalive: !0, - }) - } - async createProfile({ events: e }, t = {}) { - let i = 'Create Profile' - nz.info(`Sending "${i}" request`) - let n = this.constructExperienceRequestBody(e, t) - nz.debug(`"${i}" request body:`, n) - try { - let e = await this.makeProfileMutationRequest({ - url: `v2/organizations/${this.clientId}/environments/${this.environment}/profiles`, - body: n, - options: t, - }), - { - data: { changes: r, experiences: s, profile: o }, - } = na(nn, await e.json()) - return ( - nz.debug(`"${i}" request successfully completed`), - { changes: r, selectedOptimizations: s, profile: o } - ) - } catch (e) { - throw (this.logRequestError(e, { requestName: i }), e) - } - } - async updateProfile({ profileId: e, events: t }, i = {}) { - if (!e) throw Error('Valid profile ID required.') - let n = 'Update Profile' - nz.info(`Sending "${n}" request`) - let r = this.constructExperienceRequestBody(t, i) - nz.debug(`"${n}" request body:`, r) - try { - let t = await this.makeProfileMutationRequest({ - url: `v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`, - body: r, - options: i, - }), - { - data: { changes: s, experiences: o, profile: a }, - } = na(nn, await t.json()) - return ( - nz.debug(`"${n}" request successfully completed`), - { changes: s, selectedOptimizations: o, profile: a } - ) - } catch (e) { - throw (this.logRequestError(e, { requestName: n }), e) - } - } - async upsertProfile({ profileId: e, events: t }, i) { - return e - ? await this.updateProfile({ profileId: e, events: t }, i) - : await this.createProfile({ events: t }, i) - } - async upsertManyProfiles({ events: e }, t = {}) { - let i = 'Upsert Many Profiles' - nz.info(`Sending "${i}" request`) - let n = na(i6, { events: e, options: this.constructBodyOptions(t) }) - nz.debug(`"${i}" request body:`, n) - try { - let e = await this.makeProfileMutationRequest({ - url: `v2/organizations/${this.clientId}/environments/${this.environment}/events`, - body: n, - options: { plainText: !1, ...t }, - }), - { - data: { profiles: r }, - } = na(i9, await e.json()) - return (nz.debug(`"${i}" request successfully completed`), r) - } catch (e) { - throw (this.logRequestError(e, { requestName: i }), e) - } - } - constructUrl(e, t) { - let i = new URL(e, this.baseUrl), - n = t.locale ?? this.locale, - r = t.preflight ?? this.preflight - return ( - n && i.searchParams.set('locale', n), - r && i.searchParams.set('type', 'preflight'), - i.toString() - ) - } - constructHeaders({ ip: e = this.ip, plainText: t = this.plainText }) { - let i = new Map() - return ( - e && i.set('X-Force-IP', e), - (t ?? this.plainText ?? !0) - ? i.set('Content-Type', 'text/plain') - : i.set('Content-Type', 'application/json'), - Object.fromEntries(i) - ) - } - constructBodyOptions = ({ enabledFeatures: e = this.enabledFeatures }) => { - let t = {} - return ( - e && Array.isArray(e) && e.length > 0 - ? (t.features = e) - : (t.features = ['ip-enrichment', 'location']), - t - ) - } - constructExperienceRequestBody(e, t) { - return i2.parse({ events: na(i0, e), options: this.constructBodyOptions(t) }) - } - } - let nS = nu('ApiClient:Insights') - class nE extends n_ { - baseUrl - beaconHandler - constructor(e) { - super('Insights', e) - const { baseUrl: t, beaconHandler: i } = e - ;((this.baseUrl = t || 'https://ingest.insights.ninetailed.co/'), (this.beaconHandler = i)) - } - async sendBatchEvents(e, t = {}) { - let { beaconHandler: i = this.beaconHandler } = t, - n = new URL( - `v1/organizations/${this.clientId}/environments/${this.environment}/events`, - this.baseUrl, - ), - r = na(no, e) - if ('function' == typeof i) { - if ((nS.debug('Queueing events via beaconHandler'), i(n, r))) return !0 - nS.warn( - 'beaconHandler failed to queue events; events will be emitted immediately via fetch', - ) - } - let s = 'Event Batches' - ;(nS.info(`Sending "${s}" request`), nS.debug(`"${s}" request body:`, r)) - try { - return ( - await this.fetch(n, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(r), - keepalive: !0, - }), - nS.debug(`"${s}" request successfully completed`), - !0 - ) - } catch (e) { - return (this.logRequestError(e, { requestName: s }), !1) - } - } - } - class nx { - config - experience - insights - constructor(e) { - const { experience: t, insights: i, clientId: n, environment: r, fetchOptions: s } = e, - o = { clientId: n, environment: r, fetchOptions: s } - ;((this.config = o), - (this.experience = new nO({ ...o, ...t })), - (this.insights = new nE({ ...o, ...i }))) - } - } - function nk(e) { - if (!e || 'object' != typeof e) return !1 - let t = Object.getPrototypeOf(e) - return ( - (null === t || t === Object.prototype || null === Object.getPrototypeOf(t)) && - '[object Object]' === Object.prototype.toString.call(e) - ) - } - function nI(e) { - return nk(e) || Array.isArray(e) - } - let n$ = tN({ - campaign: t2(i$), - locale: t2(tI()), - location: t2(iT), - page: t2(iC), - screen: t2(iM), - userAgent: t2(tI()), - }), - nP = tD(n$, { componentId: tI(), experienceId: t2(tI()), variantIndex: t2(tj()) }), - nj = tD(nP, { sticky: t2(tT()), viewId: tI(), viewDurationMs: tj() }), - nA = tD(nP, { viewId: t2(tI()), viewDurationMs: t2(tj()) }), - nT = tD(nP, { hoverId: tI(), hoverDurationMs: tj() }), - nF = tD(n$, { traits: t2(iB), userId: tI() }), - nC = tD(n$, { - properties: t2( - (function (e, t) { - var i = void 0 - let n = e._zod.def.checks - if (n && n.length > 0) - throw Error('.partial() cannot be used on object schemas containing refinements') - let r = eA(e._zod.def, { - get shape() { - let t = e._zod.def.shape, - n = { ...t } - if (i) - for (let e in i) { - if (!(e in t)) throw Error(`Unrecognized key: "${e}"`) - i[e] && (n[e] = t1 ? new t1({ type: 'optional', innerType: t[e] }) : t[e]) - } - else - for (let e in t) n[e] = t1 ? new t1({ type: 'optional', innerType: t[e] }) : t[e] - return (ej(this, 'shape', n), n) - }, - checks: [], - }) - return eB(e, r) - })(iC), - ), - }), - nR = tD(n$, { name: tI(), properties: iR }), - nM = tD(n$, { event: tI(), properties: t2(t8(iR, {})) }), - nB = { path: '', query: {}, referrer: '', search: '', title: '', url: '' }, - nq = class { - app - channel - library - getLocale - getPageProperties - getUserAgent - constructor(e) { - const { - app: t, - channel: i, - library: n, - getLocale: r, - getPageProperties: s, - getUserAgent: o, - } = e - ;((this.app = t), - (this.channel = i), - (this.library = n), - (this.getLocale = r ?? (() => 'en-US')), - (this.getPageProperties = s ?? (() => nB)), - (this.getUserAgent = o ?? (() => void 0))) - } - buildUniversalEventProperties({ - campaign: e = {}, - locale: t, - location: i, - page: n, - screen: r, - userAgent: s, - }) { - let o = new Date().toISOString() - return { - channel: this.channel, - context: { - app: this.app, - campaign: e, - gdpr: { isConsentGiven: !0 }, - library: this.library, - locale: t ?? this.getLocale() ?? 'en-US', - location: i, - page: n ?? this.getPageProperties(), - screen: r, - userAgent: s ?? this.getUserAgent(), - }, - messageId: crypto.randomUUID(), - originalTimestamp: o, - sentAt: o, - timestamp: o, - } - } - buildEntryInteractionBase(e, t, i, n) { - return { - ...this.buildUniversalEventProperties(e), - componentType: 'Entry', - componentId: t, - experienceId: i, - variantIndex: n ?? 0, - } - } - buildView(e) { - let { - componentId: t, - viewId: i, - experienceId: n, - variantIndex: r, - viewDurationMs: s, - ...o - } = na(nj, e) - return { - ...this.buildEntryInteractionBase(o, t, n, r), - type: 'component', - viewId: i, - viewDurationMs: s, - } - } - buildClick(e) { - let { componentId: t, experienceId: i, variantIndex: n, ...r } = na(nP, e) - return { ...this.buildEntryInteractionBase(r, t, i, n), type: 'component_click' } - } - buildHover(e) { - let { - hoverId: t, - componentId: i, - experienceId: n, - hoverDurationMs: r, - variantIndex: s, - ...o - } = na(nT, e) - return { - ...this.buildEntryInteractionBase(o, i, n, s), - type: 'component_hover', - hoverId: t, - hoverDurationMs: r, - } - } - buildFlagView(e) { - let { - componentId: t, - experienceId: i, - variantIndex: n, - viewId: r, - viewDurationMs: s, - ...o - } = na(nA, e) - return { - ...this.buildEntryInteractionBase(o, t, i, n), - ...(void 0 === s ? {} : { viewDurationMs: s }), - ...(void 0 === r ? {} : { viewId: r }), - type: 'component', - componentType: 'Variable', - } - } - buildIdentify(e) { - let { traits: t = {}, userId: i, ...n } = na(nF, e) - return { - ...this.buildUniversalEventProperties(n), - type: 'identify', - traits: t, - userId: i, - } - } - buildPageView(e = {}) { - let { properties: t = {}, ...i } = na(nC, e), - n = this.getPageProperties(), - r = (function e(t, i) { - let n = Object.keys(i) - for (let r = 0; r < n.length; r++) { - let s = n[r] - if ('__proto__' === s) continue - let o = i[s], - a = t[s] - nI(o) && nI(a) - ? (t[s] = e(a, o)) - : Array.isArray(o) - ? (t[s] = e([], o)) - : nk(o) - ? (t[s] = e({}, o)) - : (void 0 === a || void 0 !== o) && (t[s] = o) - } - return t - })({ ...n, title: n.title ?? nB.title }, t), - { - context: { screen: s, ...o }, - ...a - } = this.buildUniversalEventProperties(i), - l = na(iZ, o) - return { ...a, context: l, type: 'page', properties: r } - } - buildScreenView(e) { - let { name: t, properties: i, ...n } = na(nR, e), - { - context: { page: r, ...s }, - ...o - } = this.buildUniversalEventProperties(n), - a = na(iL, { ...s, screen: s.screen ?? { name: t } }) - return { ...o, context: a, type: 'screen', name: t, properties: { name: t, ...i } } - } - buildTrack(e) { - let { event: t, properties: i = {}, ...n } = na(nM, e) - return { - ...this.buildUniversalEventProperties(n), - type: 'track', - event: t, - properties: i, - } - } - } - class nU { - interceptors = new Map() - nextId = 0 - add(e) { - let { nextId: t } = this - return ((this.nextId += 1), this.interceptors.set(t, e), t) - } - remove(e) { - return this.interceptors.delete(e) - } - clear() { - this.interceptors.clear() - } - count() { - return this.interceptors.size - } - async run(e) { - let t = Array.from(this.interceptors.values()), - i = e - for (let e of t) i = await e(ea(i)) - return i - } - } - let nV = { - resolve: (e) => - e - ? e.reduce((e, { key: t, value: i }) => { - let n = - 'object' == typeof i && null !== i && 'value' in i && 'object' == typeof i.value - ? i.value - : i - return ((e[t] = n), e) - }, {}) - : {}, - }, - nN = nu('Optimization'), - nD = 'Could not resolve Merge Tag value:', - nZ = (e, t) => { - if (!e || 'object' != typeof e) return - if (!t) return e - let i = e - for (let e of t.split('.').filter(Boolean)) { - if (!i || ('object' != typeof i && 'function' != typeof i)) return - i = Reflect.get(i, e) - } - return i - }, - nQ = { - normalizeSelectors: (e) => - e - .split('_') - .map((e, t, i) => - [i.slice(0, t).join('.'), i.slice(t).join('_')].filter((e) => '' !== e).join('.'), - ), - getValueFromProfile(e, t) { - let i = nQ.normalizeSelectors(e).find((e) => nZ(t, e)) - if (!i) return - let n = nZ(t, i) - if (n && ('string' == typeof n || 'number' == typeof n || 'boolean' == typeof n)) - return `${n}` - }, - resolve(e, t) { - if (!ih.safeParse(e).success) - return void nN.warn(`${nD} supplied entry is not a Merge Tag entry`) - let { - fields: { nt_fallback: i }, - } = e - return i4.safeParse(t).success - ? (nQ.getValueFromProfile(e.fields.nt_mergetag_id, t) ?? i) - : (nN.warn(`${nD} no valid profile`), i) - }, - }, - nL = nu('Optimization'), - nJ = 'Could not resolve optimized entry variant:', - nH = { - getOptimizationEntry({ optimizedEntry: e, selectedOptimizations: t }, i = !1) { - if (i || (t.length && ik(e))) - return e.fields.nt_experiences - .filter((e) => ix(e)) - .find((e) => t.some(({ experienceId: t }) => t === e.fields.nt_experience_id)) - }, - getSelectedOptimization({ optimizationEntry: e, selectedOptimizations: t }, i = !1) { - if (i || (t.length && ix(e))) - return t.find(({ experienceId: t }) => t === e.fields.nt_experience_id) - }, - getSelectedVariant( - { optimizedEntry: e, optimizationEntry: t, selectedVariantIndex: i }, - n = !1, - ) { - var r - if (!n && (!ik(e) || !ix(t))) return - let s = ((r = t.fields.nt_config), - { - distribution: r?.distribution === void 0 ? [] : [...r.distribution], - traffic: r?.traffic ?? 0, - components: r?.components === void 0 ? [] : [...r.components], - sticky: r?.sticky ?? !1, - }).components - .filter( - (e) => ('EntryReplacement' === e.type || void 0 === e.type) && !e.baseline.hidden, - ) - .find((t) => t.baseline.id === e.sys.id)?.variants - if (s?.length) return s.at(i - 1) - }, - getSelectedVariantEntry({ optimizationEntry: e, selectedVariant: t }, i = !1) { - if (!i && (!ix(e) || !iv.safeParse(t).success)) return - let n = e.fields.nt_variants?.find((e) => e.sys.id === t.id) - return ic.safeParse(n).success ? n : void 0 - }, - resolve: function (e, t) { - if ((nL.debug(`Resolving optimized entry for baseline entry ${e.sys.id}`), !t?.length)) - return ( - nL.warn(`${nJ} no selectedOptimizations exist for the current profile`), - { entry: e } - ) - if (!ik(e)) return (nL.warn(`${nJ} entry ${e.sys.id} is not optimized`), { entry: e }) - let i = nH.getOptimizationEntry({ optimizedEntry: e, selectedOptimizations: t }, !0) - if (!i) - return ( - nL.warn(`${nJ} could not find an optimization entry for ${e.sys.id}`), - { entry: e } - ) - let n = nH.getSelectedOptimization( - { optimizationEntry: i, selectedOptimizations: t }, - !0, - ), - r = n?.variantIndex ?? 0 - if (0 === r) - return ( - nL.debug(`Resolved optimization entry for entry ${e.sys.id} is baseline`), - { entry: e } - ) - let s = nH.getSelectedVariant( - { optimizedEntry: e, optimizationEntry: i, selectedVariantIndex: r }, - !0, - ) - if (!s) - return ( - nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`), - { entry: e } - ) - let o = nH.getSelectedVariantEntry({ optimizationEntry: i, selectedVariant: s }, !0) - return o - ? (nL.debug(`Entry ${e.sys.id} has been resolved to variant entry ${o.sys.id}`), - { entry: o, selectedOptimization: n }) - : (nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`), - { entry: e }) - }, - } - class nK { - api - eventBuilder - config - flagsResolver = nV - mergeTagValueResolver = nQ - optimizedEntryResolver = nH - interceptors = { event: new nU(), state: new nU() } - constructor(e, t = {}) { - this.config = e - const { eventBuilder: i, logLevel: n, environment: r, clientId: s, fetchOptions: o } = e - nl.addSink(new nh(n)) - const a = { - clientId: s, - environment: r, - fetchOptions: o, - experience: t.experience, - insights: t.insights, - } - ;((this.api = new nx(a)), - (this.eventBuilder = new nq( - i ?? { - channel: 'server', - library: { name: '@contentful/optimization-android-bridge', version: '0.0.0' }, - }, - ))) - } - getFlag(e, t) { - return this.flagsResolver.resolve(t)[e] - } - resolveOptimizedEntry(e, t) { - return this.optimizedEntryResolver.resolve(e, t) - } - getMergeTagValue(e, t) { - return this.mergeTagValueResolver.resolve(e, t) - } - } - let nW = nK - function nG() {} - function nX(e, t) { - return (function e(t, i, n, r, s, o, a) { - let l = a(t, i, n, r, s, o) - if (void 0 !== l) return l - if (typeof t == typeof i) - switch (typeof t) { - case 'bigint': - case 'string': - case 'boolean': - case 'symbol': - case 'undefined': - case 'function': - return t === i - case 'number': - return t === i || Object.is(t, i) - } - return (function t(i, n, r, s) { - if (Object.is(i, n)) return !0 - let o = F(i), - a = F(n) - if ((o === q && (o = L), a === q && (a = L), o !== a)) return !1 - switch (o) { - case R: - return i.toString() === n.toString() - case M: { - let e = i.valueOf(), - t = n.valueOf() - return e === t || (Number.isNaN(e) && Number.isNaN(t)) - } - case B: - case V: - case U: - return Object.is(i.valueOf(), n.valueOf()) - case C: - return i.source === n.source && i.flags === n.flags - case '[object Function]': - return i === n - } - let l = (r = r ?? new Map()).get(i), - u = r.get(n) - if (null != l && null != u) return l === n - ;(r.set(i, n), r.set(n, i)) - try { - switch (o) { - case N: - if (i.size !== n.size) return !1 - for (let [t, o] of i.entries()) - if (!n.has(t) || !e(o, n.get(t), t, i, n, r, s)) return !1 - return !0 - case D: { - if (i.size !== n.size) return !1 - let t = Array.from(i.values()), - o = Array.from(n.values()) - for (let a = 0; a < t.length; a++) { - let l = t[a], - u = o.findIndex((t) => e(l, t, void 0, i, n, r, s)) - if (-1 === u) return !1 - o.splice(u, 1) - } - return !0 - } - case Z: - case H: - case K: - case W: - case G: - case '[object BigUint64Array]': - case X: - case Y: - case ee: - case '[object BigInt64Array]': - case et: - case ei: - if (er(i) !== er(n) || i.length !== n.length) return !1 - for (let t = 0; t < i.length; t++) if (!e(i[t], n[t], t, i, n, r, s)) return !1 - return !0 - case Q: - if (i.byteLength !== n.byteLength) return !1 - return t(new Uint8Array(i), new Uint8Array(n), r, s) - case J: - if (i.byteLength !== n.byteLength || i.byteOffset !== n.byteOffset) return !1 - return t(new Uint8Array(i), new Uint8Array(n), r, s) - case '[object Error]': - return i.name === n.name && i.message === n.message - case L: { - if (!(t(i.constructor, n.constructor, r, s) || (nk(i) && nk(n)))) return !1 - let o = [...Object.keys(i), ...T(i)], - a = [...Object.keys(n), ...T(n)] - if (o.length !== a.length) return !1 - for (let t = 0; t < o.length; t++) { - let a = o[t], - l = i[a] - if (!Object.hasOwn(n, a)) return !1 - let u = n[a] - if (!e(l, u, a, i, n, r, s)) return !1 - } - return !0 - } - default: - return !1 - } - } finally { - ;(r.delete(i), r.delete(n)) - } - })(t, i, o, a) - })(e, t, void 0, void 0, void 0, void 0, nG) - } - let nY = nu('CoreStateful'), - n0 = { - trackView: 'component', - trackFlagView: 'component', - trackClick: 'component_click', - trackHover: 'component_hover', - } - class n1 extends nW { - flagObservables = new Map() - lastTrackedFlagValues = new Map() - getFlag(e, t = eu.value) { - let i = super.getFlag(e, t) - if (!nX(i, this.lastTrackedFlagValues.get(e))) { - this.lastTrackedFlagValues.set(e, i) - let n = this.buildFlagViewBuilderArgs(e, t) - this.trackFlagView(n).catch((t) => { - nl.warn(`Failed to emit "flag view" event for "${e}"`, String(t)) - }) - } - return i - } - resolveOptimizedEntry(e, t = ey.value) { - return super.resolveOptimizedEntry(e, t) - } - getMergeTagValue(e, t = em.value) { - return super.getMergeTagValue(e, t) - } - async identify(e) { - let { profile: t, ...i } = e - return await this.sendExperienceEvent( - 'identify', - [e], - this.eventBuilder.buildIdentify(i), - t, - ) - } - async page(e = {}) { - let { profile: t, ...i } = e - return await this.sendExperienceEvent('page', [e], this.eventBuilder.buildPageView(i), t) - } - async screen(e) { - let { profile: t, ...i } = e - return await this.sendExperienceEvent( - 'screen', - [e], - this.eventBuilder.buildScreenView(i), - t, - ) - } - async track(e) { - let { profile: t, ...i } = e - return await this.sendExperienceEvent('track', [e], this.eventBuilder.buildTrack(i), t) - } - async trackView(e) { - let t, - { profile: i, ...n } = e - return ( - e.sticky && - (t = await this.sendExperienceEvent( - 'trackView', - [e], - this.eventBuilder.buildView(n), - i, - )), - await this.sendInsightsEvent('trackView', [e], this.eventBuilder.buildView(n), i), - t - ) - } - async trackClick(e) { - await this.sendInsightsEvent('trackClick', [e], this.eventBuilder.buildClick(e)) - } - async trackHover(e) { - await this.sendInsightsEvent('trackHover', [e], this.eventBuilder.buildHover(e)) - } - async trackFlagView(e) { - await this.sendInsightsEvent('trackFlagView', [e], this.eventBuilder.buildFlagView(e)) - } - hasConsent(e) { - let { [e]: t } = n0, - i = - void 0 !== t - ? this.allowedEventTypes.includes(t) - : this.allowedEventTypes.some((t) => t === e) - return !!ed.value || i - } - onBlockedByConsent(e, t) { - ;(nY.warn(`Event "${e}" was blocked due to lack of consent; payload: ${JSON.stringify(t)}`), - this.reportBlockedEvent('consent', e, t)) - } - async sendExperienceEvent(e, t, i, n) { - return this.hasConsent(e) - ? await this.experienceQueue.send(i) - : void this.onBlockedByConsent(e, t) - } - async sendInsightsEvent(e, t, i, n) { - this.hasConsent(e) ? await this.insightsQueue.send(i) : this.onBlockedByConsent(e, t) - } - buildFlagViewBuilderArgs(e, t = eu.value) { - let i = t?.find((t) => t.key === e) - return { - componentId: e, - experienceId: i?.meta.experienceId, - variantIndex: i?.meta.variantIndex, - } - } - getFlagObservable(e) { - var t - let i, - n = this.flagObservables.get(e) - if (n) return n - let r = this.trackFlagView.bind(this), - s = this.buildFlagViewBuilderArgs.bind(this), - o = - ((t = ew.computed(() => super.getFlag(e, eu.value))), - (i = el(t)), - { - get current() { - return i.current - }, - subscribe(e) { - let t = !1, - n = ea(i.current) - return i.subscribe((i) => { - ;(t && nX(n, i)) || ((t = !0), (n = ea(i)), e(i)) - }) - }, - subscribeOnce: (e) => i.subscribeOnce(e), - }), - a = { - get current() { - let { current: t } = o - return ( - r(s(e, eu.value)).catch((t) => { - nl.warn(`Failed to emit "flag view" event for "${e}"`, String(t)) - }), - t - ) - }, - subscribe: (t) => - o.subscribe((i) => { - ;(r(s(e, eu.value)).catch((t) => { - nl.warn(`Failed to emit "flag view" event for "${e}"`, String(t)) - }), - t(i)) - }), - subscribeOnce: (t) => - o.subscribeOnce((i) => { - ;(r(s(e, eu.value)).catch((t) => { - nl.warn(`Failed to emit "flag view" event for "${e}"`, String(t)) - }), - t(i)) - }), - } - return (this.flagObservables.set(e, a), a) - } - reportBlockedEvent(e, t, i) { - let n = { reason: e, method: t, args: i } - try { - this.onEventBlocked?.(n) - } catch (e) { - nY.warn(`onEventBlocked callback failed for method "${t}"`, e) - } - ec.value = n - } - } - let n2 = n1, - n6 = (e, t) => (!Number.isFinite(e) || void 0 === e || e < 1 ? t : Math.floor(e)), - n3 = { - flushIntervalMs: 3e4, - baseBackoffMs: 500, - maxBackoffMs: 3e4, - jitterRatio: 0.2, - maxConsecutiveFailures: 8, - circuitOpenMs: 12e4, - }, - n4 = '__ctfl_optimization_stateful_runtime_lock__', - n8 = () => { - let e = globalThis - return ((e[n4] ??= { owner: void 0 }), e[n4]) - }, - n5 = (e) => { - let t = n8() - t.owner === e && (t.owner = void 0) - } - class n9 { - circuitOpenUntil = 0 - flushFailureCount = 0 - flushInFlight = !1 - nextFlushAllowedAt = 0 - onCallbackError - onRetry - policy - retryTimer - constructor(e) { - const { onCallbackError: t, onRetry: i, policy: n } = e - ;((this.policy = n), (this.onRetry = i), (this.onCallbackError = t)) - } - reset() { - ;(this.clearScheduledRetry(), - (this.circuitOpenUntil = 0), - (this.flushFailureCount = 0), - (this.flushInFlight = !1), - (this.nextFlushAllowedAt = 0)) - } - clearScheduledRetry() { - void 0 !== this.retryTimer && (clearTimeout(this.retryTimer), (this.retryTimer = void 0)) - } - shouldSkip(e) { - let { force: t, isOnline: i } = e - if (this.flushInFlight) return !0 - if (t) return !1 - if (!i) return !0 - let n = Date.now() - return !!(this.nextFlushAllowedAt > n) || !!(this.circuitOpenUntil > n) - } - markFlushStarted() { - this.flushInFlight = !0 - } - markFlushFinished() { - this.flushInFlight = !1 - } - handleFlushSuccess() { - let { flushFailureCount: e } = this - ;(this.clearScheduledRetry(), - (this.circuitOpenUntil = 0), - (this.flushFailureCount = 0), - (this.nextFlushAllowedAt = 0), - e <= 0 || this.safeInvoke('onFlushRecovered', { consecutiveFailures: e })) - } - handleFlushFailure(e) { - let { queuedBatches: t, queuedEvents: i } = e - this.flushFailureCount += 1 - let n = ((e) => { - let { - consecutiveFailures: t, - policy: { baseBackoffMs: i, jitterRatio: n, maxBackoffMs: r }, - } = e, - s = Math.min(r, i * 2 ** Math.max(0, t - 1)), - o = s * n * Math.random() - return Math.round(s + o) - })({ consecutiveFailures: this.flushFailureCount, policy: this.policy }), - r = Date.now(), - s = { - consecutiveFailures: this.flushFailureCount, - queuedBatches: t, - queuedEvents: i, - retryDelayMs: n, - } - this.safeInvoke('onFlushFailure', s) - let { - circuitOpenUntil: o, - nextFlushAllowedAt: a, - openedCircuit: l, - retryDelayMs: u, - } = ((e) => { - let { - consecutiveFailures: t, - failureTimestamp: i, - retryDelayMs: n, - policy: { maxConsecutiveFailures: r, circuitOpenMs: s }, - } = e - if (t < r) - return { - openedCircuit: !1, - retryDelayMs: n, - nextFlushAllowedAt: i + n, - circuitOpenUntil: 0, - } - let o = i + s - return { openedCircuit: !0, retryDelayMs: s, nextFlushAllowedAt: o, circuitOpenUntil: o } - })({ - consecutiveFailures: this.flushFailureCount, - failureTimestamp: r, - retryDelayMs: n, - policy: this.policy, - }) - ;((this.nextFlushAllowedAt = a), - l && - ((this.circuitOpenUntil = o), - this.safeInvoke('onCircuitOpen', { ...s, retryDelayMs: u })), - this.scheduleRetry(u)) - } - scheduleRetry(e) { - ;(this.clearScheduledRetry(), - (this.retryTimer = setTimeout(() => { - ;((this.retryTimer = void 0), this.onRetry()) - }, e))) - } - safeInvoke(...e) { - let [t, i] = e - try { - if ('onFlushRecovered' === t) return void this.policy.onFlushRecovered?.(i) - if ('onCircuitOpen' === t) return void this.policy.onCircuitOpen?.(i) - this.policy.onFlushFailure?.(i) - } catch (e) { - this.onCallbackError?.(t, e) - } - } - } - let n7 = nu('CoreStateful') - class re { - experienceApi - eventInterceptors - flushRuntime - getAnonymousId - offlineMaxEvents - onOfflineDrop - queuedExperienceEvents = new Set() - stateInterceptors - constructor(e) { - const { - experienceApi: t, - eventInterceptors: i, - flushPolicy: n, - getAnonymousId: r, - offlineMaxEvents: s, - onOfflineDrop: o, - stateInterceptors: a, - } = e - ;((this.experienceApi = t), - (this.eventInterceptors = i), - (this.getAnonymousId = r), - (this.offlineMaxEvents = s), - (this.onOfflineDrop = o), - (this.stateInterceptors = a), - (this.flushRuntime = new n9({ - policy: n, - onRetry: () => { - this.flush() - }, - onCallbackError: (e, t) => { - n7.warn(`Experience flush policy callback "${e}" failed`, t) - }, - }))) - } - clearScheduledRetry() { - this.flushRuntime.clearScheduledRetry() - } - async send(e) { - let t = na(iY, await this.eventInterceptors.run(e)) - if (((ep.value = t), eh.value)) return await this.upsertProfile([t]) - ;(n7.debug(`Queueing ${t.type} event`, t), this.enqueueEvent(t)) - } - async flush(e = {}) { - let { force: t = !1 } = e - if (this.flushRuntime.shouldSkip({ force: t, isOnline: !!eh.value })) return - if (0 === this.queuedExperienceEvents.size) - return void this.flushRuntime.clearScheduledRetry() - n7.debug('Flushing offline Experience event queue') - let i = Array.from(this.queuedExperienceEvents) - this.flushRuntime.markFlushStarted() - try { - ;(await this.tryUpsertQueuedEvents(i)) - ? (i.forEach((e) => { - this.queuedExperienceEvents.delete(e) - }), - this.flushRuntime.handleFlushSuccess()) - : this.flushRuntime.handleFlushFailure({ - queuedBatches: +(this.queuedExperienceEvents.size > 0), - queuedEvents: this.queuedExperienceEvents.size, - }) - } finally { - this.flushRuntime.markFlushFinished() - } - } - enqueueEvent(e) { - let t = [] - if (this.queuedExperienceEvents.size >= this.offlineMaxEvents) { - let e = this.queuedExperienceEvents.size - this.offlineMaxEvents + 1 - ;(t = this.dropOldestEvents(e)).length > 0 && - n7.warn( - `Dropped ${t.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`, - ) - } - ;(this.queuedExperienceEvents.add(e), - t.length > 0 && - this.invokeOfflineDropCallback({ - droppedCount: t.length, - droppedEvents: t, - maxEvents: this.offlineMaxEvents, - queuedEvents: this.queuedExperienceEvents.size, - })) - } - dropOldestEvents(e) { - let t = [] - for (let i = 0; i < e; i += 1) { - let e = this.queuedExperienceEvents.values().next() - if (e.done) break - ;(this.queuedExperienceEvents.delete(e.value), t.push(e.value)) - } - return t - } - invokeOfflineDropCallback(e) { - try { - this.onOfflineDrop?.(e) - } catch (e) { - n7.warn('Offline queue drop callback failed', e) - } - } - async tryUpsertQueuedEvents(e) { - try { - return (await this.upsertProfile(e), !0) - } catch (e) { - return (n7.warn('Experience queue flush request threw an error', e), !1) - } - } - async upsertProfile(e) { - let t = this.getAnonymousId() - t && n7.debug(`Anonymous ID found: ${t}`) - let i = await this.experienceApi.upsertProfile({ profileId: t ?? em.value?.id, events: e }) - return (await this.updateOutputSignals(i), i) - } - async updateOutputSignals(e) { - let { - changes: t, - profile: i, - selectedOptimizations: n, - } = await this.stateInterceptors.run(e) - p(() => { - ;(nX(eu.value, t) || (eu.value = t), - nX(em.value, i) || (em.value = i), - nX(ey.value, n) || (ey.value = n)) - }) - } - } - let rt = nu('CoreStateful') - class ri { - eventInterceptors - flushIntervalMs - flushRuntime - insightsApi - queuedInsightsByProfile = new Map() - insightsPeriodicFlushTimer - constructor(e) { - const { eventInterceptors: t, flushPolicy: i, insightsApi: n } = e, - { flushIntervalMs: r } = i - ;((this.eventInterceptors = t), - (this.flushIntervalMs = r), - (this.insightsApi = n), - (this.flushRuntime = new n9({ - policy: i, - onRetry: () => { - this.flush() - }, - onCallbackError: (e, t) => { - rt.warn(`Insights flush policy callback "${e}" failed`, t) - }, - }))) - } - clearScheduledRetry() { - this.flushRuntime.clearScheduledRetry() - } - clearPeriodicFlushTimer() { - void 0 !== this.insightsPeriodicFlushTimer && - (clearInterval(this.insightsPeriodicFlushTimer), - (this.insightsPeriodicFlushTimer = void 0)) - } - async send(e) { - let { value: t } = em - if (!t) return void rt.warn('Attempting to emit an event without an Optimization profile') - let i = na(nr, await this.eventInterceptors.run(e)) - rt.debug(`Queueing ${i.type} event for profile ${t.id}`, i) - let n = this.queuedInsightsByProfile.get(t.id) - ;((ep.value = i), - n - ? ((n.profile = t), n.events.push(i)) - : this.queuedInsightsByProfile.set(t.id, { profile: t, events: [i] }), - this.ensurePeriodicFlushTimer(), - this.getQueuedEventCount() >= 25 && (await this.flush()), - this.reconcilePeriodicFlushTimer()) - } - async flush(e = {}) { - let { force: t = !1 } = e - if (this.flushRuntime.shouldSkip({ force: t, isOnline: !!eh.value })) return - rt.debug('Flushing insights event queue') - let i = this.createBatches() - if (!i.length) { - ;(this.flushRuntime.clearScheduledRetry(), this.reconcilePeriodicFlushTimer()) - return - } - this.flushRuntime.markFlushStarted() - try { - ;(await this.trySendBatches(i)) - ? (this.queuedInsightsByProfile.clear(), this.flushRuntime.handleFlushSuccess()) - : this.flushRuntime.handleFlushFailure({ - queuedBatches: i.length, - queuedEvents: this.getQueuedEventCount(), - }) - } finally { - ;(this.flushRuntime.markFlushFinished(), this.reconcilePeriodicFlushTimer()) - } - } - createBatches() { - let e = [] - return ( - this.queuedInsightsByProfile.forEach(({ profile: t, events: i }) => { - e.push({ profile: t, events: i }) - }), - e - ) - } - async trySendBatches(e) { - try { - return await this.insightsApi.sendBatchEvents(e) - } catch (e) { - return (rt.warn('Insights queue flush request threw an error', e), !1) - } - } - getQueuedEventCount() { - let e = 0 - return ( - this.queuedInsightsByProfile.forEach(({ events: t }) => { - e += t.length - }), - e - ) - } - ensurePeriodicFlushTimer() { - void 0 !== this.insightsPeriodicFlushTimer || - (0 !== this.getQueuedEventCount() && - (this.insightsPeriodicFlushTimer = setInterval(() => { - this.flush() - }, this.flushIntervalMs))) - } - reconcilePeriodicFlushTimer() { - this.getQueuedEventCount() > 0 - ? this.ensurePeriodicFlushTimer() - : this.clearPeriodicFlushTimer() - } - } - let rn = Symbol.for('ctfl.optimization.preview.signals'), - rr = Symbol.for('ctfl.optimization.preview.signalFns'), - rs = nu('CoreStateful'), - ro = ['identify', 'page', 'screen'], - ra = (e) => Object.values(e).some((e) => void 0 !== e), - rl = 0 - class ru extends n2 { - singletonOwner - destroyed = !1 - allowedEventTypes - experienceQueue - insightsQueue - onEventBlocked - states = { - blockedEventStream: el(ec), - flag: (e) => this.getFlagObservable(e), - consent: el(ed), - eventStream: el(ep), - canOptimize: el(eg), - selectedOptimizations: el(ey), - previewPanelAttached: el(ef), - previewPanelOpen: el(ev), - profile: el(em), - } - constructor(e) { - ;(super(e, { - experience: ((e) => { - if (void 0 === e) return - let t = { - baseUrl: e.experienceBaseUrl, - enabledFeatures: e.enabledFeatures, - ip: e.ip, - locale: e.locale, - plainText: e.plainText, - preflight: e.preflight, - } - return ra(t) ? t : void 0 - })(e.api), - insights: ((e) => { - if (void 0 === e) return - let t = { baseUrl: e.insightsBaseUrl, beaconHandler: e.beaconHandler } - return ra(t) ? t : void 0 - })(e.api), - }), - (this.singletonOwner = `CoreStateful#${++rl}`), - ((e) => { - let t = n8() - if (t.owner) - throw Error( - `Stateful Optimization SDK already initialized (${t.owner}). Only one stateful instance is supported per runtime.`, - ) - t.owner = e - })(this.singletonOwner)) - try { - const { - allowedEventTypes: t, - defaults: i, - getAnonymousId: n, - onEventBlocked: r, - queuePolicy: s, - } = e, - { changes: o, consent: a, selectedOptimizations: l, profile: u } = i ?? {}, - c = ((e) => ({ - flush: ((e, t = n3) => { - var i, n - let r = e ?? {}, - s = n6(r.baseBackoffMs, t.baseBackoffMs), - o = Math.max(s, n6(r.maxBackoffMs, t.maxBackoffMs)) - return { - flushIntervalMs: n6(r.flushIntervalMs, t.flushIntervalMs), - baseBackoffMs: s, - maxBackoffMs: o, - jitterRatio: - ((i = r.jitterRatio), - (n = t.jitterRatio), - Number.isFinite(i) && void 0 !== i ? Math.min(1, Math.max(0, i)) : n), - maxConsecutiveFailures: n6(r.maxConsecutiveFailures, t.maxConsecutiveFailures), - circuitOpenMs: n6(r.circuitOpenMs, t.circuitOpenMs), - onCircuitOpen: r.onCircuitOpen, - onFlushFailure: r.onFlushFailure, - onFlushRecovered: r.onFlushRecovered, - } - })(e?.flush), - offlineMaxEvents: n6(e?.offlineMaxEvents, 100), - onOfflineDrop: e?.onOfflineDrop, - }))(s) - ;((this.allowedEventTypes = t ?? ro), - (this.onEventBlocked = r), - (this.insightsQueue = new ri({ - eventInterceptors: this.interceptors.event, - flushPolicy: c.flush, - insightsApi: this.api.insights, - })), - (this.experienceQueue = new re({ - experienceApi: this.api.experience, - eventInterceptors: this.interceptors.event, - flushPolicy: c.flush, - getAnonymousId: n ?? (() => void 0), - offlineMaxEvents: c.offlineMaxEvents, - onOfflineDrop: c.onOfflineDrop, - stateInterceptors: this.interceptors.state, - })), - void 0 !== a && (ed.value = a), - p(() => { - ;(void 0 !== o && (eu.value = o), - void 0 !== l && (ey.value = l), - void 0 !== u && (em.value = u)) - }), - this.initializeEffects()) - } catch (e) { - throw (n5(this.singletonOwner), e) - } - } - initializeEffects() { - ;(A(() => { - rs.debug( - `Profile ${em.value && `with ID ${em.value.id}`} has been ${em.value ? 'set' : 'cleared'}`, - ) - }), - A(() => { - rs.debug(`Variants have been ${ey.value?.length ? 'populated' : 'cleared'}`) - }), - A(() => { - rs.info( - `Core ${ed.value ? 'will' : 'will not'} emit gated events due to consent (${ed.value})`, - ) - }), - A(() => { - eh.value && - (this.insightsQueue.clearScheduledRetry(), - this.experienceQueue.clearScheduledRetry(), - this.flushQueues({ force: !0 })) - })) - } - async flushQueues(e = {}) { - ;(await this.insightsQueue.flush(e), await this.experienceQueue.flush(e)) - } - destroy() { - this.destroyed || - ((this.destroyed = !0), - this.insightsQueue.flush({ force: !0 }).catch((e) => { - nl.warn('Failed to flush insights queue during destroy()', String(e)) - }), - this.experienceQueue.flush({ force: !0 }).catch((e) => { - nl.warn('Failed to flush Experience queue during destroy()', String(e)) - }), - this.insightsQueue.clearPeriodicFlushTimer(), - n5(this.singletonOwner)) - } - reset() { - p(() => { - ;((ec.value = void 0), - (ep.value = void 0), - (eu.value = void 0), - (em.value = void 0), - (ey.value = void 0)) - }) - } - async flush() { - await this.flushQueues() - } - consent(e) { - ed.value = e - } - get online() { - return eh.value ?? !1 - } - set online(e) { - eh.value = e - } - registerPreviewPanel(e) { - ;(Reflect.set(e, rn, eb), Reflect.set(e, rr, ew)) - } - } - let rc = 'ALL_VISITORS' - function rd(e, t) { - let { - audiences: { [t]: i }, - } = e - return i ? (i.isActive ? 'on' : 'off') : 'default' - } - function rp(e, t) { - return 'on' === e || ('off' !== e && t) - } - function rh(e, t, i, n) { - let r = t[e.id] ?? 0, - s = void 0 !== i.selectedOptimizations[e.id], - o = { ...e, currentVariantIndex: r, isOverridden: s } - if (s && void 0 !== n) { - let { [e.id]: t } = n - void 0 !== t && (o.naturalVariantIndex = t) - } - return o - } - function rf(e, t) { - let i = Object.values(t) - if (0 === i.length) return e - let n = e.map((e) => { - let { [e.experienceId]: i } = t - return i ? { ...e, variantIndex: i.variantIndex } : e - }) - for (let e of i) - n.some((t) => t.experienceId === e.experienceId) || - n.push({ experienceId: e.experienceId, variantIndex: e.variantIndex, variants: {} }) - return n - } - nu('Preview') - let rv = nu('PreviewOverrides'), - ry = { audiences: {}, selectedOptimizations: {} } - class rg { - baselineSelectedOptimizations = null - baselineAudienceQualifications = {} - overrides = { ...ry, audiences: {}, selectedOptimizations: {} } - interceptorId = null - selectedOptimizations - profile - stateInterceptors - onOverridesChanged - constructor(e) { - const { - selectedOptimizations: t, - profile: i, - stateInterceptors: n, - onOverridesChanged: r, - } = e - ;((this.selectedOptimizations = t), - (this.profile = i), - (this.stateInterceptors = n), - (this.onOverridesChanged = r)) - const { value: s } = t - ;(s && - ((this.baselineSelectedOptimizations = s), - rv.debug('Captured initial signal state as baseline')), - (this.interceptorId = e.stateInterceptors.add((e) => { - let { selectedOptimizations: t } = e - this.baselineSelectedOptimizations = t - let i = Object.keys(this.overrides.selectedOptimizations).length > 0, - n = i - ? { - ...e, - selectedOptimizations: rf( - e.selectedOptimizations, - this.overrides.selectedOptimizations, - ), - } - : { ...e } - return ( - i && rv.debug('Intercepting state update to preserve overrides'), - this.notifyChanged(), - n - ) - })), - rv.info('State interceptor registered')) - } - activateAudience(e, t) { - ;(rv.info('Activating audience override:', e), this.setAudienceOverride(e, !0, 1, t)) - } - deactivateAudience(e, t) { - ;(rv.info('Deactivating audience override:', e), this.setAudienceOverride(e, !1, 0, t)) - } - resetAudienceOverride(e) { - rv.info('Resetting audience override:', e) - let { overrides: t } = this, - { audiences: i, selectedOptimizations: n } = t, - r = i[e]?.experienceIds ?? [], - s = new Set(r), - o = Object.fromEntries(Object.entries(n).filter(([e]) => !s.has(e))), - a = Object.fromEntries(Object.entries(i).filter(([t]) => t !== e)) - ;((this.overrides = { audiences: a, selectedOptimizations: o }), - (this.baselineAudienceQualifications = Object.fromEntries( - Object.entries(this.baselineAudienceQualifications).filter(([t]) => t !== e), - )), - r.length > 0 && this.syncOverridesToSignal(), - this.notifyChanged()) - } - setVariantOverride(e, t) { - ;(rv.info('Setting variant override:', { experienceId: e, variantIndex: t }), - (this.overrides = { - ...this.overrides, - selectedOptimizations: { - ...this.overrides.selectedOptimizations, - [e]: { experienceId: e, variantIndex: t }, - }, - }), - this.syncOverridesToSignal(), - this.notifyChanged()) - } - resetOptimizationOverride(e) { - rv.info('Resetting optimization override:', e) - let { selectedOptimizations: t } = { ...this.overrides }, - i = Object.fromEntries(Object.entries(t).filter(([t]) => t !== e)) - ;((this.overrides = { ...this.overrides, selectedOptimizations: i }), - this.syncOverridesToSignal(), - this.notifyChanged()) - } - resetAll() { - ;(rv.info('Resetting all overrides to baseline'), - (this.overrides = { audiences: {}, selectedOptimizations: {} }), - (this.baselineAudienceQualifications = {})) - let { baselineSelectedOptimizations: e } = this - ;(e && ((this.selectedOptimizations.value = e), rv.debug('Restored signal to baseline')), - this.notifyChanged()) - } - getOverrides() { - return this.overrides - } - getBaselineSelectedOptimizations() { - return this.baselineSelectedOptimizations - } - getBaselineAudienceQualifications() { - return this.baselineAudienceQualifications - } - destroy() { - ;(null !== this.interceptorId && - (this.stateInterceptors.remove(this.interceptorId), - rv.info('State interceptor removed'), - (this.interceptorId = null)), - (this.overrides = { audiences: {}, selectedOptimizations: {} }), - (this.baselineSelectedOptimizations = null), - (this.baselineAudienceQualifications = {})) - } - snapshotAudienceQualification(e) { - if (!this.profile || e in this.baselineAudienceQualifications) return - let t = this.profile.value?.audiences.includes(e) ?? !1 - this.baselineAudienceQualifications[e] = t - } - syncOverridesToSignal() { - ;((this.selectedOptimizations.value = rf( - this.baselineSelectedOptimizations ?? [], - this.overrides.selectedOptimizations, - )), - rv.debug('Synced overrides to signal')) - } - setAudienceOverride(e, t, i, n) { - this.snapshotAudienceQualification(e) - let r = { ...this.overrides.selectedOptimizations } - for (let e of n) r[e] = { experienceId: e, variantIndex: i } - ;((this.overrides = { - audiences: { - ...this.overrides.audiences, - [e]: { audienceId: e, isActive: t, source: 'manual', experienceIds: n }, - }, - selectedOptimizations: r, - }), - n.length > 0 && this.syncOverridesToSignal(), - this.notifyChanged()) - } - notifyChanged() { - this.onOverridesChanged?.(this.overrides) - } - } - let rm = null, - rb = null, - rw = null, - r_ = null, - rz = null, - rO = null, - rS = {}, - rE = {}, - rx = { - initialize(e) { - ;(rm && rx.destroy(), - (rz = null), - (rO = null), - (rS = {}), - (rE = {}), - (rm = new ru({ - clientId: e.clientId, - environment: e.environment, - api: { experienceBaseUrl: e.experienceBaseUrl, insightsBaseUrl: e.insightsBaseUrl }, - })), - e.defaults && - (void 0 !== e.defaults.consent && rm.consent(e.defaults.consent), - void 0 !== e.defaults.profile && (eb.profile.value = e.defaults.profile), - void 0 !== e.defaults.changes && (eb.changes.value = e.defaults.changes), - void 0 !== e.defaults.optimizations && - (eb.selectedOptimizations.value = e.defaults.optimizations)), - rm.consent(!0)) - let t = globalThis - ;((r_ = new rg({ - selectedOptimizations: eb.selectedOptimizations, - profile: eb.profile, - stateInterceptors: rm.interceptors.state, - onOverridesChanged: () => { - 'function' == typeof t.__nativeOnOverridesChanged && - t.__nativeOnOverridesChanged(rx.getPreviewState()) - }, - })), - (rb = A(() => { - let e = { - profile: eb.profile.value ?? null, - consent: eb.consent.value, - canPersonalize: eb.canOptimize.value, - changes: eb.changes.value ?? null, - selectedPersonalizations: eb.selectedOptimizations.value ?? null, - } - 'function' == typeof t.__nativeOnStateChange && - t.__nativeOnStateChange(JSON.stringify(e)) - })), - (rw = A(() => { - let e = eb.event.value - e && - 'function' == typeof t.__nativeOnEventEmitted && - t.__nativeOnEventEmitted(JSON.stringify(e)) - }))) - }, - identify(e, t, i) { - rm - ? rm - .identify(e) - .then((e) => { - t(JSON.stringify(e ?? null)) - }) - .catch((e) => { - i(e instanceof Error ? e.message : String(e)) - }) - : i('SDK not initialized. Call initialize() first.') - }, - page(e, t, i) { - rm - ? rm - .page(e) - .then((e) => { - t(JSON.stringify(e ?? null)) - }) - .catch((e) => { - i(e instanceof Error ? e.message : String(e)) - }) - : i('SDK not initialized. Call initialize() first.') - }, - screen(e, t, i) { - rm - ? rm - .screen({ name: e.name, properties: e.properties ?? {} }) - .then((e) => { - t(JSON.stringify(e ?? null)) - }) - .catch((e) => { - i(e instanceof Error ? e.message : String(e)) - }) - : i('SDK not initialized. Call initialize() first.') - }, - flush(e, t) { - rm - ? rm - .flush() - .then(() => { - e(JSON.stringify(null)) - }) - .catch((e) => { - t(e instanceof Error ? e.message : String(e)) - }) - : t('SDK not initialized. Call initialize() first.') - }, - trackView(e, t, i) { - rm - ? rm - .trackView(e) - .then((e) => { - t(JSON.stringify(e ?? null)) - }) - .catch((e) => { - i(e instanceof Error ? e.message : String(e)) - }) - : i('SDK not initialized. Call initialize() first.') - }, - trackClick(e, t, i) { - rm - ? rm - .trackClick(e) - .then(() => { - t(JSON.stringify(null)) - }) - .catch((e) => { - i(e instanceof Error ? e.message : String(e)) - }) - : i('SDK not initialized. Call initialize() first.') - }, - consent(e) { - rm && rm.consent(e) - }, - reset() { - rm && (r_?.resetAll(), rm.reset()) - }, - setOnline(e) { - eb.online.value = e - }, - personalizeEntry: (e, t) => - rm ? JSON.stringify(rm.resolveOptimizedEntry(e, t)) : JSON.stringify({ entry: e }), - setPreviewPanelOpen(e) { - rm && (eb.previewPanelOpen.value = e) - }, - overrideAudience(e, t, i) { - r_ && (t ? r_.activateAudience(e, i) : r_.deactivateAudience(e, i)) - }, - overrideVariant(e, t) { - r_?.setVariantOverride(e, t) - }, - resetAudienceOverride(e) { - r_?.resetAudienceOverride(e) - }, - resetVariantOverride(e) { - r_?.resetOptimizationOverride(e) - }, - resetAllOverrides() { - r_?.resetAll() - }, - loadDefinitions(e, t) { - try { - let i, n - for (let r of ((rz = e.map((e) => { - let t = e.fields - return 'object' == typeof t && null !== t - ? { - id: t.nt_audience_id ?? e.sys.id, - name: t.nt_name ?? t.nt_audience_id ?? e.sys.id, - description: t.nt_description, - } - : { id: e.sys.id, name: e.sys.id } - })), - (i = new Map()), - t.forEach((e) => { - ;(e.includes?.Entry ?? []).forEach((e) => { - i.set(e.sys.id, e) - }) - }), - (rO = t.map((e) => { - let t = e.fields - if ('object' != typeof t || null === t) - return { - id: e.sys.id, - name: e.sys.id, - type: 'nt_personalization', - distribution: [], - } - let { nt_config: n } = t, - r = [] - return ( - n?.distribution && - n.distribution.forEach((e, t) => { - let s, - o = - ((s = n.components?.[0]), - void 0 === s || (void 0 !== s.type && 'EntryReplacement' !== s.type) - ? '' - : 0 === t - ? s.baseline.id - : (s.variants[t - 1]?.id ?? '')), - a = i.get(o) - r.push({ - index: t, - variantRef: o, - percentage: Math.round(100 * e), - name: a - ? (function (e) { - let t = e.fields - if ('object' == typeof t && null !== t) - return t.internalTitle ?? t.title ?? t.name - })(a) - : void 0, - }) - }), - { - id: t.nt_experience_id ?? e.sys.id, - name: t.nt_name ?? e.sys.id, - type: t.nt_type ?? 'nt_personalization', - distribution: r, - audience: t.nt_audience ? { id: t.nt_audience.sys.id } : void 0, - } - ) - })), - (n = {}), - t.forEach((e) => { - let t = e.fields - if ('object' == typeof t && null !== t) { - let i = t.nt_personalization_id ?? t.nt_experience_id ?? e.sys.id, - { nt_name: r } = t - r && (n[i] = r) - } - }), - (rE = n), - (rS = {}), - rz)) - rS[r.id] = r.name - return JSON.stringify({ audienceCount: rz.length, experienceCount: rO.length }) - } catch (e) { - return ( - (rz = null), - (rO = null), - (rS = {}), - (rE = {}), - JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) - ) - } - }, - getPreviewState() { - let e = r_?.getOverrides() ?? { audiences: {}, selectedOptimizations: {} }, - t = r_?.getBaselineSelectedOptimizations(), - i = {} - for (let [t, n] of Object.entries(e.audiences)) i[t] = n.isActive - let n = {} - for (let [t, i] of Object.entries(e.selectedOptimizations)) n[t] = i.variantIndex - let r = {} - if (t) - for (let e of t) void 0 !== n[e.experienceId] && (r[e.experienceId] = e.variantIndex) - let s = - rz && rO - ? { - ...(function (e) { - let { - audienceDefinitions: t, - experienceDefinitions: i, - signals: n, - overrides: r, - } = e, - { profile: s, selectedOptimizations: o } = n, - a = new Set(s?.audiences ?? []), - l = {} - if (o) for (let { experienceId: e, variantIndex: t } of o) l[e] = t - let u = - null != e.baselineSelectedOptimizations - ? Object.fromEntries( - e.baselineSelectedOptimizations.map((e) => [ - e.experienceId, - e.variantIndex, - ]), - ) - : void 0, - c = new Set(t.map((e) => e.id)), - d = i - .filter((e) => !e.audience?.id || !c.has(e.audience.id)) - .map((e) => rh(e, l, r, u)), - p = t.map((e) => { - let t = i.filter((t) => t.audience?.id === e.id).map((e) => rh(e, l, r, u)), - n = a.has(e.id), - s = rd(r, e.id), - o = rp(s, n) - return { - audience: e, - experiences: t, - isQualified: n, - isActive: o, - overrideState: s, - } - }) - if (d.length > 0) { - let e = rd(r, rc) - p.push({ - audience: { - id: rc, - name: 'All Visitors', - description: - 'Experiences that apply to all visitors regardless of audience membership', - }, - experiences: d, - isQualified: !0, - isActive: rp(e, !0), - overrideState: e, - }) - } - let h = t.length > 0 || i.length > 0 - return { - audiencesWithExperiences: [...p].sort((e, t) => - e.audience.id === rc - ? -1 - : t.audience.id === rc - ? 1 - : e.isActive !== t.isActive - ? e.isActive - ? -1 - : 1 - : e.audience.name.localeCompare(t.audience.name, void 0, { - sensitivity: 'base', - }), - ), - unassociatedExperiences: d, - hasData: h, - sdkVariantIndices: l, - } - })({ - audienceDefinitions: rz, - experienceDefinitions: rO, - signals: { - profile: eb.profile.value, - selectedOptimizations: eb.selectedOptimizations.value, - consent: eb.consent.value, - isLoading: !1, - }, - overrides: e, - baselineSelectedOptimizations: t, - }), - audienceNameMap: rS, - experienceNameMap: rE, - } - : null - return JSON.stringify({ - profile: eb.profile.value ?? null, - consent: eb.consent.value, - canPersonalize: eb.canOptimize.value, - changes: eb.changes.value ?? null, - selectedPersonalizations: eb.selectedOptimizations.value ?? null, - previewPanelOpen: eb.previewPanelOpen.value, - audienceOverrides: i, - variantOverrides: n, - defaultAudienceQualifications: r_?.getBaselineAudienceQualifications() ?? {}, - defaultVariantIndices: r, - previewModel: s, - }) - }, - getProfile() { - let e = eb.profile.value - return e ? JSON.stringify(e) : null - }, - getState: () => - JSON.stringify({ - profile: eb.profile.value ?? null, - consent: eb.consent.value, - canPersonalize: eb.canOptimize.value, - changes: eb.changes.value ?? null, - selectedPersonalizations: eb.selectedOptimizations.value ?? null, - }), - destroy() { - ;(r_?.destroy(), - (r_ = null), - (rz = null), - (rO = null), - (rS = {}), - (rE = {}), - rw && (rw(), (rw = null)), - rb && (rb(), (rb = null)), - rm && (rm.destroy(), (rm = null))) - }, - } - globalThis.__bridge = rx - let rk = rx - return u.default - })(), -) -//# sourceMappingURL=optimization-android-bridge.umd.js.map +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.OptimizationBridge=t():e.OptimizationBridge=t()}(globalThis,()=>(()=>{"use strict";let e,t,i,n,r,s,o;var a,l={};l.d=(e,t)=>{for(var i in t)l.o(t,i)&&!l.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var u={};l.d(u,{default:()=>rk});var c=Symbol.for("preact-signals");function d(){if(g>1)g--;else{for(var e,t=!1;void 0!==y;){var i=y;for(y=void 0,m++;void 0!==i;){var n=i.o;if(i.o=void 0,i.f&=-3,!(8&i.f)&&O(i))try{i.c()}catch(i){t||(e=i,t=!0)}i=n}}if(m=0,g--,t)throw e}}function p(e){if(g>0)return e();g++;try{return e()}finally{d()}}var h=void 0;function f(e){var t=h;h=void 0;try{return e()}finally{h=t}}var v,y=void 0,g=0,m=0,b=0;function w(e){if(void 0!==h){var t=e.n;if(void 0===t||t.t!==h)return t={i:0,S:e,p:h.s,n:void 0,t:h,e:void 0,x:void 0,r:t},void 0!==h.s&&(h.s.n=t),h.s=t,e.n=t,32&h.f&&e.S(t),t;if(-1===t.i)return t.i=0,void 0!==t.n&&(t.n.p=t.p,void 0!==t.p&&(t.p.n=t.n),t.p=h.s,t.n=void 0,h.s.n=t,h.s=t),t}}function _(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function z(e,t){return new _(e,t)}function O(e){for(var t=e.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function S(e){for(var t=e.s;void 0!==t;t=t.n){var i=t.S.n;if(void 0!==i&&(t.r=i),t.S.n=t,t.i=-1,void 0===t.n){e.s=t;break}}}function E(e){for(var t=e.s,i=void 0;void 0!==t;){var n=t.p;-1===t.i?(t.S.U(t),void 0!==n&&(n.n=t.n),void 0!==t.n&&(t.n.p=n)):i=t,t.S.n=t.r,void 0!==t.r&&(t.r=void 0),t=n}e.s=i}function x(e,t){_.call(this,void 0),this.x=e,this.s=void 0,this.g=b-1,this.f=4,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function k(e,t){return new x(e,t)}function I(e){var t=e.u;if(e.u=void 0,"function"==typeof t){g++;var i=h;h=void 0;try{t()}catch(t){throw e.f&=-2,e.f|=8,$(e),t}finally{h=i,d()}}}function $(e){for(var t=e.s;void 0!==t;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,I(e)}function P(e){if(h!==this)throw Error("Out-of-order effect");E(this),h=e,this.f&=-2,8&this.f&&$(this),d()}function j(e,t){this.x=e,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=null==t?void 0:t.name,v&&v.push(this)}function A(e,t){var i=new j(e,t);try{i.c()}catch(e){throw i.d(),e}var n=i.d.bind(i);return n[Symbol.dispose]=n,n}function T(e){return Object.getOwnPropertySymbols(e).filter(t=>Object.prototype.propertyIsEnumerable.call(e,t))}function F(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}_.prototype.brand=c,_.prototype.h=function(){return!0},_.prototype.S=function(e){var t=this,i=this.t;i!==e&&void 0===e.e&&(e.x=i,this.t=e,void 0!==i?i.e=e:f(function(){var e;null==(e=t.W)||e.call(t)}))},_.prototype.U=function(e){var t=this;if(void 0!==this.t){var i=e.e,n=e.x;void 0!==i&&(i.x=n,e.e=void 0),void 0!==n&&(n.e=i,e.x=void 0),e===this.t&&(this.t=n,void 0===n&&f(function(){var e;null==(e=t.Z)||e.call(t)}))}},_.prototype.subscribe=function(e){var t=this;return A(function(){var i=t.value,n=h;h=void 0;try{e(i)}finally{h=n}},{name:"sub"})},_.prototype.valueOf=function(){return this.value},_.prototype.toString=function(){return this.value+""},_.prototype.toJSON=function(){return this.value},_.prototype.peek=function(){var e=h;h=void 0;try{return this.value}finally{h=e}},Object.defineProperty(_.prototype,"value",{get:function(){var e=w(this);return void 0!==e&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(m>100)throw Error("Cycle detected");this.v=e,this.i++,b++,g++;try{for(var t=this.t;void 0!==t;t=t.x)t.t.N()}finally{d()}}}}),x.prototype=new _,x.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if(32==(36&this.f)||(this.f&=-5,this.g===b))return!0;if(this.g=b,this.f|=1,this.i>0&&!O(this))return this.f&=-2,!0;var e=h;try{S(this),h=this;var t=this.x();(16&this.f||this.v!==t||0===this.i)&&(this.v=t,this.f&=-17,this.i++)}catch(e){this.v=e,this.f|=16,this.i++}return h=e,E(this),this.f&=-2,!0},x.prototype.S=function(e){if(void 0===this.t){this.f|=36;for(var t=this.s;void 0!==t;t=t.n)t.S.S(t)}_.prototype.S.call(this,e)},x.prototype.U=function(e){if(void 0!==this.t&&(_.prototype.U.call(this,e),void 0===this.t)){this.f&=-33;for(var t=this.s;void 0!==t;t=t.n)t.S.U(t)}},x.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;void 0!==e;e=e.x)e.t.N()}},Object.defineProperty(x.prototype,"value",{get:function(){if(1&this.f)throw Error("Cycle detected");var e=w(this);if(this.h(),void 0!==e&&(e.i=this.i),16&this.f)throw this.v;return this.v}}),j.prototype.c=function(){var e=this.S();try{if(8&this.f||void 0===this.x)return;var t=this.x();"function"==typeof t&&(this.u=t)}finally{e()}},j.prototype.S=function(){if(1&this.f)throw Error("Cycle detected");this.f|=1,this.f&=-9,I(this),S(this),g++;var e=h;return h=this,P.bind(this,e)},j.prototype.N=function(){2&this.f||(this.f|=2,this.o=y,y=this)},j.prototype.d=function(){this.f|=8,1&this.f||$(this)},j.prototype.dispose=function(){this.d()};let C="[object RegExp]",R="[object String]",M="[object Number]",B="[object Boolean]",q="[object Arguments]",U="[object Symbol]",V="[object Date]",N="[object Map]",D="[object Set]",Z="[object Array]",Q="[object ArrayBuffer]",L="[object Object]",J="[object DataView]",H="[object Uint8Array]",K="[object Uint8ClampedArray]",W="[object Uint16Array]",G="[object Uint32Array]",X="[object Int8Array]",Y="[object Int16Array]",ee="[object Int32Array]",et="[object Float32Array]",ei="[object Float64Array]",en="object"==typeof globalThis&&globalThis||"object"==typeof window&&window||"object"==typeof self&&self||"object"==typeof global&&global||function(){return this}()||Function("return this")();function er(e){return void 0!==en.Buffer&&en.Buffer.isBuffer(e)}function es(e,t,i,n=new Map,r){let s=r?.(e,t,i,n);if(void 0!==s)return s;if(null==e||"object"!=typeof e&&"function"!=typeof e)return e;if(n.has(e))return n.get(e);if(Array.isArray(e)){let t=Array(e.length);n.set(e,t);for(let s=0;stypeof SharedArrayBuffer&&e instanceof SharedArrayBuffer)return e.slice(0);if(e instanceof DataView){let t=new DataView(e.buffer.slice(0),e.byteOffset,e.byteLength);return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof File&&e instanceof File){let t=new File([e],e.name,{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof Blob&&e instanceof Blob){let t=new Blob([e],{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Error){let t=structuredClone(e);return n.set(e,t),t.message=e.message,t.name=e.name,t.stack=e.stack,t.cause=e.cause,t.constructor=e.constructor,eo(t,e,i,n,r),t}if(e instanceof Boolean){let t=new Boolean(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Number){let t=new Number(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof String){let t=new String(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if("object"==typeof e&&function(e){switch(F(e)){case q:case Z:case Q:case J:case B:case V:case et:case ei:case X:case Y:case ee:case N:case M:case L:case C:case D:case R:case U:case H:case K:case W:case G:return!0;default:return!1}}(e)){let t=Object.create(Object.getPrototypeOf(e));return n.set(e,t),eo(t,e,i,n,r),t}return e}function eo(e,t,i=e,n,r){let s=[...Object.keys(t),...T(t)];for(let o=0;o({unsubscribe:A(()=>{t(ea(e.value))})}),subscribeOnce(t){let i=!1,n=!1,r=()=>void 0;return r=A(()=>{if(i)return;let{value:s}=e;if(null==s)return;i=!0;let o=null;try{t(ea(s))}catch(e){o=e instanceof Error?e:Error(`Subscriber threw non-Error value: ${String(e)}`)}if(n?r():queueMicrotask(r),o)throw o}),n=!0,{unsubscribe:()=>{!i&&(i=!0,n&&r())}}}}}let eu=z(),ec=z(),ed=z(),ep=z(),eh=z(!0),ef=z(!1),ev=z(!1),ey=z(),eg=k(()=>void 0!==ey.value),em=z(),eb={blockedEvent:ec,changes:eu,consent:ed,event:ep,online:eh,previewPanelAttached:ef,previewPanelOpen:ev,selectedOptimizations:ey,canOptimize:eg,profile:em},ew={batch:p,computed:k,effect:A,untracked:f};function e_(e,t,i){function n(i,n){if(i._zod||Object.defineProperty(i,"_zod",{value:{def:n,constr:o,traits:new Set},enumerable:!1}),i._zod.traits.has(e))return;i._zod.traits.add(e),t(i,n);let r=o.prototype,s=Object.keys(r);for(let e=0;e!!i?.Parent&&t instanceof i.Parent||t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}Object.freeze({status:"aborted"}),Symbol("zod_brand");class ez extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}let eO={};function eS(e){return e&&Object.assign(eO,e),eO}function eE(e,t="|"){return e.map(e=>eU(e)).join(t)}function ex(e,t){return"bigint"==typeof t?t.toString():t}function ek(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}}}}function eI(e){let t=+!!e.startsWith("^"),i=e.endsWith("$")?e.length-1:e.length;return e.slice(t,i)}let e$=Symbol("evaluating");function eP(e,t,i){let n;Object.defineProperty(e,t,{get(){if(n!==e$)return void 0===n&&(n=e$,n=i()),n},set(i){Object.defineProperty(e,t,{value:i})},configurable:!0})}function ej(e,t,i){Object.defineProperty(e,t,{value:i,writable:!0,enumerable:!0,configurable:!0})}function eA(...e){let t={};for(let i of e)Object.assign(t,Object.getOwnPropertyDescriptors(i));return Object.defineProperties({},t)}let eT="captureStackTrace"in Error?Error.captureStackTrace:(...e)=>{};function eF(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function eC(e){if(!1===eF(e))return!1;let t=e.constructor;if(void 0===t||"function"!=typeof t)return!0;let i=t.prototype;return!1!==eF(i)&&!1!==Object.prototype.hasOwnProperty.call(i,"isPrototypeOf")}ek(()=>{if("u">typeof navigator&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{return Function(""),!0}catch(e){return!1}});let eR=new Set(["string","number","symbol"]);function eM(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function eB(e,t,i){let n=new e._zod.constr(t??e._zod.def);return(!t||i?.parent)&&(n._zod.parent=e),n}function eq(e){if(!e)return{};if("string"==typeof e)return{error:()=>e};if(e?.message!==void 0){if(e?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");e.error=e.message}return(delete e.message,"string"==typeof e.error)?{...e,error:()=>e.error}:e}function eU(e){return"bigint"==typeof e?e.toString()+"n":"string"==typeof e?`"${e}"`:`${e}`}function eV(e,t=0){if(!0===e.aborted)return!0;for(let i=t;i(t.path??(t.path=[]),t.path.unshift(e),t))}function eD(e){return"string"==typeof e?e:e?.message}function eZ(e,t,i){let n={...e,path:e.path??[]};return e.message||(n.message=eD(e.inst?._zod.def?.error?.(e))??eD(t?.error?.(e))??eD(i.customError?.(e))??eD(i.localeError?.(e))??"Invalid input"),delete n.inst,delete n.continue,t?.reportInput||delete n.input,n}function eQ(e){return Array.isArray(e)?"array":"string"==typeof e?"string":"unknown"}let eL=(e,t)=>{e.name="$ZodError",Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,ex,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},eJ=e_("$ZodError",eL),eH=e_("$ZodError",eL,{Parent:Error}),eK=(e=eH,(t,i,n,r)=>{let s=n?Object.assign(n,{async:!1}):{async:!1},o=t._zod.run({value:i,issues:[]},s);if(o instanceof Promise)throw new ez;if(o.issues.length){let t=new(r?.Err??e)(o.issues.map(e=>eZ(e,s,eS())));throw eT(t,r?.callee),t}return o.value}),eW=(t=eH,async(e,i,n,r)=>{let s=n?Object.assign(n,{async:!0}):{async:!0},o=e._zod.run({value:i,issues:[]},s);if(o instanceof Promise&&(o=await o),o.issues.length){let e=new(r?.Err??t)(o.issues.map(e=>eZ(e,s,eS())));throw eT(e,r?.callee),e}return o.value}),eG=(i=eH,(e,t,n)=>{let r=n?{...n,async:!1}:{async:!1},s=e._zod.run({value:t,issues:[]},r);if(s instanceof Promise)throw new ez;return s.issues.length?{success:!1,error:new(i??eJ)(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eX=(n=eH,async(e,t,i)=>{let r=i?Object.assign(i,{async:!0}):{async:!0},s=e._zod.run({value:t,issues:[]},r);return s instanceof Promise&&(s=await s),s.issues.length?{success:!1,error:new n(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eY=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,e0="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",e1=RegExp(`^${e0}$`);function e2(e){let t="(?:[01]\\d|2[0-3]):[0-5]\\d";return"number"==typeof e.precision?-1===e.precision?`${t}`:0===e.precision?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}let e6=/^-?\d+(?:\.\d+)?$/,e3=/^(?:true|false)$/i,e4=/^null$/i,e8=e_("$ZodCheck",(e,t)=>{var i;e._zod??(e._zod={}),e._zod.def=t,(i=e._zod).onattach??(i.onattach=[])}),e5=e_("$ZodCheckMinLength",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag.minimum??-1/0;t.minimum>i&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=i=>{let n=i.value;if(n.length>=t.minimum)return;let r=eQ(n);i.issues.push({origin:r,code:"too_small",minimum:t.minimum,inclusive:!0,input:n,inst:e,continue:!t.abort})}}),e9=e_("$ZodCheckLengthEquals",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag;i.minimum=t.length,i.maximum=t.length,i.length=t.length}),e._zod.check=i=>{let n=i.value,r=n.length;if(r===t.length)return;let s=eQ(n),o=r>t.length;i.issues.push({origin:s,...o?{code:"too_big",maximum:t.length}:{code:"too_small",minimum:t.length},inclusive:!0,exact:!0,input:i.value,inst:e,continue:!t.abort})}}),e7=e_("$ZodCheckStringFormat",(e,t)=>{var i,n;e8.init(e,t),e._zod.onattach.push(e=>{let i=e._zod.bag;i.format=t.format,t.pattern&&(i.patterns??(i.patterns=new Set),i.patterns.add(t.pattern))}),t.pattern?(i=e._zod).check??(i.check=i=>{t.pattern.lastIndex=0,t.pattern.test(i.value)||i.issues.push({origin:"string",code:"invalid_format",format:t.format,input:i.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(n=e._zod).check??(n.check=()=>{})}),te={major:4,minor:3,patch:6},tt=e_("$ZodType",(e,t)=>{var i;e??(e={}),e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=te;let n=[...e._zod.def.checks??[]];for(let t of(e._zod.traits.has("$ZodCheck")&&n.unshift(e),n))for(let i of t._zod.onattach)i(e);if(0===n.length)(i=e._zod).deferred??(i.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,i)=>{let n,r=eV(e);for(let s of t){if(s._zod.def.when){if(!s._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=s._zod.check(e);if(o instanceof Promise&&i?.async===!1)throw new ez;if(n||o instanceof Promise)n=(n??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||(r=eV(e,t)))});else{if(e.issues.length===t)continue;r||(r=eV(e,t))}}return n?n.then(()=>e):e},i=(i,r,s)=>{if(eV(i))return i.aborted=!0,i;let o=t(r,n,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(t=>e._zod.parse(t,s))}return e._zod.parse(o,s)};e._zod.run=(r,s)=>{if(s.skipChecks)return e._zod.parse(r,s);if("backward"===s.direction){let t=e._zod.parse({value:r.value,issues:[]},{...s,skipChecks:!0});return t instanceof Promise?t.then(e=>i(e,r,s)):i(t,r,s)}let o=e._zod.parse(r,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(e=>t(e,n,s))}return t(o,n,s)}}eP(e,"~standard",()=>({validate:t=>{try{let i=eG(e,t);return i.success?{value:i.data}:{issues:i.error?.issues}}catch(i){return eX(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:"zod",version:1}))}),ti=e_("$ZodString",(e,t)=>{var i;let n;tt.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??(n=(i=e._zod.bag)?`[\\s\\S]{${i?.minimum??0},${i?.maximum??""}}`:"[\\s\\S]*",RegExp(`^${n}$`)),e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=String(i.value)}catch(e){}return"string"==typeof i.value||i.issues.push({expected:"string",code:"invalid_type",input:i.value,inst:e}),i}}),tn=e_("$ZodStringFormat",(e,t)=>{e7.init(e,t),ti.init(e,t)}),tr=e_("$ZodISODateTime",(e,t)=>{let i,n,r;t.pattern??(i=e2({precision:t.precision}),n=["Z"],t.local&&n.push(""),t.offset&&n.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)"),r=`${i}(?:${n.join("|")})`,t.pattern=RegExp(`^${e0}T(?:${r})$`)),tn.init(e,t)}),ts=((e,t)=>{t.pattern??(t.pattern=e1),tn.init(e,t)},e_("$ZodNumber",(e,t)=>{tt.init(e,t),e._zod.pattern=e._zod.bag.pattern??e6,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=Number(i.value)}catch(e){}let r=i.value;if("number"==typeof r&&!Number.isNaN(r)&&Number.isFinite(r))return i;let s="number"==typeof r?Number.isNaN(r)?"NaN":Number.isFinite(r)?void 0:"Infinity":void 0;return i.issues.push({expected:"number",code:"invalid_type",input:r,inst:e,...s?{received:s}:{}}),i}})),to=e_("$ZodBoolean",(e,t)=>{tt.init(e,t),e._zod.pattern=e3,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=!!i.value}catch(e){}let r=i.value;return"boolean"==typeof r||i.issues.push({expected:"boolean",code:"invalid_type",input:r,inst:e}),i}}),ta=e_("$ZodNull",(e,t)=>{tt.init(e,t),e._zod.pattern=e4,e._zod.values=new Set([null]),e._zod.parse=(t,i)=>{let n=t.value;return null===n||t.issues.push({expected:"null",code:"invalid_type",input:n,inst:e}),t}}),tl=e_("$ZodAny",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e}),tu=e_("$ZodUnknown",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e});function tc(e,t,i){e.issues.length&&t.issues.push(...eN(i,e.issues)),t.value[i]=e.value}let td=e_("$ZodArray",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!Array.isArray(r))return i.issues.push({expected:"array",code:"invalid_type",input:r,inst:e}),i;i.value=Array(r.length);let s=[];for(let e=0;etc(t,i,e))):tc(a,i,e)}return s.length?Promise.all(s).then(()=>i):i}});function tp(e,t,i,n,r){if(e.issues.length){if(r&&!(i in n))return;t.issues.push(...eN(i,e.issues))}void 0===e.value?i in n&&(t.value[i]=void 0):t.value[i]=e.value}let th=e_("$ZodObject",(e,t)=>{let i;tt.init(e,t);let n=Object.getOwnPropertyDescriptor(t,"shape");if(!n?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let i={...e};return Object.defineProperty(t,"shape",{value:i}),i}})}let r=ek(()=>(function(e){var t;let i=Object.keys(e.shape);for(let t of i)if(!e.shape?.[t]?._zod?.traits?.has("$ZodType"))throw Error(`Invalid element at key "${t}": expected a Zod schema`);let n=Object.keys(t=e.shape).filter(e=>"optional"===t[e]._zod.optin&&"optional"===t[e]._zod.optout);return{...e,keys:i,keySet:new Set(i),numKeys:i.length,optionalKeys:new Set(n)}})(t));eP(e._zod,"propValues",()=>{let e=t.shape,i={};for(let t in e){let n=e[t]._zod;if(n.values)for(let e of(i[t]??(i[t]=new Set),n.values))i[t].add(e)}return i});let s=t.catchall;e._zod.parse=(t,n)=>{i??(i=r.value);let o=t.value;if(!eF(o))return t.issues.push({expected:"object",code:"invalid_type",input:o,inst:e}),t;t.value={};let a=[],l=i.shape;for(let e of i.keys){let i=l[e],r="optional"===i._zod.optout,s=i._zod.run({value:o[e],issues:[]},n);s instanceof Promise?a.push(s.then(i=>tp(i,t,e,o,r))):tp(s,t,e,o,r)}return s?function(e,t,i,n,r,s){let o=[],a=r.keySet,l=r.catchall._zod,u=l.def.type,c="optional"===l.optout;for(let r in t){if(a.has(r))continue;if("never"===u){o.push(r);continue}let s=l.run({value:t[r],issues:[]},n);s instanceof Promise?e.push(s.then(e=>tp(e,i,r,t,c))):tp(s,i,r,t,c)}return(o.length&&i.issues.push({code:"unrecognized_keys",keys:o,input:t,inst:s}),e.length)?Promise.all(e).then(()=>i):i}(a,o,t,n,r.value,e):a.length?Promise.all(a).then(()=>t):t}});function tf(e,t,i,n){for(let i of e)if(0===i.issues.length)return t.value=i.value,t;let r=e.filter(e=>!eV(e));return 1===r.length?(t.value=r[0].value,r[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:i,errors:e.map(e=>e.issues.map(e=>eZ(e,n,eS())))}),t)}let tv=e_("$ZodUnion",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.options.some(e=>"optional"===e._zod.optin)?"optional":void 0),eP(e._zod,"optout",()=>t.options.some(e=>"optional"===e._zod.optout)?"optional":void 0),eP(e._zod,"values",()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),eP(e._zod,"pattern",()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>eI(e.source)).join("|")})$`)}});let i=1===t.options.length,n=t.options[0]._zod.run;e._zod.parse=(r,s)=>{if(i)return n(r,s);let o=!1,a=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},s);if(t instanceof Promise)a.push(t),o=!0;else{if(0===t.issues.length)return t;a.push(t)}}return o?Promise.all(a).then(t=>tf(t,r,e,s)):tf(a,r,e,s)}}),ty=e_("$ZodDiscriminatedUnion",(e,t)=>{t.inclusive=!1,tv.init(e,t);let i=e._zod.parse;eP(e._zod,"propValues",()=>{let e={};for(let i of t.options){let n=i._zod.propValues;if(!n||0===Object.keys(n).length)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(i)}"`);for(let[t,i]of Object.entries(n))for(let n of(e[t]||(e[t]=new Set),i))e[t].add(n)}return e});let n=ek(()=>{let e=t.options,i=new Map;for(let n of e){let e=n._zod.propValues?.[t.discriminator];if(!e||0===e.size)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(n)}"`);for(let t of e){if(i.has(t))throw Error(`Duplicate discriminator value "${String(t)}"`);i.set(t,n)}}return i});e._zod.parse=(r,s)=>{let o=r.value;if(!eF(o))return r.issues.push({code:"invalid_type",expected:"object",input:o,inst:e}),r;let a=n.value.get(o?.[t.discriminator]);return a?a._zod.run(r,s):t.unionFallback?i(r,s):(r.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:t.discriminator,input:o,path:[t.discriminator],inst:e}),r)}}),tg=e_("$ZodRecord",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!eC(r))return i.issues.push({expected:"record",code:"invalid_type",input:r,inst:e}),i;let s=[],o=t.keyType._zod.values;if(o){let a;i.value={};let l=new Set;for(let e of o)if("string"==typeof e||"number"==typeof e||"symbol"==typeof e){l.add("number"==typeof e?e.toString():e);let o=t.valueType._zod.run({value:r[e],issues:[]},n);o instanceof Promise?s.push(o.then(t=>{t.issues.length&&i.issues.push(...eN(e,t.issues)),i.value[e]=t.value})):(o.issues.length&&i.issues.push(...eN(e,o.issues)),i.value[e]=o.value)}for(let e in r)l.has(e)||(a=a??[]).push(e);a&&a.length>0&&i.issues.push({code:"unrecognized_keys",input:r,inst:e,keys:a})}else for(let o of(i.value={},Reflect.ownKeys(r))){if("__proto__"===o)continue;let a=t.keyType._zod.run({value:o,issues:[]},n);if(a instanceof Promise)throw Error("Async schemas not supported in object keys currently");if("string"==typeof o&&e6.test(o)&&a.issues.length){let e=t.keyType._zod.run({value:Number(o),issues:[]},n);if(e instanceof Promise)throw Error("Async schemas not supported in object keys currently");0===e.issues.length&&(a=e)}if(a.issues.length){"loose"===t.mode?i.value[o]=r[o]:i.issues.push({code:"invalid_key",origin:"record",issues:a.issues.map(e=>eZ(e,n,eS())),input:o,path:[o],inst:e});continue}let l=t.valueType._zod.run({value:r[o],issues:[]},n);l instanceof Promise?s.push(l.then(e=>{e.issues.length&&i.issues.push(...eN(o,e.issues)),i.value[a.value]=e.value})):(l.issues.length&&i.issues.push(...eN(o,l.issues)),i.value[a.value]=l.value)}return s.length?Promise.all(s).then(()=>i):i}}),tm=e_("$ZodEnum",(e,t)=>{var i;let n;tt.init(e,t);let r=(n=Object.values(i=t.entries).filter(e=>"number"==typeof e),Object.entries(i).filter(([e,t])=>-1===n.indexOf(+e)).map(([e,t])=>t)),s=new Set(r);e._zod.values=s,e._zod.pattern=RegExp(`^(${r.filter(e=>eR.has(typeof e)).map(e=>"string"==typeof e?eM(e):e.toString()).join("|")})$`),e._zod.parse=(t,i)=>{let n=t.value;return s.has(n)||t.issues.push({code:"invalid_value",values:r,input:n,inst:e}),t}}),tb=e_("$ZodLiteral",(e,t)=>{if(tt.init(e,t),0===t.values.length)throw Error("Cannot create literal schema with no valid values");let i=new Set(t.values);e._zod.values=i,e._zod.pattern=RegExp(`^(${t.values.map(e=>"string"==typeof e?eM(e):e?eM(e.toString()):String(e)).join("|")})$`),e._zod.parse=(n,r)=>{let s=n.value;return i.has(s)||n.issues.push({code:"invalid_value",values:t.values,input:s,inst:e}),n}});function tw(e,t){return e.issues.length&&void 0===t?{issues:[],value:void 0}:e}let t_=e_("$ZodOptional",(e,t)=>{tt.init(e,t),e._zod.optin="optional",e._zod.optout="optional",eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)})?$`):void 0}),e._zod.parse=(e,i)=>{if("optional"===t.innerType._zod.optin){let n=t.innerType._zod.run(e,i);return n instanceof Promise?n.then(t=>tw(t,e.value)):tw(n,e.value)}return void 0===e.value?e:t.innerType._zod.run(e,i)}}),tz=e_("$ZodNullable",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.innerType._zod.optin),eP(e._zod,"optout",()=>t.innerType._zod.optout),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)}|null)$`):void 0}),eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,i)=>null===e.value?e:t.innerType._zod.run(e,i)}),tO=e_("$ZodPrefault",(e,t)=>{tt.init(e,t),e._zod.optin="optional",eP(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(e,i)=>("backward"===i.direction||void 0===e.value&&(e.value=t.defaultValue),t.innerType._zod.run(e,i))}),tS=e_("$ZodLazy",(e,t)=>{tt.init(e,t),eP(e._zod,"innerType",()=>t.getter()),eP(e._zod,"pattern",()=>e._zod.innerType?._zod?.pattern),eP(e._zod,"propValues",()=>e._zod.innerType?._zod?.propValues),eP(e._zod,"optin",()=>e._zod.innerType?._zod?.optin??void 0),eP(e._zod,"optout",()=>e._zod.innerType?._zod?.optout??void 0),e._zod.parse=(t,i)=>e._zod.innerType._zod.run(t,i)});Symbol("ZodOutput"),Symbol("ZodInput");function tE(e,t){return new e5({check:"min_length",...eq(t),minimum:e})}(a=globalThis).__zod_globalRegistry??(a.__zod_globalRegistry=new class e{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let i=t[0];return this._map.set(e,i),i&&"object"==typeof i&&"id"in i&&this._idmap.set(i.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&"object"==typeof t&&"id"in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let i={...this.get(t)??{}};delete i.id;let n={...i,...this._map.get(e)};return Object.keys(n).length?n:void 0}return this._map.get(e)}has(e){return this._map.has(e)}});let tx=e_("ZodMiniType",(e,t)=>{if(!e._zod)throw Error("Uninitialized schema in ZodMiniType.");tt.init(e,t),e.def=t,e.type=t.type,e.parse=(t,i)=>eK(e,t,i,{callee:e.parse}),e.safeParse=(t,i)=>eG(e,t,i),e.parseAsync=async(t,i)=>eW(e,t,i,{callee:e.parseAsync}),e.safeParseAsync=async(t,i)=>eX(e,t,i),e.check=(...i)=>e.clone({...t,checks:[...t.checks??[],...i.map(e=>"function"==typeof e?{_zod:{check:e,def:{check:"custom"},onattach:[]}}:e)]},{parent:!0}),e.with=e.check,e.clone=(t,i)=>eB(e,t,i),e.brand=()=>e,e.register=(t,i)=>(t.add(e,i),e),e.apply=t=>t(e)}),tk=e_("ZodMiniString",(e,t)=>{ti.init(e,t),tx.init(e,t)});function tI(e){return new tk({type:"string",...eq(e)})}let t$=e_("ZodMiniStringFormat",(e,t)=>{tn.init(e,t),tk.init(e,t)}),tP=e_("ZodMiniNumber",(e,t)=>{ts.init(e,t),tx.init(e,t)});function tj(e){return new tP({type:"number",checks:[],...eq(e)})}let tA=e_("ZodMiniBoolean",(e,t)=>{to.init(e,t),tx.init(e,t)});function tT(e){return new tA({type:"boolean",...eq(e)})}let tF=e_("ZodMiniNull",(e,t)=>{ta.init(e,t),tx.init(e,t)});function tC(e){return new tF({type:"null",...eq(e)})}let tR=e_("ZodMiniAny",(e,t)=>{tl.init(e,t),tx.init(e,t)});function tM(){return new tR({type:"any"})}let tB=e_("ZodMiniUnknown",(e,t)=>{tu.init(e,t),tx.init(e,t)}),tq=e_("ZodMiniArray",(e,t)=>{td.init(e,t),tx.init(e,t)});function tU(e,t){return new tq({type:"array",element:e,...eq(t)})}let tV=e_("ZodMiniObject",(e,t)=>{th.init(e,t),tx.init(e,t),eP(e,"shape",()=>t.shape)});function tN(e,t){return new tV({type:"object",shape:e??{},...eq(t)})}function tD(e,t){if(!eC(t))throw Error("Invalid input to extend: expected a plain object");let i=e._zod.def.checks;if(i&&i.length>0){let i=e._zod.def.shape;for(let e in t)if(void 0!==Object.getOwnPropertyDescriptor(i,e))throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}let n=eA(e._zod.def,{get shape(){let i={...e._zod.def.shape,...t};return ej(this,"shape",i),i}});return eB(e,n)}function tZ(e,t){return e.clone({...e._zod.def,catchall:t})}let tQ=e_("ZodMiniUnion",(e,t)=>{tv.init(e,t),tx.init(e,t)});function tL(e,t){return new tQ({type:"union",options:e,...eq(t)})}let tJ=e_("ZodMiniDiscriminatedUnion",(e,t)=>{ty.init(e,t),tx.init(e,t)});function tH(e,t,i){return new tJ({type:"union",options:t,discriminator:e,...eq(i)})}let tK=e_("ZodMiniRecord",(e,t)=>{tg.init(e,t),tx.init(e,t)});function tW(e,t,i){return new tK({type:"record",keyType:e,valueType:t,...eq(i)})}let tG=e_("ZodMiniEnum",(e,t)=>{tm.init(e,t),tx.init(e,t),e.options=Object.values(t.entries)});function tX(e,t){return new tG({type:"enum",entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...eq(t)})}let tY=e_("ZodMiniLiteral",(e,t)=>{tb.init(e,t),tx.init(e,t)});function t0(e,t){return new tY({type:"literal",values:Array.isArray(e)?e:[e],...eq(t)})}let t1=e_("ZodMiniOptional",(e,t)=>{t_.init(e,t),tx.init(e,t)});function t2(e){return new t1({type:"optional",innerType:e})}let t6=e_("ZodMiniNullable",(e,t)=>{tz.init(e,t),tx.init(e,t)});function t3(e){return new t6({type:"nullable",innerType:e})}let t4=e_("ZodMiniPrefault",(e,t)=>{tO.init(e,t),tx.init(e,t)});function t8(e,t){return new t4({type:"prefault",innerType:e,get defaultValue(){return"function"==typeof t?t():eC(t)?{...t}:Array.isArray(t)?[...t]:t}})}let t5=e_("ZodMiniLazy",(e,t)=>{tS.init(e,t),tx.init(e,t)});function t9(){let e=new t5({type:"lazy",getter:()=>tL([tI(),tj(),tT(),tC(),tU(e),tW(tI(),e)])});return e}let t7=e_("ZodMiniISODateTime",(e,t)=>{tr.init(e,t),t$.init(e,t)});function ie(e){return new t7({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...eq(e)})}let it=tZ(tN({}),t9()),ii=tN({sys:tN({type:t0("Link"),linkType:tI(),id:tI()})}),ir=tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:tI()})}),is=tN({sys:tN({type:t0("Link"),linkType:t0("Environment"),id:tI()})}),io=tN({sys:tN({type:t0("Link"),linkType:t0("Space"),id:tI()})}),ia=tN({sys:tN({type:t0("Link"),linkType:t0("TaxonomyConcept"),id:tI()})}),il=tN({sys:tN({type:t0("Link"),linkType:t0("Tag"),id:tI()})}),iu=tN({type:t0("Entry"),contentType:ir,publishedVersion:tj(),id:tI(),createdAt:tM(),updatedAt:tM(),locale:t2(tI()),revision:tj(),space:io,environment:is}),ic=tN({fields:it,metadata:tN({tags:tU(il),concepts:t2(tU(ia))}),sys:iu}),id=tD(it,{nt_audience_id:tI(),nt_name:t2(tI()),nt_description:t2(tI())}),ip=tD(ic,{fields:id});tN({contentTypeId:t0("nt_audience"),fields:id});let ih=tD(ic,{fields:tN({nt_name:tI(),nt_fallback:t2(tI()),nt_mergetag_id:tI()}),sys:tD(iu,{contentType:tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:t0("nt_mergetag")})})})}),iv=tN({id:tI(),hidden:t2(tT())}),iy=tN({type:t2(t0("EntryReplacement")),baseline:iv,variants:tU(iv)}),ig=tN({value:tL([tI(),tT(),tC(),tj(),tW(tI(),t9())])}),im=tX(["Boolean","Number","Object","String"]),ib=tH("type",[iy,tN({type:t0("InlineVariable"),key:tI(),valueType:im,baseline:ig,variants:tU(ig)})]),iw=tU(ib),i_=tN({distribution:t2(tU(tj())),traffic:t2(tj()),components:t2(iw),sticky:t2(tT())}),iz=tL([t0("nt_experiment"),t0("nt_personalization")]),iO=tD(it,{nt_name:tI(),nt_description:t2(t3(tI())),nt_type:iz,nt_config:t2(t3(i_)),nt_audience:t2(t3(ip)),nt_variants:t2(tU(tL([ii,ic]))),nt_experience_id:tI()}),iS=tD(ic,{fields:iO});tN({contentTypeId:t0("nt_experience"),fields:iO});let iE=tD(ic,{fields:tD(it,{nt_experiences:tU(tL([ii,iS]))})});function ix(e){return iS.safeParse(e).success}function ik(e){return iE.safeParse(e).success}let iI=t2(tN({name:tI(),version:tI()})),i$=tN({name:t2(tI()),source:t2(tI()),medium:t2(tI()),term:t2(tI()),content:t2(tI())}),iP=tL([t0("mobile"),t0("server"),t0("web")]),ij=tW(tI(),tI()),iA=tN({latitude:tj(),longitude:tj()}),iT=tN({coordinates:t2(iA),city:t2(tI()),postalCode:t2(tI()),region:t2(tI()),regionCode:t2(tI()),country:t2(tI()),countryCode:t2(tI().check(new e9({check:"length_equals",...eq(void 0),length:2}))),continent:t2(tI()),timezone:t2(tI())}),iF=tN({name:tI(),version:tI()}),iC=tZ(tN({path:tI(),query:ij,referrer:tI(),search:tI(),title:t2(tI()),url:tI()}),t9()),iR=tW(tI(),t9()),iM=tZ(tN({name:tI()}),t9()),iB=tW(tI(),t9()),iq=tN({app:iI,campaign:i$,gdpr:tN({isConsentGiven:tT()}),library:iF,locale:tI(),location:t2(iT),userAgent:t2(tI())}),iU=tN({channel:iP,context:tD(iq,{page:t2(iC),screen:t2(iM)}),messageId:tI(),originalTimestamp:ie(),sentAt:ie(),timestamp:ie(),userId:t2(tI())}),iV=tD(iU,{type:t0("alias")}),iN=tD(iU,{type:t0("group")}),iD=tD(iU,{type:t0("identify"),traits:iB}),iZ=tD(iq,{page:iC}),iQ=tD(iU,{type:t0("page"),name:t2(tI()),properties:iC,context:iZ}),iL=tD(iq,{screen:iM}),iJ=tD(iU,{type:t0("screen"),name:tI(),properties:t2(iR),context:iL}),iH=tD(iU,{type:t0("track"),event:tI(),properties:iR}),iK=tD(iU,{componentType:tL([t0("Entry"),t0("Variable")]),componentId:tI(),experienceId:t2(tI()),variantIndex:tj()}),iW=tD(iK,{type:t0("component"),viewDurationMs:t2(tj()),viewId:t2(tI())}),iG={anonymousId:tI()},iX=tU(tH("type",[tD(iV,iG),tD(iW,iG),tD(iN,iG),tD(iD,iG),tD(iQ,iG),tD(iJ,iG),tD(iH,iG)])),iY=tH("type",[iV,iW,iN,iD,iQ,iJ,iH]),i0=tU(iY),i1=tN({features:t2(tU(tI()))}),i2=tN({events:i0.check(tE(1)),options:t2(i1)}),i6=tN({events:iX.check(tE(1)),options:t2(i1)}),i3=tN({id:tI(),isReturningVisitor:tT(),landingPage:iC,count:tj(),activeSessionLength:tj(),averageSessionLength:tj()}),i4=tN({id:tI(),stableId:tI(),random:tj(),audiences:tU(tI()),traits:iB,location:iT,session:i3}),i8=tZ(tN({id:tI()}),t9()),i5=tN({data:tN(),message:tI(),error:t3(tT())}),i9=tD(i5,{data:tN({profiles:t2(tU(i4))})}),i7=tN({key:tI(),type:tL([tX(["Variable"]),tI()]),meta:tN({experienceId:tI(),variantIndex:tj()})}),ne=tL([tI(),tT(),tC(),tj(),tW(tI(),t9())]);tD(i7,{type:tI(),value:new tB({type:"unknown"})});let nt=tU(tH("type",[tD(i7,{type:t0("Variable"),value:ne})])),ni=tU(tN({experienceId:tI(),variantIndex:tj(),variants:tW(tI(),tI()),sticky:t2(t8(tT(),!1))})),nn=tD(i5,{data:tN({profile:i4,experiences:ni,changes:nt})}),nr=tH("type",[iW,tD(iK,{type:t0("component_click")}),tD(iK,{type:t0("component_hover"),hoverDurationMs:tj(),hoverId:tI()})]),ns=tN({profile:i8,events:tU(nr)}),no=tU(ns);function na(e,t){let i=e.safeParse(t);if(i.success)return i.data;throw Error(function(e){let t=[];for(let i of[...e.issues].sort((e,t)=>(e.path??[]).length-(t.path??[]).length))t.push(`✖ ${i.message}`),i.path?.length&&t.push(` → at ${function(e){let t=[];for(let i of e.map(e=>"object"==typeof e?e.key:e))"number"==typeof i?t.push(`[${i}]`):"symbol"==typeof i?t.push(`[${JSON.stringify(String(i))}]`):/[^\w$]/.test(i)?t.push(`[${JSON.stringify(i)}]`):(t.length&&t.push("."),t.push(i));return t.join("")}(i.path)}`);return t.join("\n")}(i.error))}eS({localeError:(r={string:{unit:"characters",verb:"to have"},file:{unit:"bytes",verb:"to have"},array:{unit:"items",verb:"to have"},set:{unit:"items",verb:"to have"},map:{unit:"entries",verb:"to have"}},s={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",mac:"MAC address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},o={nan:"NaN"},e=>{switch(e.code){case"invalid_type":{let t=o[e.expected]??e.expected,i=function(e){let t=typeof e;switch(t){case"number":return Number.isNaN(e)?"nan":"number";case"object":if(null===e)return"null";if(Array.isArray(e))return"array";if(e&&Object.getPrototypeOf(e)!==Object.prototype&&"constructor"in e&&e.constructor)return e.constructor.name}return t}(e.input),n=o[i]??i;return`Invalid input: expected ${t}, received ${n}`}case"invalid_value":if(1===e.values.length)return`Invalid input: expected ${eU(e.values[0])}`;return`Invalid option: expected one of ${eE(e.values,"|")}`;case"too_big":{let t=e.inclusive?"<=":"<",i=r[e.origin]??null;if(i)return`Too big: expected ${e.origin??"value"} to have ${t}${e.maximum.toString()} ${i.unit??"elements"}`;return`Too big: expected ${e.origin??"value"} to be ${t}${e.maximum.toString()}`}case"too_small":{let t=e.inclusive?">=":">",i=r[e.origin]??null;if(i)return`Too small: expected ${e.origin} to have ${t}${e.minimum.toString()} ${i.unit}`;return`Too small: expected ${e.origin} to be ${t}${e.minimum.toString()}`}case"invalid_format":if("starts_with"===e.format)return`Invalid string: must start with "${e.prefix}"`;if("ends_with"===e.format)return`Invalid string: must end with "${e.suffix}"`;if("includes"===e.format)return`Invalid string: must include "${e.includes}"`;if("regex"===e.format)return`Invalid string: must match pattern ${e.pattern}`;return`Invalid ${s[e.format]??e.format}`;case"not_multiple_of":return`Invalid number: must be a multiple of ${e.divisor}`;case"unrecognized_keys":return`Unrecognized key${e.keys.length>1?"s":""}: ${eE(e.keys,", ")}`;case"invalid_key":return`Invalid key in ${e.origin}`;case"invalid_union":default:return"Invalid input";case"invalid_element":return`Invalid value in ${e.origin}`}})});let nl=new class{name="@contentful/optimization";PREFIX_PARTS=["Ctfl","O10n"];DELIMITER=":";sinks=[];assembleLocationPrefix(e){return`[${[...this.PREFIX_PARTS,e].join(this.DELIMITER)}]`}addSink(e){this.sinks=[...this.sinks.filter(t=>t.name!==e.name),e]}removeSink(e){this.sinks=this.sinks.filter(t=>t.name!==e)}removeSinks(){this.sinks=[]}debug(e,t,...i){this.emit("debug",e,t,...i)}info(e,t,...i){this.emit("info",e,t,...i)}log(e,t,...i){this.emit("log",e,t,...i)}warn(e,t,...i){this.emit("warn",e,t,...i)}error(e,t,...i){this.emit("error",e,t,...i)}fatal(e,t,...i){this.emit("fatal",e,t,...i)}emit(e,t,i,...n){this.onLogEvent({name:this.name,level:e,messages:[`${this.assembleLocationPrefix(t)} ${String(i)}`,...n]})}onLogEvent(e){this.sinks.forEach(t=>{t.ingest(e)})}};function nu(e){return{debug:(t,...i)=>{nl.debug(e,t,...i)},info:(t,...i)=>{nl.info(e,t,...i)},log:(t,...i)=>{nl.log(e,t,...i)},warn:(t,...i)=>{nl.warn(e,t,...i)},error:(t,...i)=>{nl.error(e,t,...i)},fatal:(t,...i)=>{nl.fatal(e,t,...i)}}}let nc={fatal:60,error:50,warn:40,info:30,debug:20,log:10},nd=class{},np={debug:(...e)=>{console.debug(...e)},info:(...e)=>{console.info(...e)},log:(...e)=>{console.log(...e)},warn:(...e)=>{console.warn(...e)},error:(...e)=>{console.error(...e)},fatal:(...e)=>{console.error(...e)}};class nh extends nd{name="ConsoleLogSink";verbosity;constructor(e){super(),this.verbosity=e??"error"}ingest(e){nc[e.level]{i(void 0)},e),await t}let ng=nu("ApiClient:Timeout"),nm=nu("ApiClient:Fetch"),nb=function(e){try{let t=function({apiName:e="Optimization",fetchMethod:t=fetch,onRequestTimeout:i,requestTimeout:n=3e3}={}){return async(r,s)=>{let o=new AbortController,a=setTimeout(()=>{"function"==typeof i?i({apiName:e}):ng.error(`Request to "${r.toString()}" timed out`,Error("Request timeout")),o.abort()},n),l=await t(r,{...s,signal:o.signal});return clearTimeout(a),l}}(e);return function({apiName:e="Optimization",fetchMethod:t=fetch,intervalTimeout:i=0,onFailedAttempt:n,retries:r=1}={}){return async(s,o)=>{let a=new AbortController,l=r+1,u=function({apiName:e="Optimization",controller:t,fetchMethod:i=fetch,init:n,url:r}){return async()=>{try{let s=await i(r,n);if(503===s.status)throw new nv(`${e} API request to "${r.toString()}" failed with status: "[${s.status}] ${s.statusText}".`,503);if(!s.ok){let e=Error(`Request to "${r.toString()}" failed with status: [${s.status}] ${s.statusText} - traceparent: ${s.headers.get("traceparent")}`);nf.error("Request failed with non-OK status:",e),t.abort();return}return nf.debug(`Response from "${r.toString()}":`,s),s}catch(e){if(e instanceof nv&&503===e.status)throw e;nf.error(`Request to "${r.toString()}" failed:`,e),t.abort()}}}({apiName:e,controller:a,fetchMethod:t,init:o,url:s});for(let t=1;t<=l;t++)try{let e=await u();if(e)return e;break}catch(s){if(!(s instanceof nv)||503!==s.status)throw s;let r=l-t;if(n?.({apiName:e,error:s,attemptNumber:t,retriesLeft:r}),0===r)throw s;await ny(i)}throw Error(`${e} API request to "${s.toString()}" may not be retried.`)}}({...e,fetchMethod:t})}catch(e){throw e instanceof Error&&("AbortError"===e.name?nm.warn("Request aborted due to network issues. This request may not be retried."):nm.error("Request failed:",e)),e}},nw=nu("ApiClient"),n_=class{name;clientId;environment;fetch;constructor(e,{fetchOptions:t,clientId:i,environment:n}){this.clientId=i,this.environment=n??"main",this.name=e,this.fetch=nb({...t??{},apiName:e})}logRequestError(e,{requestName:t}){e instanceof Error&&("AbortError"===e.name?nw.warn(`[${this.name}] "${t}" request aborted due to network issues. This request may not be retried.`):nw.error(`[${this.name}] "${t}" request failed:`,e))}},nz=nu("ApiClient:Experience");class nO extends n_{baseUrl;enabledFeatures;ip;locale;plainText;preflight;constructor(e){super("Experience",e);const{baseUrl:t,enabledFeatures:i,ip:n,locale:r,plainText:s,preflight:o}=e;this.baseUrl=t||"https://experience.ninetailed.co/",this.enabledFeatures=i,this.ip=n,this.locale=r,this.plainText=s,this.preflight=o}async getProfile(e,t={}){if(!e)throw Error("Valid profile ID required.");let i="Get Profile";nz.info(`Sending "${i}" request`);try{let n=await this.fetch(this.constructUrl(`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,t),{method:"GET"}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await n.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async makeProfileMutationRequest({url:e,body:t,options:i}){return await this.fetch(this.constructUrl(e,i),{method:"POST",headers:this.constructHeaders(i),body:JSON.stringify(t),keepalive:!0})}async createProfile({events:e},t={}){let i="Create Profile";nz.info(`Sending "${i}" request`);let n=this.constructExperienceRequestBody(e,t);nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles`,body:n,options:t}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await e.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async updateProfile({profileId:e,events:t},i={}){if(!e)throw Error("Valid profile ID required.");let n="Update Profile";nz.info(`Sending "${n}" request`);let r=this.constructExperienceRequestBody(t,i);nz.debug(`"${n}" request body:`,r);try{let t=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,body:r,options:i}),{data:{changes:s,experiences:o,profile:a}}=na(nn,await t.json());return nz.debug(`"${n}" request successfully completed`),{changes:s,selectedOptimizations:o,profile:a}}catch(e){throw this.logRequestError(e,{requestName:n}),e}}async upsertProfile({profileId:e,events:t},i){return e?await this.updateProfile({profileId:e,events:t},i):await this.createProfile({events:t},i)}async upsertManyProfiles({events:e},t={}){let i="Upsert Many Profiles";nz.info(`Sending "${i}" request`);let n=na(i6,{events:e,options:this.constructBodyOptions(t)});nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/events`,body:n,options:{plainText:!1,...t}}),{data:{profiles:r}}=na(i9,await e.json());return nz.debug(`"${i}" request successfully completed`),r}catch(e){throw this.logRequestError(e,{requestName:i}),e}}constructUrl(e,t){let i=new URL(e,this.baseUrl),n=t.locale??this.locale,r=t.preflight??this.preflight;return n&&i.searchParams.set("locale",n),r&&i.searchParams.set("type","preflight"),i.toString()}constructHeaders({ip:e=this.ip,plainText:t=this.plainText}){let i=new Map;return e&&i.set("X-Force-IP",e),t??this.plainText??!0?i.set("Content-Type","text/plain"):i.set("Content-Type","application/json"),Object.fromEntries(i)}constructBodyOptions=({enabledFeatures:e=this.enabledFeatures})=>{let t={};return e&&Array.isArray(e)&&e.length>0?t.features=e:t.features=["ip-enrichment","location"],t};constructExperienceRequestBody(e,t){return i2.parse({events:na(i0,e),options:this.constructBodyOptions(t)})}}let nS=nu("ApiClient:Insights");class nE extends n_{baseUrl;beaconHandler;constructor(e){super("Insights",e);const{baseUrl:t,beaconHandler:i}=e;this.baseUrl=t||"https://ingest.insights.ninetailed.co/",this.beaconHandler=i}async sendBatchEvents(e,t={}){let{beaconHandler:i=this.beaconHandler}=t,n=new URL(`v1/organizations/${this.clientId}/environments/${this.environment}/events`,this.baseUrl),r=na(no,e);if("function"==typeof i){if(nS.debug("Queueing events via beaconHandler"),i(n,r))return!0;nS.warn("beaconHandler failed to queue events; events will be emitted immediately via fetch")}let s="Event Batches";nS.info(`Sending "${s}" request`),nS.debug(`"${s}" request body:`,r);try{return await this.fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r),keepalive:!0}),nS.debug(`"${s}" request successfully completed`),!0}catch(e){return this.logRequestError(e,{requestName:s}),!1}}}class nx{config;experience;insights;constructor(e){const{experience:t,insights:i,clientId:n,environment:r,fetchOptions:s}=e,o={clientId:n,environment:r,fetchOptions:s};this.config=o,this.experience=new nO({...o,...t}),this.insights=new nE({...o,...i})}}function nk(e){if(!e||"object"!=typeof e)return!1;let t=Object.getPrototypeOf(e);return(null===t||t===Object.prototype||null===Object.getPrototypeOf(t))&&"[object Object]"===Object.prototype.toString.call(e)}function nI(e){return nk(e)||Array.isArray(e)}let n$=tN({campaign:t2(i$),locale:t2(tI()),location:t2(iT),page:t2(iC),screen:t2(iM),userAgent:t2(tI())}),nP=tD(n$,{componentId:tI(),experienceId:t2(tI()),variantIndex:t2(tj())}),nj=tD(nP,{sticky:t2(tT()),viewId:tI(),viewDurationMs:tj()}),nA=tD(nP,{viewId:t2(tI()),viewDurationMs:t2(tj())}),nT=tD(nP,{hoverId:tI(),hoverDurationMs:tj()}),nF=tD(n$,{traits:t2(iB),userId:tI()}),nC=tD(n$,{properties:t2(function(e,t){var i=void 0;let n=e._zod.def.checks;if(n&&n.length>0)throw Error(".partial() cannot be used on object schemas containing refinements");let r=eA(e._zod.def,{get shape(){let t=e._zod.def.shape,n={...t};if(i)for(let e in i){if(!(e in t))throw Error(`Unrecognized key: "${e}"`);i[e]&&(n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e])}else for(let e in t)n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e];return ej(this,"shape",n),n},checks:[]});return eB(e,r)}(iC))}),nR=tD(n$,{name:tI(),properties:iR}),nM=tD(n$,{event:tI(),properties:t2(t8(iR,{}))}),nB={path:"",query:{},referrer:"",search:"",title:"",url:""},nq=class{app;channel;library;getLocale;getPageProperties;getUserAgent;constructor(e){const{app:t,channel:i,library:n,getLocale:r,getPageProperties:s,getUserAgent:o}=e;this.app=t,this.channel=i,this.library=n,this.getLocale=r??(()=>"en-US"),this.getPageProperties=s??(()=>nB),this.getUserAgent=o??(()=>void 0)}buildUniversalEventProperties({campaign:e={},locale:t,location:i,page:n,screen:r,userAgent:s}){let o=new Date().toISOString();return{channel:this.channel,context:{app:this.app,campaign:e,gdpr:{isConsentGiven:!0},library:this.library,locale:t??this.getLocale()??"en-US",location:i,page:n??this.getPageProperties(),screen:r,userAgent:s??this.getUserAgent()},messageId:crypto.randomUUID(),originalTimestamp:o,sentAt:o,timestamp:o}}buildEntryInteractionBase(e,t,i,n){return{...this.buildUniversalEventProperties(e),componentType:"Entry",componentId:t,experienceId:i,variantIndex:n??0}}buildView(e){let{componentId:t,viewId:i,experienceId:n,variantIndex:r,viewDurationMs:s,...o}=na(nj,e);return{...this.buildEntryInteractionBase(o,t,n,r),type:"component",viewId:i,viewDurationMs:s}}buildClick(e){let{componentId:t,experienceId:i,variantIndex:n,...r}=na(nP,e);return{...this.buildEntryInteractionBase(r,t,i,n),type:"component_click"}}buildHover(e){let{hoverId:t,componentId:i,experienceId:n,hoverDurationMs:r,variantIndex:s,...o}=na(nT,e);return{...this.buildEntryInteractionBase(o,i,n,s),type:"component_hover",hoverId:t,hoverDurationMs:r}}buildFlagView(e){let{componentId:t,experienceId:i,variantIndex:n,viewId:r,viewDurationMs:s,...o}=na(nA,e);return{...this.buildEntryInteractionBase(o,t,i,n),...void 0===s?{}:{viewDurationMs:s},...void 0===r?{}:{viewId:r},type:"component",componentType:"Variable"}}buildIdentify(e){let{traits:t={},userId:i,...n}=na(nF,e);return{...this.buildUniversalEventProperties(n),type:"identify",traits:t,userId:i}}buildPageView(e={}){let{properties:t={},...i}=na(nC,e),n=this.getPageProperties(),r=function e(t,i){let n=Object.keys(i);for(let r=0;re?e.reduce((e,{key:t,value:i})=>{let n="object"==typeof i&&null!==i&&"value"in i&&"object"==typeof i.value?i.value:i;return e[t]=n,e},{}):{}},nN=nu("Optimization"),nD="Could not resolve Merge Tag value:",nZ=(e,t)=>{if(!e||"object"!=typeof e)return;if(!t)return e;let i=e;for(let e of t.split(".").filter(Boolean)){if(!i||"object"!=typeof i&&"function"!=typeof i)return;i=Reflect.get(i,e)}return i},nQ={normalizeSelectors:e=>e.split("_").map((e,t,i)=>[i.slice(0,t).join("."),i.slice(t).join("_")].filter(e=>""!==e).join(".")),getValueFromProfile(e,t){let i=nQ.normalizeSelectors(e).find(e=>nZ(t,e));if(!i)return;let n=nZ(t,i);if(n&&("string"==typeof n||"number"==typeof n||"boolean"==typeof n))return`${n}`},resolve(e,t){if(!ih.safeParse(e).success)return void nN.warn(`${nD} supplied entry is not a Merge Tag entry`);let{fields:{nt_fallback:i}}=e;return i4.safeParse(t).success?nQ.getValueFromProfile(e.fields.nt_mergetag_id,t)??i:(nN.warn(`${nD} no valid profile`),i)}},nL=nu("Optimization"),nJ="Could not resolve optimized entry variant:",nH={getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ik(e))return e.fields.nt_experiences.filter(e=>ix(e)).find(e=>t.some(({experienceId:t})=>t===e.fields.nt_experience_id))},getSelectedOptimization({optimizationEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ix(e))return t.find(({experienceId:t})=>t===e.fields.nt_experience_id)},getSelectedVariant({optimizedEntry:e,optimizationEntry:t,selectedVariantIndex:i},n=!1){var r;if(!n&&(!ik(e)||!ix(t)))return;let s=(r=t.fields.nt_config,{distribution:r?.distribution===void 0?[]:[...r.distribution],traffic:r?.traffic??0,components:r?.components===void 0?[]:[...r.components],sticky:r?.sticky??!1}).components.filter(e=>("EntryReplacement"===e.type||void 0===e.type)&&!e.baseline.hidden).find(t=>t.baseline.id===e.sys.id)?.variants;if(s?.length)return s.at(i-1)},getSelectedVariantEntry({optimizationEntry:e,selectedVariant:t},i=!1){if(!i&&(!ix(e)||!iv.safeParse(t).success))return;let n=e.fields.nt_variants?.find(e=>e.sys.id===t.id);return ic.safeParse(n).success?n:void 0},resolve:function(e,t){if(nL.debug(`Resolving optimized entry for baseline entry ${e.sys.id}`),!t?.length)return nL.warn(`${nJ} no selectedOptimizations exist for the current profile`),{entry:e};if(!ik(e))return nL.warn(`${nJ} entry ${e.sys.id} is not optimized`),{entry:e};let i=nH.getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},!0);if(!i)return nL.warn(`${nJ} could not find an optimization entry for ${e.sys.id}`),{entry:e};let n=nH.getSelectedOptimization({optimizationEntry:i,selectedOptimizations:t},!0),r=n?.variantIndex??0;if(0===r)return nL.debug(`Resolved optimization entry for entry ${e.sys.id} is baseline`),{entry:e};let s=nH.getSelectedVariant({optimizedEntry:e,optimizationEntry:i,selectedVariantIndex:r},!0);if(!s)return nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e};let o=nH.getSelectedVariantEntry({optimizationEntry:i,selectedVariant:s},!0);return o?(nL.debug(`Entry ${e.sys.id} has been resolved to variant entry ${o.sys.id}`),{entry:o,selectedOptimization:n}):(nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e})}};class nK{api;eventBuilder;config;flagsResolver=nV;mergeTagValueResolver=nQ;optimizedEntryResolver=nH;interceptors={event:new nU,state:new nU};constructor(e,t={}){this.config=e;const{eventBuilder:i,logLevel:n,environment:r,clientId:s,fetchOptions:o}=e;nl.addSink(new nh(n));const a={clientId:s,environment:r,fetchOptions:o,experience:t.experience,insights:t.insights};this.api=new nx(a),this.eventBuilder=new nq(i??{channel:"server",library:{name:"@contentful/optimization-android-bridge",version:"0.0.0"}})}getFlag(e,t){return this.flagsResolver.resolve(t)[e]}resolveOptimizedEntry(e,t){return this.optimizedEntryResolver.resolve(e,t)}getMergeTagValue(e,t){return this.mergeTagValueResolver.resolve(e,t)}}let nW=nK;function nG(){}function nX(e,t){return function e(t,i,n,r,s,o,a){let l=a(t,i,n,r,s,o);if(void 0!==l)return l;if(typeof t==typeof i)switch(typeof t){case"bigint":case"string":case"boolean":case"symbol":case"undefined":case"function":return t===i;case"number":return t===i||Object.is(t,i)}return function t(i,n,r,s){if(Object.is(i,n))return!0;let o=F(i),a=F(n);if(o===q&&(o=L),a===q&&(a=L),o!==a)return!1;switch(o){case R:return i.toString()===n.toString();case M:{let e=i.valueOf(),t=n.valueOf();return e===t||Number.isNaN(e)&&Number.isNaN(t)}case B:case V:case U:return Object.is(i.valueOf(),n.valueOf());case C:return i.source===n.source&&i.flags===n.flags;case"[object Function]":return i===n}let l=(r=r??new Map).get(i),u=r.get(n);if(null!=l&&null!=u)return l===n;r.set(i,n),r.set(n,i);try{switch(o){case N:if(i.size!==n.size)return!1;for(let[t,o]of i.entries())if(!n.has(t)||!e(o,n.get(t),t,i,n,r,s))return!1;return!0;case D:{if(i.size!==n.size)return!1;let t=Array.from(i.values()),o=Array.from(n.values());for(let a=0;ae(l,t,void 0,i,n,r,s));if(-1===u)return!1;o.splice(u,1)}return!0}case Z:case H:case K:case W:case G:case"[object BigUint64Array]":case X:case Y:case ee:case"[object BigInt64Array]":case et:case ei:if(er(i)!==er(n)||i.length!==n.length)return!1;for(let t=0;t{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))})}return i}resolveOptimizedEntry(e,t=ey.value){return super.resolveOptimizedEntry(e,t)}getMergeTagValue(e,t=em.value){return super.getMergeTagValue(e,t)}async identify(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("identify",[e],this.eventBuilder.buildIdentify(i),t)}async page(e={}){let{profile:t,...i}=e;return await this.sendExperienceEvent("page",[e],this.eventBuilder.buildPageView(i),t)}async screen(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("screen",[e],this.eventBuilder.buildScreenView(i),t)}async track(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("track",[e],this.eventBuilder.buildTrack(i),t)}async trackView(e){let t,{profile:i,...n}=e;return e.sticky&&(t=await this.sendExperienceEvent("trackView",[e],this.eventBuilder.buildView(n),i)),await this.sendInsightsEvent("trackView",[e],this.eventBuilder.buildView(n),i),t}async trackClick(e){await this.sendInsightsEvent("trackClick",[e],this.eventBuilder.buildClick(e))}async trackHover(e){await this.sendInsightsEvent("trackHover",[e],this.eventBuilder.buildHover(e))}async trackFlagView(e){await this.sendInsightsEvent("trackFlagView",[e],this.eventBuilder.buildFlagView(e))}hasConsent(e){let{[e]:t}=n0,i=void 0!==t?this.allowedEventTypes.includes(t):this.allowedEventTypes.some(t=>t===e);return!!ed.value||i}onBlockedByConsent(e,t){nY.warn(`Event "${e}" was blocked due to lack of consent; payload: ${JSON.stringify(t)}`),this.reportBlockedEvent("consent",e,t)}async sendExperienceEvent(e,t,i,n){return this.hasConsent(e)?await this.experienceQueue.send(i):void this.onBlockedByConsent(e,t)}async sendInsightsEvent(e,t,i,n){this.hasConsent(e)?await this.insightsQueue.send(i):this.onBlockedByConsent(e,t)}buildFlagViewBuilderArgs(e,t=eu.value){let i=t?.find(t=>t.key===e);return{componentId:e,experienceId:i?.meta.experienceId,variantIndex:i?.meta.variantIndex}}getFlagObservable(e){var t;let i,n=this.flagObservables.get(e);if(n)return n;let r=this.trackFlagView.bind(this),s=this.buildFlagViewBuilderArgs.bind(this),o=(t=ew.computed(()=>super.getFlag(e,eu.value)),i=el(t),{get current(){return i.current},subscribe(e){let t=!1,n=ea(i.current);return i.subscribe(i=>{t&&nX(n,i)||(t=!0,n=ea(i),e(i))})},subscribeOnce:e=>i.subscribeOnce(e)}),a={get current(){let{current:t}=o;return r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t},subscribe:t=>o.subscribe(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)}),subscribeOnce:t=>o.subscribeOnce(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)})};return this.flagObservables.set(e,a),a}reportBlockedEvent(e,t,i){let n={reason:e,method:t,args:i};try{this.onEventBlocked?.(n)}catch(e){nY.warn(`onEventBlocked callback failed for method "${t}"`,e)}ec.value=n}}let n2=n1,n6=(e,t)=>!Number.isFinite(e)||void 0===e||e<1?t:Math.floor(e),n3={flushIntervalMs:3e4,baseBackoffMs:500,maxBackoffMs:3e4,jitterRatio:.2,maxConsecutiveFailures:8,circuitOpenMs:12e4},n4="__ctfl_optimization_stateful_runtime_lock__",n8=()=>{let e=globalThis;return e[n4]??={owner:void 0},e[n4]},n5=e=>{let t=n8();t.owner===e&&(t.owner=void 0)};class n9{circuitOpenUntil=0;flushFailureCount=0;flushInFlight=!1;nextFlushAllowedAt=0;onCallbackError;onRetry;policy;retryTimer;constructor(e){const{onCallbackError:t,onRetry:i,policy:n}=e;this.policy=n,this.onRetry=i,this.onCallbackError=t}reset(){this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.flushInFlight=!1,this.nextFlushAllowedAt=0}clearScheduledRetry(){void 0!==this.retryTimer&&(clearTimeout(this.retryTimer),this.retryTimer=void 0)}shouldSkip(e){let{force:t,isOnline:i}=e;if(this.flushInFlight)return!0;if(t)return!1;if(!i)return!0;let n=Date.now();return!!(this.nextFlushAllowedAt>n)||!!(this.circuitOpenUntil>n)}markFlushStarted(){this.flushInFlight=!0}markFlushFinished(){this.flushInFlight=!1}handleFlushSuccess(){let{flushFailureCount:e}=this;this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.nextFlushAllowedAt=0,e<=0||this.safeInvoke("onFlushRecovered",{consecutiveFailures:e})}handleFlushFailure(e){let{queuedBatches:t,queuedEvents:i}=e;this.flushFailureCount+=1;let n=(e=>{let{consecutiveFailures:t,policy:{baseBackoffMs:i,jitterRatio:n,maxBackoffMs:r}}=e,s=Math.min(r,i*2**Math.max(0,t-1)),o=s*n*Math.random();return Math.round(s+o)})({consecutiveFailures:this.flushFailureCount,policy:this.policy}),r=Date.now(),s={consecutiveFailures:this.flushFailureCount,queuedBatches:t,queuedEvents:i,retryDelayMs:n};this.safeInvoke("onFlushFailure",s);let{circuitOpenUntil:o,nextFlushAllowedAt:a,openedCircuit:l,retryDelayMs:u}=(e=>{let{consecutiveFailures:t,failureTimestamp:i,retryDelayMs:n,policy:{maxConsecutiveFailures:r,circuitOpenMs:s}}=e;if(t{this.retryTimer=void 0,this.onRetry()},e)}safeInvoke(...e){let[t,i]=e;try{if("onFlushRecovered"===t)return void this.policy.onFlushRecovered?.(i);if("onCircuitOpen"===t)return void this.policy.onCircuitOpen?.(i);this.policy.onFlushFailure?.(i)}catch(e){this.onCallbackError?.(t,e)}}}let n7=nu("CoreStateful");class re{experienceApi;eventInterceptors;flushRuntime;getAnonymousId;offlineMaxEvents;onOfflineDrop;queuedExperienceEvents=new Set;stateInterceptors;constructor(e){const{experienceApi:t,eventInterceptors:i,flushPolicy:n,getAnonymousId:r,offlineMaxEvents:s,onOfflineDrop:o,stateInterceptors:a}=e;this.experienceApi=t,this.eventInterceptors=i,this.getAnonymousId=r,this.offlineMaxEvents=s,this.onOfflineDrop=o,this.stateInterceptors=a,this.flushRuntime=new n9({policy:n,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{n7.warn(`Experience flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}async send(e){let t=na(iY,await this.eventInterceptors.run(e));if(ep.value=t,eh.value)return await this.upsertProfile([t]);n7.debug(`Queueing ${t.type} event`,t),this.enqueueEvent(t)}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!eh.value}))return;if(0===this.queuedExperienceEvents.size)return void this.flushRuntime.clearScheduledRetry();n7.debug("Flushing offline Experience event queue");let i=Array.from(this.queuedExperienceEvents);this.flushRuntime.markFlushStarted();try{await this.tryUpsertQueuedEvents(i)?(i.forEach(e=>{this.queuedExperienceEvents.delete(e)}),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:+(this.queuedExperienceEvents.size>0),queuedEvents:this.queuedExperienceEvents.size})}finally{this.flushRuntime.markFlushFinished()}}enqueueEvent(e){let t=[];if(this.queuedExperienceEvents.size>=this.offlineMaxEvents){let e=this.queuedExperienceEvents.size-this.offlineMaxEvents+1;(t=this.dropOldestEvents(e)).length>0&&n7.warn(`Dropped ${t.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`)}this.queuedExperienceEvents.add(e),t.length>0&&this.invokeOfflineDropCallback({droppedCount:t.length,droppedEvents:t,maxEvents:this.offlineMaxEvents,queuedEvents:this.queuedExperienceEvents.size})}dropOldestEvents(e){let t=[];for(let i=0;i{nX(eu.value,t)||(eu.value=t),nX(em.value,i)||(em.value=i),nX(ey.value,n)||(ey.value=n)})}}let rt=nu("CoreStateful");class ri{eventInterceptors;flushIntervalMs;flushRuntime;insightsApi;queuedInsightsByProfile=new Map;insightsPeriodicFlushTimer;constructor(e){const{eventInterceptors:t,flushPolicy:i,insightsApi:n}=e,{flushIntervalMs:r}=i;this.eventInterceptors=t,this.flushIntervalMs=r,this.insightsApi=n,this.flushRuntime=new n9({policy:i,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{rt.warn(`Insights flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}clearPeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer&&(clearInterval(this.insightsPeriodicFlushTimer),this.insightsPeriodicFlushTimer=void 0)}async send(e){let{value:t}=em;if(!t)return void rt.warn("Attempting to emit an event without an Optimization profile");let i=na(nr,await this.eventInterceptors.run(e));rt.debug(`Queueing ${i.type} event for profile ${t.id}`,i);let n=this.queuedInsightsByProfile.get(t.id);ep.value=i,n?(n.profile=t,n.events.push(i)):this.queuedInsightsByProfile.set(t.id,{profile:t,events:[i]}),this.ensurePeriodicFlushTimer(),this.getQueuedEventCount()>=25&&await this.flush(),this.reconcilePeriodicFlushTimer()}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!eh.value}))return;rt.debug("Flushing insights event queue");let i=this.createBatches();if(!i.length){this.flushRuntime.clearScheduledRetry(),this.reconcilePeriodicFlushTimer();return}this.flushRuntime.markFlushStarted();try{await this.trySendBatches(i)?(this.queuedInsightsByProfile.clear(),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:i.length,queuedEvents:this.getQueuedEventCount()})}finally{this.flushRuntime.markFlushFinished(),this.reconcilePeriodicFlushTimer()}}createBatches(){let e=[];return this.queuedInsightsByProfile.forEach(({profile:t,events:i})=>{e.push({profile:t,events:i})}),e}async trySendBatches(e){try{return await this.insightsApi.sendBatchEvents(e)}catch(e){return rt.warn("Insights queue flush request threw an error",e),!1}}getQueuedEventCount(){let e=0;return this.queuedInsightsByProfile.forEach(({events:t})=>{e+=t.length}),e}ensurePeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer||0!==this.getQueuedEventCount()&&(this.insightsPeriodicFlushTimer=setInterval(()=>{this.flush()},this.flushIntervalMs))}reconcilePeriodicFlushTimer(){this.getQueuedEventCount()>0?this.ensurePeriodicFlushTimer():this.clearPeriodicFlushTimer()}}let rn=Symbol.for("ctfl.optimization.preview.signals"),rr=Symbol.for("ctfl.optimization.preview.signalFns"),rs=nu("CoreStateful"),ro=["identify","page","screen"],ra=e=>Object.values(e).some(e=>void 0!==e),rl=0;class ru extends n2{singletonOwner;destroyed=!1;allowedEventTypes;experienceQueue;insightsQueue;onEventBlocked;states={blockedEventStream:el(ec),flag:e=>this.getFlagObservable(e),consent:el(ed),eventStream:el(ep),canOptimize:el(eg),selectedOptimizations:el(ey),previewPanelAttached:el(ef),previewPanelOpen:el(ev),profile:el(em)};constructor(e){super(e,{experience:(e=>{if(void 0===e)return;let t={baseUrl:e.experienceBaseUrl,enabledFeatures:e.enabledFeatures,ip:e.ip,locale:e.locale,plainText:e.plainText,preflight:e.preflight};return ra(t)?t:void 0})(e.api),insights:(e=>{if(void 0===e)return;let t={baseUrl:e.insightsBaseUrl,beaconHandler:e.beaconHandler};return ra(t)?t:void 0})(e.api)}),this.singletonOwner=`CoreStateful#${++rl}`,(e=>{let t=n8();if(t.owner)throw Error(`Stateful Optimization SDK already initialized (${t.owner}). Only one stateful instance is supported per runtime.`);t.owner=e})(this.singletonOwner);try{const{allowedEventTypes:t,defaults:i,getAnonymousId:n,onEventBlocked:r,queuePolicy:s}=e,{changes:o,consent:a,selectedOptimizations:l,profile:u}=i??{},c=(e=>({flush:((e,t=n3)=>{var i,n;let r=e??{},s=n6(r.baseBackoffMs,t.baseBackoffMs),o=Math.max(s,n6(r.maxBackoffMs,t.maxBackoffMs));return{flushIntervalMs:n6(r.flushIntervalMs,t.flushIntervalMs),baseBackoffMs:s,maxBackoffMs:o,jitterRatio:(i=r.jitterRatio,n=t.jitterRatio,Number.isFinite(i)&&void 0!==i?Math.min(1,Math.max(0,i)):n),maxConsecutiveFailures:n6(r.maxConsecutiveFailures,t.maxConsecutiveFailures),circuitOpenMs:n6(r.circuitOpenMs,t.circuitOpenMs),onCircuitOpen:r.onCircuitOpen,onFlushFailure:r.onFlushFailure,onFlushRecovered:r.onFlushRecovered}})(e?.flush),offlineMaxEvents:n6(e?.offlineMaxEvents,100),onOfflineDrop:e?.onOfflineDrop}))(s);this.allowedEventTypes=t??ro,this.onEventBlocked=r,this.insightsQueue=new ri({eventInterceptors:this.interceptors.event,flushPolicy:c.flush,insightsApi:this.api.insights}),this.experienceQueue=new re({experienceApi:this.api.experience,eventInterceptors:this.interceptors.event,flushPolicy:c.flush,getAnonymousId:n??(()=>void 0),offlineMaxEvents:c.offlineMaxEvents,onOfflineDrop:c.onOfflineDrop,stateInterceptors:this.interceptors.state}),void 0!==a&&(ed.value=a),p(()=>{void 0!==o&&(eu.value=o),void 0!==l&&(ey.value=l),void 0!==u&&(em.value=u)}),this.initializeEffects()}catch(e){throw n5(this.singletonOwner),e}}initializeEffects(){A(()=>{rs.debug(`Profile ${em.value&&`with ID ${em.value.id}`} has been ${em.value?"set":"cleared"}`)}),A(()=>{rs.debug(`Variants have been ${ey.value?.length?"populated":"cleared"}`)}),A(()=>{rs.info(`Core ${ed.value?"will":"will not"} emit gated events due to consent (${ed.value})`)}),A(()=>{eh.value&&(this.insightsQueue.clearScheduledRetry(),this.experienceQueue.clearScheduledRetry(),this.flushQueues({force:!0}))})}async flushQueues(e={}){await this.insightsQueue.flush(e),await this.experienceQueue.flush(e)}destroy(){this.destroyed||(this.destroyed=!0,this.insightsQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush insights queue during destroy()",String(e))}),this.experienceQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush Experience queue during destroy()",String(e))}),this.insightsQueue.clearPeriodicFlushTimer(),n5(this.singletonOwner))}reset(){p(()=>{ec.value=void 0,ep.value=void 0,eu.value=void 0,em.value=void 0,ey.value=void 0})}async flush(){await this.flushQueues()}consent(e){ed.value=e}get online(){return eh.value??!1}set online(e){eh.value=e}registerPreviewPanel(e){Reflect.set(e,rn,eb),Reflect.set(e,rr,ew)}}let rc="ALL_VISITORS";function rd(e,t){let{audiences:{[t]:i}}=e;return i?i.isActive?"on":"off":"default"}function rp(e,t){return"on"===e||"off"!==e&&t}function rh(e,t,i,n){let r=t[e.id]??0,s=void 0!==i.selectedOptimizations[e.id],o={...e,currentVariantIndex:r,isOverridden:s};if(s&&void 0!==n){let{[e.id]:t}=n;void 0!==t&&(o.naturalVariantIndex=t)}return o}function rf(e,t){let i=Object.values(t);if(0===i.length)return e;let n=e.map(e=>{let{[e.experienceId]:i}=t;return i?{...e,variantIndex:i.variantIndex}:e});for(let e of i)n.some(t=>t.experienceId===e.experienceId)||n.push({experienceId:e.experienceId,variantIndex:e.variantIndex,variants:{}});return n}nu("Preview");let rv=nu("PreviewOverrides"),ry={audiences:{},selectedOptimizations:{}};class rg{baselineSelectedOptimizations=null;baselineAudienceQualifications={};overrides={...ry,audiences:{},selectedOptimizations:{}};interceptorId=null;selectedOptimizations;profile;stateInterceptors;onOverridesChanged;constructor(e){const{selectedOptimizations:t,profile:i,stateInterceptors:n,onOverridesChanged:r}=e;this.selectedOptimizations=t,this.profile=i,this.stateInterceptors=n,this.onOverridesChanged=r;const{value:s}=t;s&&(this.baselineSelectedOptimizations=s,rv.debug("Captured initial signal state as baseline")),this.interceptorId=e.stateInterceptors.add(e=>{let{selectedOptimizations:t}=e;this.baselineSelectedOptimizations=t;let i=Object.keys(this.overrides.selectedOptimizations).length>0,n=i?{...e,selectedOptimizations:rf(e.selectedOptimizations,this.overrides.selectedOptimizations)}:{...e};return i&&rv.debug("Intercepting state update to preserve overrides"),this.notifyChanged(),n}),rv.info("State interceptor registered")}activateAudience(e,t){rv.info("Activating audience override:",e),this.setAudienceOverride(e,!0,1,t)}deactivateAudience(e,t){rv.info("Deactivating audience override:",e),this.setAudienceOverride(e,!1,0,t)}resetAudienceOverride(e){rv.info("Resetting audience override:",e);let{overrides:t}=this,{audiences:i,selectedOptimizations:n}=t,r=i[e]?.experienceIds??[],s=new Set(r),o=Object.fromEntries(Object.entries(n).filter(([e])=>!s.has(e))),a=Object.fromEntries(Object.entries(i).filter(([t])=>t!==e));this.overrides={audiences:a,selectedOptimizations:o},this.baselineAudienceQualifications=Object.fromEntries(Object.entries(this.baselineAudienceQualifications).filter(([t])=>t!==e)),r.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}setVariantOverride(e,t){rv.info("Setting variant override:",{experienceId:e,variantIndex:t}),this.overrides={...this.overrides,selectedOptimizations:{...this.overrides.selectedOptimizations,[e]:{experienceId:e,variantIndex:t}}},this.syncOverridesToSignal(),this.notifyChanged()}resetOptimizationOverride(e){rv.info("Resetting optimization override:",e);let{selectedOptimizations:t}={...this.overrides},i=Object.fromEntries(Object.entries(t).filter(([t])=>t!==e));this.overrides={...this.overrides,selectedOptimizations:i},this.syncOverridesToSignal(),this.notifyChanged()}resetAll(){rv.info("Resetting all overrides to baseline"),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineAudienceQualifications={};let{baselineSelectedOptimizations:e}=this;e&&(this.selectedOptimizations.value=e,rv.debug("Restored signal to baseline")),this.notifyChanged()}getOverrides(){return this.overrides}getBaselineSelectedOptimizations(){return this.baselineSelectedOptimizations}getBaselineAudienceQualifications(){return this.baselineAudienceQualifications}destroy(){null!==this.interceptorId&&(this.stateInterceptors.remove(this.interceptorId),rv.info("State interceptor removed"),this.interceptorId=null),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineSelectedOptimizations=null,this.baselineAudienceQualifications={}}snapshotAudienceQualification(e){if(!this.profile||e in this.baselineAudienceQualifications)return;let t=this.profile.value?.audiences.includes(e)??!1;this.baselineAudienceQualifications[e]=t}syncOverridesToSignal(){this.selectedOptimizations.value=rf(this.baselineSelectedOptimizations??[],this.overrides.selectedOptimizations),rv.debug("Synced overrides to signal")}setAudienceOverride(e,t,i,n){this.snapshotAudienceQualification(e);let r={...this.overrides.selectedOptimizations};for(let e of n)r[e]={experienceId:e,variantIndex:i};this.overrides={audiences:{...this.overrides.audiences,[e]:{audienceId:e,isActive:t,source:"manual",experienceIds:n}},selectedOptimizations:r},n.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}notifyChanged(){this.onOverridesChanged?.(this.overrides)}}let rm=null,rb=null,rw=null,r_=null,rz=null,rO=null,rS={},rE={},rx={initialize(e){rm&&rx.destroy(),rz=null,rO=null,rS={},rE={},rm=new ru({clientId:e.clientId,environment:e.environment,api:{experienceBaseUrl:e.experienceBaseUrl,insightsBaseUrl:e.insightsBaseUrl}}),e.defaults&&(void 0!==e.defaults.consent&&rm.consent(e.defaults.consent),void 0!==e.defaults.profile&&(eb.profile.value=e.defaults.profile),void 0!==e.defaults.changes&&(eb.changes.value=e.defaults.changes),void 0!==e.defaults.optimizations&&(eb.selectedOptimizations.value=e.defaults.optimizations)),rm.consent(!0);let t=globalThis;r_=new rg({selectedOptimizations:eb.selectedOptimizations,profile:eb.profile,stateInterceptors:rm.interceptors.state,onOverridesChanged:()=>{"function"==typeof t.__nativeOnOverridesChanged&&t.__nativeOnOverridesChanged(rx.getPreviewState())}}),rb=A(()=>{let e={profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null};"function"==typeof t.__nativeOnStateChange&&t.__nativeOnStateChange(JSON.stringify(e))}),rw=A(()=>{let e=eb.event.value;e&&"function"==typeof t.__nativeOnEventEmitted&&t.__nativeOnEventEmitted(JSON.stringify(e))})},identify(e,t,i){rm?rm.identify(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},page(e,t,i){rm?rm.page(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},screen(e,t,i){rm?rm.screen({name:e.name,properties:e.properties??{}}).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},flush(e,t){rm?rm.flush().then(()=>{e(JSON.stringify(null))}).catch(e=>{t(e instanceof Error?e.message:String(e))}):t("SDK not initialized. Call initialize() first.")},trackView(e,t,i){rm?rm.trackView(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},trackClick(e,t,i){rm?rm.trackClick(e).then(()=>{t(JSON.stringify(null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},consent(e){rm&&rm.consent(e)},reset(){rm&&(r_?.resetAll(),rm.reset())},setOnline(e){eb.online.value=e},personalizeEntry:(e,t)=>rm?JSON.stringify(rm.resolveOptimizedEntry(e,t)):JSON.stringify({entry:e}),setPreviewPanelOpen(e){rm&&(eb.previewPanelOpen.value=e)},overrideAudience(e,t,i){r_&&(t?r_.activateAudience(e,i):r_.deactivateAudience(e,i))},overrideVariant(e,t){r_?.setVariantOverride(e,t)},resetAudienceOverride(e){r_?.resetAudienceOverride(e)},resetVariantOverride(e){r_?.resetOptimizationOverride(e)},resetAllOverrides(){r_?.resetAll()},loadDefinitions(e,t){try{let i,n;for(let r of(rz=e.map(e=>{let t=e.fields;return"object"==typeof t&&null!==t?{id:t.nt_audience_id??e.sys.id,name:t.nt_name??t.nt_audience_id??e.sys.id,description:t.nt_description}:{id:e.sys.id,name:e.sys.id}}),i=new Map,t.forEach(e=>{(e.includes?.Entry??[]).forEach(e=>{i.set(e.sys.id,e)})}),rO=t.map(e=>{let t=e.fields;if("object"!=typeof t||null===t)return{id:e.sys.id,name:e.sys.id,type:"nt_personalization",distribution:[]};let{nt_config:n}=t,r=[];return n?.distribution&&n.distribution.forEach((e,t)=>{let s,o=(s=n.components?.[0],void 0===s||void 0!==s.type&&"EntryReplacement"!==s.type?"":0===t?s.baseline.id:s.variants[t-1]?.id??""),a=i.get(o);r.push({index:t,variantRef:o,percentage:Math.round(100*e),name:a?function(e){let t=e.fields;if("object"==typeof t&&null!==t)return t.internalTitle??t.title??t.name}(a):void 0})}),{id:t.nt_experience_id??e.sys.id,name:t.nt_name??e.sys.id,type:t.nt_type??"nt_personalization",distribution:r,audience:t.nt_audience?{id:t.nt_audience.sys.id}:void 0}}),n={},t.forEach(e=>{let t=e.fields;if("object"==typeof t&&null!==t){let i=t.nt_personalization_id??t.nt_experience_id??e.sys.id,{nt_name:r}=t;r&&(n[i]=r)}}),rE=n,rS={},rz))rS[r.id]=r.name;return JSON.stringify({audienceCount:rz.length,experienceCount:rO.length})}catch(e){return rz=null,rO=null,rS={},rE={},JSON.stringify({error:e instanceof Error?e.message:String(e)})}},getPreviewState(){let e=r_?.getOverrides()??{audiences:{},selectedOptimizations:{}},t=r_?.getBaselineSelectedOptimizations(),i={};for(let[t,n]of Object.entries(e.audiences))i[t]=n.isActive;let n={};for(let[t,i]of Object.entries(e.selectedOptimizations))n[t]=i.variantIndex;let r={};if(t)for(let e of t)void 0!==n[e.experienceId]&&(r[e.experienceId]=e.variantIndex);let s=rz&&rO?{...function(e){let{audienceDefinitions:t,experienceDefinitions:i,signals:n,overrides:r}=e,{profile:s,selectedOptimizations:o}=n,a=new Set(s?.audiences??[]),l={};if(o)for(let{experienceId:e,variantIndex:t}of o)l[e]=t;let u=null!=e.baselineSelectedOptimizations?Object.fromEntries(e.baselineSelectedOptimizations.map(e=>[e.experienceId,e.variantIndex])):void 0,c=new Set(t.map(e=>e.id)),d=i.filter(e=>!e.audience?.id||!c.has(e.audience.id)).map(e=>rh(e,l,r,u)),p=t.map(e=>{let t=i.filter(t=>t.audience?.id===e.id).map(e=>rh(e,l,r,u)),n=a.has(e.id),s=rd(r,e.id),o=rp(s,n);return{audience:e,experiences:t,isQualified:n,isActive:o,overrideState:s}});if(d.length>0){let e=rd(r,rc);p.push({audience:{id:rc,name:"All Visitors",description:"Experiences that apply to all visitors regardless of audience membership"},experiences:d,isQualified:!0,isActive:rp(e,!0),overrideState:e})}let h=t.length>0||i.length>0;return{audiencesWithExperiences:[...p].sort((e,t)=>e.audience.id===rc?-1:t.audience.id===rc?1:e.isActive!==t.isActive?e.isActive?-1:1:e.audience.name.localeCompare(t.audience.name,void 0,{sensitivity:"base"})),unassociatedExperiences:d,hasData:h,sdkVariantIndices:l}}({audienceDefinitions:rz,experienceDefinitions:rO,signals:{profile:eb.profile.value,selectedOptimizations:eb.selectedOptimizations.value,consent:eb.consent.value,isLoading:!1},overrides:e,baselineSelectedOptimizations:t}),audienceNameMap:rS,experienceNameMap:rE}:null;return JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null,previewPanelOpen:eb.previewPanelOpen.value,audienceOverrides:i,variantOverrides:n,defaultAudienceQualifications:r_?.getBaselineAudienceQualifications()??{},defaultVariantIndices:r,previewModel:s})},getProfile(){let e=eb.profile.value;return e?JSON.stringify(e):null},getState:()=>JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null}),destroy(){r_?.destroy(),r_=null,rz=null,rO=null,rS={},rE={},rw&&(rw(),rw=null),rb&&(rb(),rb=null),rm&&(rm.destroy(),rm=null)}};globalThis.__bridge=rx;let rk=rx;return u.default})()); +//# sourceMappingURL=optimization-android-bridge.umd.js.map \ No newline at end of file From 46a89c270d94b7c74b8747022599488d57d1e2ce Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Fri, 22 May 2026 16:50:12 +0200 Subject: [PATCH 05/66] Port the getMergeTagValue and flag bridge methods into the merged optimization-js-bridge so the JS bridge matches the iOS and Android native SDKs that already call them --- .../assets/optimization-android-bridge.umd.js | 2 +- .../Resources/optimization-ios-bridge.umd.js | 2 +- .../optimization-js-bridge/src/index.ts | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js b/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js index 2e661cdf..cb34e21c 100644 --- a/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js +++ b/packages/android/ContentfulOptimization/src/main/assets/optimization-android-bridge.umd.js @@ -1,2 +1,2 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.OptimizationBridge=t():e.OptimizationBridge=t()}(globalThis,()=>(()=>{"use strict";let e,t,i,n,r,s,o;var a,l={};l.d=(e,t)=>{for(var i in t)l.o(t,i)&&!l.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var u={};l.d(u,{default:()=>rk});var c=Symbol.for("preact-signals");function d(){if(g>1)g--;else{for(var e,t=!1;void 0!==y;){var i=y;for(y=void 0,m++;void 0!==i;){var n=i.o;if(i.o=void 0,i.f&=-3,!(8&i.f)&&O(i))try{i.c()}catch(i){t||(e=i,t=!0)}i=n}}if(m=0,g--,t)throw e}}function p(e){if(g>0)return e();g++;try{return e()}finally{d()}}var h=void 0;function f(e){var t=h;h=void 0;try{return e()}finally{h=t}}var v,y=void 0,g=0,m=0,b=0;function w(e){if(void 0!==h){var t=e.n;if(void 0===t||t.t!==h)return t={i:0,S:e,p:h.s,n:void 0,t:h,e:void 0,x:void 0,r:t},void 0!==h.s&&(h.s.n=t),h.s=t,e.n=t,32&h.f&&e.S(t),t;if(-1===t.i)return t.i=0,void 0!==t.n&&(t.n.p=t.p,void 0!==t.p&&(t.p.n=t.n),t.p=h.s,t.n=void 0,h.s.n=t,h.s=t),t}}function _(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function z(e,t){return new _(e,t)}function O(e){for(var t=e.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function S(e){for(var t=e.s;void 0!==t;t=t.n){var i=t.S.n;if(void 0!==i&&(t.r=i),t.S.n=t,t.i=-1,void 0===t.n){e.s=t;break}}}function E(e){for(var t=e.s,i=void 0;void 0!==t;){var n=t.p;-1===t.i?(t.S.U(t),void 0!==n&&(n.n=t.n),void 0!==t.n&&(t.n.p=n)):i=t,t.S.n=t.r,void 0!==t.r&&(t.r=void 0),t=n}e.s=i}function x(e,t){_.call(this,void 0),this.x=e,this.s=void 0,this.g=b-1,this.f=4,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function k(e,t){return new x(e,t)}function I(e){var t=e.u;if(e.u=void 0,"function"==typeof t){g++;var i=h;h=void 0;try{t()}catch(t){throw e.f&=-2,e.f|=8,$(e),t}finally{h=i,d()}}}function $(e){for(var t=e.s;void 0!==t;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,I(e)}function P(e){if(h!==this)throw Error("Out-of-order effect");E(this),h=e,this.f&=-2,8&this.f&&$(this),d()}function j(e,t){this.x=e,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=null==t?void 0:t.name,v&&v.push(this)}function A(e,t){var i=new j(e,t);try{i.c()}catch(e){throw i.d(),e}var n=i.d.bind(i);return n[Symbol.dispose]=n,n}function T(e){return Object.getOwnPropertySymbols(e).filter(t=>Object.prototype.propertyIsEnumerable.call(e,t))}function F(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}_.prototype.brand=c,_.prototype.h=function(){return!0},_.prototype.S=function(e){var t=this,i=this.t;i!==e&&void 0===e.e&&(e.x=i,this.t=e,void 0!==i?i.e=e:f(function(){var e;null==(e=t.W)||e.call(t)}))},_.prototype.U=function(e){var t=this;if(void 0!==this.t){var i=e.e,n=e.x;void 0!==i&&(i.x=n,e.e=void 0),void 0!==n&&(n.e=i,e.x=void 0),e===this.t&&(this.t=n,void 0===n&&f(function(){var e;null==(e=t.Z)||e.call(t)}))}},_.prototype.subscribe=function(e){var t=this;return A(function(){var i=t.value,n=h;h=void 0;try{e(i)}finally{h=n}},{name:"sub"})},_.prototype.valueOf=function(){return this.value},_.prototype.toString=function(){return this.value+""},_.prototype.toJSON=function(){return this.value},_.prototype.peek=function(){var e=h;h=void 0;try{return this.value}finally{h=e}},Object.defineProperty(_.prototype,"value",{get:function(){var e=w(this);return void 0!==e&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(m>100)throw Error("Cycle detected");this.v=e,this.i++,b++,g++;try{for(var t=this.t;void 0!==t;t=t.x)t.t.N()}finally{d()}}}}),x.prototype=new _,x.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if(32==(36&this.f)||(this.f&=-5,this.g===b))return!0;if(this.g=b,this.f|=1,this.i>0&&!O(this))return this.f&=-2,!0;var e=h;try{S(this),h=this;var t=this.x();(16&this.f||this.v!==t||0===this.i)&&(this.v=t,this.f&=-17,this.i++)}catch(e){this.v=e,this.f|=16,this.i++}return h=e,E(this),this.f&=-2,!0},x.prototype.S=function(e){if(void 0===this.t){this.f|=36;for(var t=this.s;void 0!==t;t=t.n)t.S.S(t)}_.prototype.S.call(this,e)},x.prototype.U=function(e){if(void 0!==this.t&&(_.prototype.U.call(this,e),void 0===this.t)){this.f&=-33;for(var t=this.s;void 0!==t;t=t.n)t.S.U(t)}},x.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;void 0!==e;e=e.x)e.t.N()}},Object.defineProperty(x.prototype,"value",{get:function(){if(1&this.f)throw Error("Cycle detected");var e=w(this);if(this.h(),void 0!==e&&(e.i=this.i),16&this.f)throw this.v;return this.v}}),j.prototype.c=function(){var e=this.S();try{if(8&this.f||void 0===this.x)return;var t=this.x();"function"==typeof t&&(this.u=t)}finally{e()}},j.prototype.S=function(){if(1&this.f)throw Error("Cycle detected");this.f|=1,this.f&=-9,I(this),S(this),g++;var e=h;return h=this,P.bind(this,e)},j.prototype.N=function(){2&this.f||(this.f|=2,this.o=y,y=this)},j.prototype.d=function(){this.f|=8,1&this.f||$(this)},j.prototype.dispose=function(){this.d()};let C="[object RegExp]",R="[object String]",M="[object Number]",B="[object Boolean]",q="[object Arguments]",U="[object Symbol]",V="[object Date]",N="[object Map]",D="[object Set]",Z="[object Array]",Q="[object ArrayBuffer]",L="[object Object]",J="[object DataView]",H="[object Uint8Array]",K="[object Uint8ClampedArray]",W="[object Uint16Array]",G="[object Uint32Array]",X="[object Int8Array]",Y="[object Int16Array]",ee="[object Int32Array]",et="[object Float32Array]",ei="[object Float64Array]",en="object"==typeof globalThis&&globalThis||"object"==typeof window&&window||"object"==typeof self&&self||"object"==typeof global&&global||function(){return this}()||Function("return this")();function er(e){return void 0!==en.Buffer&&en.Buffer.isBuffer(e)}function es(e,t,i,n=new Map,r){let s=r?.(e,t,i,n);if(void 0!==s)return s;if(null==e||"object"!=typeof e&&"function"!=typeof e)return e;if(n.has(e))return n.get(e);if(Array.isArray(e)){let t=Array(e.length);n.set(e,t);for(let s=0;stypeof SharedArrayBuffer&&e instanceof SharedArrayBuffer)return e.slice(0);if(e instanceof DataView){let t=new DataView(e.buffer.slice(0),e.byteOffset,e.byteLength);return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof File&&e instanceof File){let t=new File([e],e.name,{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof Blob&&e instanceof Blob){let t=new Blob([e],{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Error){let t=structuredClone(e);return n.set(e,t),t.message=e.message,t.name=e.name,t.stack=e.stack,t.cause=e.cause,t.constructor=e.constructor,eo(t,e,i,n,r),t}if(e instanceof Boolean){let t=new Boolean(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Number){let t=new Number(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof String){let t=new String(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if("object"==typeof e&&function(e){switch(F(e)){case q:case Z:case Q:case J:case B:case V:case et:case ei:case X:case Y:case ee:case N:case M:case L:case C:case D:case R:case U:case H:case K:case W:case G:return!0;default:return!1}}(e)){let t=Object.create(Object.getPrototypeOf(e));return n.set(e,t),eo(t,e,i,n,r),t}return e}function eo(e,t,i=e,n,r){let s=[...Object.keys(t),...T(t)];for(let o=0;o({unsubscribe:A(()=>{t(ea(e.value))})}),subscribeOnce(t){let i=!1,n=!1,r=()=>void 0;return r=A(()=>{if(i)return;let{value:s}=e;if(null==s)return;i=!0;let o=null;try{t(ea(s))}catch(e){o=e instanceof Error?e:Error(`Subscriber threw non-Error value: ${String(e)}`)}if(n?r():queueMicrotask(r),o)throw o}),n=!0,{unsubscribe:()=>{!i&&(i=!0,n&&r())}}}}}let eu=z(),ec=z(),ed=z(),ep=z(),eh=z(!0),ef=z(!1),ev=z(!1),ey=z(),eg=k(()=>void 0!==ey.value),em=z(),eb={blockedEvent:ec,changes:eu,consent:ed,event:ep,online:eh,previewPanelAttached:ef,previewPanelOpen:ev,selectedOptimizations:ey,canOptimize:eg,profile:em},ew={batch:p,computed:k,effect:A,untracked:f};function e_(e,t,i){function n(i,n){if(i._zod||Object.defineProperty(i,"_zod",{value:{def:n,constr:o,traits:new Set},enumerable:!1}),i._zod.traits.has(e))return;i._zod.traits.add(e),t(i,n);let r=o.prototype,s=Object.keys(r);for(let e=0;e!!i?.Parent&&t instanceof i.Parent||t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}Object.freeze({status:"aborted"}),Symbol("zod_brand");class ez extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}let eO={};function eS(e){return e&&Object.assign(eO,e),eO}function eE(e,t="|"){return e.map(e=>eU(e)).join(t)}function ex(e,t){return"bigint"==typeof t?t.toString():t}function ek(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}}}}function eI(e){let t=+!!e.startsWith("^"),i=e.endsWith("$")?e.length-1:e.length;return e.slice(t,i)}let e$=Symbol("evaluating");function eP(e,t,i){let n;Object.defineProperty(e,t,{get(){if(n!==e$)return void 0===n&&(n=e$,n=i()),n},set(i){Object.defineProperty(e,t,{value:i})},configurable:!0})}function ej(e,t,i){Object.defineProperty(e,t,{value:i,writable:!0,enumerable:!0,configurable:!0})}function eA(...e){let t={};for(let i of e)Object.assign(t,Object.getOwnPropertyDescriptors(i));return Object.defineProperties({},t)}let eT="captureStackTrace"in Error?Error.captureStackTrace:(...e)=>{};function eF(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function eC(e){if(!1===eF(e))return!1;let t=e.constructor;if(void 0===t||"function"!=typeof t)return!0;let i=t.prototype;return!1!==eF(i)&&!1!==Object.prototype.hasOwnProperty.call(i,"isPrototypeOf")}ek(()=>{if("u">typeof navigator&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{return Function(""),!0}catch(e){return!1}});let eR=new Set(["string","number","symbol"]);function eM(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function eB(e,t,i){let n=new e._zod.constr(t??e._zod.def);return(!t||i?.parent)&&(n._zod.parent=e),n}function eq(e){if(!e)return{};if("string"==typeof e)return{error:()=>e};if(e?.message!==void 0){if(e?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");e.error=e.message}return(delete e.message,"string"==typeof e.error)?{...e,error:()=>e.error}:e}function eU(e){return"bigint"==typeof e?e.toString()+"n":"string"==typeof e?`"${e}"`:`${e}`}function eV(e,t=0){if(!0===e.aborted)return!0;for(let i=t;i(t.path??(t.path=[]),t.path.unshift(e),t))}function eD(e){return"string"==typeof e?e:e?.message}function eZ(e,t,i){let n={...e,path:e.path??[]};return e.message||(n.message=eD(e.inst?._zod.def?.error?.(e))??eD(t?.error?.(e))??eD(i.customError?.(e))??eD(i.localeError?.(e))??"Invalid input"),delete n.inst,delete n.continue,t?.reportInput||delete n.input,n}function eQ(e){return Array.isArray(e)?"array":"string"==typeof e?"string":"unknown"}let eL=(e,t)=>{e.name="$ZodError",Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,ex,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},eJ=e_("$ZodError",eL),eH=e_("$ZodError",eL,{Parent:Error}),eK=(e=eH,(t,i,n,r)=>{let s=n?Object.assign(n,{async:!1}):{async:!1},o=t._zod.run({value:i,issues:[]},s);if(o instanceof Promise)throw new ez;if(o.issues.length){let t=new(r?.Err??e)(o.issues.map(e=>eZ(e,s,eS())));throw eT(t,r?.callee),t}return o.value}),eW=(t=eH,async(e,i,n,r)=>{let s=n?Object.assign(n,{async:!0}):{async:!0},o=e._zod.run({value:i,issues:[]},s);if(o instanceof Promise&&(o=await o),o.issues.length){let e=new(r?.Err??t)(o.issues.map(e=>eZ(e,s,eS())));throw eT(e,r?.callee),e}return o.value}),eG=(i=eH,(e,t,n)=>{let r=n?{...n,async:!1}:{async:!1},s=e._zod.run({value:t,issues:[]},r);if(s instanceof Promise)throw new ez;return s.issues.length?{success:!1,error:new(i??eJ)(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eX=(n=eH,async(e,t,i)=>{let r=i?Object.assign(i,{async:!0}):{async:!0},s=e._zod.run({value:t,issues:[]},r);return s instanceof Promise&&(s=await s),s.issues.length?{success:!1,error:new n(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eY=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,e0="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",e1=RegExp(`^${e0}$`);function e2(e){let t="(?:[01]\\d|2[0-3]):[0-5]\\d";return"number"==typeof e.precision?-1===e.precision?`${t}`:0===e.precision?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}let e6=/^-?\d+(?:\.\d+)?$/,e3=/^(?:true|false)$/i,e4=/^null$/i,e8=e_("$ZodCheck",(e,t)=>{var i;e._zod??(e._zod={}),e._zod.def=t,(i=e._zod).onattach??(i.onattach=[])}),e5=e_("$ZodCheckMinLength",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag.minimum??-1/0;t.minimum>i&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=i=>{let n=i.value;if(n.length>=t.minimum)return;let r=eQ(n);i.issues.push({origin:r,code:"too_small",minimum:t.minimum,inclusive:!0,input:n,inst:e,continue:!t.abort})}}),e9=e_("$ZodCheckLengthEquals",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag;i.minimum=t.length,i.maximum=t.length,i.length=t.length}),e._zod.check=i=>{let n=i.value,r=n.length;if(r===t.length)return;let s=eQ(n),o=r>t.length;i.issues.push({origin:s,...o?{code:"too_big",maximum:t.length}:{code:"too_small",minimum:t.length},inclusive:!0,exact:!0,input:i.value,inst:e,continue:!t.abort})}}),e7=e_("$ZodCheckStringFormat",(e,t)=>{var i,n;e8.init(e,t),e._zod.onattach.push(e=>{let i=e._zod.bag;i.format=t.format,t.pattern&&(i.patterns??(i.patterns=new Set),i.patterns.add(t.pattern))}),t.pattern?(i=e._zod).check??(i.check=i=>{t.pattern.lastIndex=0,t.pattern.test(i.value)||i.issues.push({origin:"string",code:"invalid_format",format:t.format,input:i.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(n=e._zod).check??(n.check=()=>{})}),te={major:4,minor:3,patch:6},tt=e_("$ZodType",(e,t)=>{var i;e??(e={}),e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=te;let n=[...e._zod.def.checks??[]];for(let t of(e._zod.traits.has("$ZodCheck")&&n.unshift(e),n))for(let i of t._zod.onattach)i(e);if(0===n.length)(i=e._zod).deferred??(i.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,i)=>{let n,r=eV(e);for(let s of t){if(s._zod.def.when){if(!s._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=s._zod.check(e);if(o instanceof Promise&&i?.async===!1)throw new ez;if(n||o instanceof Promise)n=(n??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||(r=eV(e,t)))});else{if(e.issues.length===t)continue;r||(r=eV(e,t))}}return n?n.then(()=>e):e},i=(i,r,s)=>{if(eV(i))return i.aborted=!0,i;let o=t(r,n,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(t=>e._zod.parse(t,s))}return e._zod.parse(o,s)};e._zod.run=(r,s)=>{if(s.skipChecks)return e._zod.parse(r,s);if("backward"===s.direction){let t=e._zod.parse({value:r.value,issues:[]},{...s,skipChecks:!0});return t instanceof Promise?t.then(e=>i(e,r,s)):i(t,r,s)}let o=e._zod.parse(r,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(e=>t(e,n,s))}return t(o,n,s)}}eP(e,"~standard",()=>({validate:t=>{try{let i=eG(e,t);return i.success?{value:i.data}:{issues:i.error?.issues}}catch(i){return eX(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:"zod",version:1}))}),ti=e_("$ZodString",(e,t)=>{var i;let n;tt.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??(n=(i=e._zod.bag)?`[\\s\\S]{${i?.minimum??0},${i?.maximum??""}}`:"[\\s\\S]*",RegExp(`^${n}$`)),e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=String(i.value)}catch(e){}return"string"==typeof i.value||i.issues.push({expected:"string",code:"invalid_type",input:i.value,inst:e}),i}}),tn=e_("$ZodStringFormat",(e,t)=>{e7.init(e,t),ti.init(e,t)}),tr=e_("$ZodISODateTime",(e,t)=>{let i,n,r;t.pattern??(i=e2({precision:t.precision}),n=["Z"],t.local&&n.push(""),t.offset&&n.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)"),r=`${i}(?:${n.join("|")})`,t.pattern=RegExp(`^${e0}T(?:${r})$`)),tn.init(e,t)}),ts=((e,t)=>{t.pattern??(t.pattern=e1),tn.init(e,t)},e_("$ZodNumber",(e,t)=>{tt.init(e,t),e._zod.pattern=e._zod.bag.pattern??e6,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=Number(i.value)}catch(e){}let r=i.value;if("number"==typeof r&&!Number.isNaN(r)&&Number.isFinite(r))return i;let s="number"==typeof r?Number.isNaN(r)?"NaN":Number.isFinite(r)?void 0:"Infinity":void 0;return i.issues.push({expected:"number",code:"invalid_type",input:r,inst:e,...s?{received:s}:{}}),i}})),to=e_("$ZodBoolean",(e,t)=>{tt.init(e,t),e._zod.pattern=e3,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=!!i.value}catch(e){}let r=i.value;return"boolean"==typeof r||i.issues.push({expected:"boolean",code:"invalid_type",input:r,inst:e}),i}}),ta=e_("$ZodNull",(e,t)=>{tt.init(e,t),e._zod.pattern=e4,e._zod.values=new Set([null]),e._zod.parse=(t,i)=>{let n=t.value;return null===n||t.issues.push({expected:"null",code:"invalid_type",input:n,inst:e}),t}}),tl=e_("$ZodAny",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e}),tu=e_("$ZodUnknown",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e});function tc(e,t,i){e.issues.length&&t.issues.push(...eN(i,e.issues)),t.value[i]=e.value}let td=e_("$ZodArray",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!Array.isArray(r))return i.issues.push({expected:"array",code:"invalid_type",input:r,inst:e}),i;i.value=Array(r.length);let s=[];for(let e=0;etc(t,i,e))):tc(a,i,e)}return s.length?Promise.all(s).then(()=>i):i}});function tp(e,t,i,n,r){if(e.issues.length){if(r&&!(i in n))return;t.issues.push(...eN(i,e.issues))}void 0===e.value?i in n&&(t.value[i]=void 0):t.value[i]=e.value}let th=e_("$ZodObject",(e,t)=>{let i;tt.init(e,t);let n=Object.getOwnPropertyDescriptor(t,"shape");if(!n?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let i={...e};return Object.defineProperty(t,"shape",{value:i}),i}})}let r=ek(()=>(function(e){var t;let i=Object.keys(e.shape);for(let t of i)if(!e.shape?.[t]?._zod?.traits?.has("$ZodType"))throw Error(`Invalid element at key "${t}": expected a Zod schema`);let n=Object.keys(t=e.shape).filter(e=>"optional"===t[e]._zod.optin&&"optional"===t[e]._zod.optout);return{...e,keys:i,keySet:new Set(i),numKeys:i.length,optionalKeys:new Set(n)}})(t));eP(e._zod,"propValues",()=>{let e=t.shape,i={};for(let t in e){let n=e[t]._zod;if(n.values)for(let e of(i[t]??(i[t]=new Set),n.values))i[t].add(e)}return i});let s=t.catchall;e._zod.parse=(t,n)=>{i??(i=r.value);let o=t.value;if(!eF(o))return t.issues.push({expected:"object",code:"invalid_type",input:o,inst:e}),t;t.value={};let a=[],l=i.shape;for(let e of i.keys){let i=l[e],r="optional"===i._zod.optout,s=i._zod.run({value:o[e],issues:[]},n);s instanceof Promise?a.push(s.then(i=>tp(i,t,e,o,r))):tp(s,t,e,o,r)}return s?function(e,t,i,n,r,s){let o=[],a=r.keySet,l=r.catchall._zod,u=l.def.type,c="optional"===l.optout;for(let r in t){if(a.has(r))continue;if("never"===u){o.push(r);continue}let s=l.run({value:t[r],issues:[]},n);s instanceof Promise?e.push(s.then(e=>tp(e,i,r,t,c))):tp(s,i,r,t,c)}return(o.length&&i.issues.push({code:"unrecognized_keys",keys:o,input:t,inst:s}),e.length)?Promise.all(e).then(()=>i):i}(a,o,t,n,r.value,e):a.length?Promise.all(a).then(()=>t):t}});function tf(e,t,i,n){for(let i of e)if(0===i.issues.length)return t.value=i.value,t;let r=e.filter(e=>!eV(e));return 1===r.length?(t.value=r[0].value,r[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:i,errors:e.map(e=>e.issues.map(e=>eZ(e,n,eS())))}),t)}let tv=e_("$ZodUnion",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.options.some(e=>"optional"===e._zod.optin)?"optional":void 0),eP(e._zod,"optout",()=>t.options.some(e=>"optional"===e._zod.optout)?"optional":void 0),eP(e._zod,"values",()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),eP(e._zod,"pattern",()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>eI(e.source)).join("|")})$`)}});let i=1===t.options.length,n=t.options[0]._zod.run;e._zod.parse=(r,s)=>{if(i)return n(r,s);let o=!1,a=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},s);if(t instanceof Promise)a.push(t),o=!0;else{if(0===t.issues.length)return t;a.push(t)}}return o?Promise.all(a).then(t=>tf(t,r,e,s)):tf(a,r,e,s)}}),ty=e_("$ZodDiscriminatedUnion",(e,t)=>{t.inclusive=!1,tv.init(e,t);let i=e._zod.parse;eP(e._zod,"propValues",()=>{let e={};for(let i of t.options){let n=i._zod.propValues;if(!n||0===Object.keys(n).length)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(i)}"`);for(let[t,i]of Object.entries(n))for(let n of(e[t]||(e[t]=new Set),i))e[t].add(n)}return e});let n=ek(()=>{let e=t.options,i=new Map;for(let n of e){let e=n._zod.propValues?.[t.discriminator];if(!e||0===e.size)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(n)}"`);for(let t of e){if(i.has(t))throw Error(`Duplicate discriminator value "${String(t)}"`);i.set(t,n)}}return i});e._zod.parse=(r,s)=>{let o=r.value;if(!eF(o))return r.issues.push({code:"invalid_type",expected:"object",input:o,inst:e}),r;let a=n.value.get(o?.[t.discriminator]);return a?a._zod.run(r,s):t.unionFallback?i(r,s):(r.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:t.discriminator,input:o,path:[t.discriminator],inst:e}),r)}}),tg=e_("$ZodRecord",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!eC(r))return i.issues.push({expected:"record",code:"invalid_type",input:r,inst:e}),i;let s=[],o=t.keyType._zod.values;if(o){let a;i.value={};let l=new Set;for(let e of o)if("string"==typeof e||"number"==typeof e||"symbol"==typeof e){l.add("number"==typeof e?e.toString():e);let o=t.valueType._zod.run({value:r[e],issues:[]},n);o instanceof Promise?s.push(o.then(t=>{t.issues.length&&i.issues.push(...eN(e,t.issues)),i.value[e]=t.value})):(o.issues.length&&i.issues.push(...eN(e,o.issues)),i.value[e]=o.value)}for(let e in r)l.has(e)||(a=a??[]).push(e);a&&a.length>0&&i.issues.push({code:"unrecognized_keys",input:r,inst:e,keys:a})}else for(let o of(i.value={},Reflect.ownKeys(r))){if("__proto__"===o)continue;let a=t.keyType._zod.run({value:o,issues:[]},n);if(a instanceof Promise)throw Error("Async schemas not supported in object keys currently");if("string"==typeof o&&e6.test(o)&&a.issues.length){let e=t.keyType._zod.run({value:Number(o),issues:[]},n);if(e instanceof Promise)throw Error("Async schemas not supported in object keys currently");0===e.issues.length&&(a=e)}if(a.issues.length){"loose"===t.mode?i.value[o]=r[o]:i.issues.push({code:"invalid_key",origin:"record",issues:a.issues.map(e=>eZ(e,n,eS())),input:o,path:[o],inst:e});continue}let l=t.valueType._zod.run({value:r[o],issues:[]},n);l instanceof Promise?s.push(l.then(e=>{e.issues.length&&i.issues.push(...eN(o,e.issues)),i.value[a.value]=e.value})):(l.issues.length&&i.issues.push(...eN(o,l.issues)),i.value[a.value]=l.value)}return s.length?Promise.all(s).then(()=>i):i}}),tm=e_("$ZodEnum",(e,t)=>{var i;let n;tt.init(e,t);let r=(n=Object.values(i=t.entries).filter(e=>"number"==typeof e),Object.entries(i).filter(([e,t])=>-1===n.indexOf(+e)).map(([e,t])=>t)),s=new Set(r);e._zod.values=s,e._zod.pattern=RegExp(`^(${r.filter(e=>eR.has(typeof e)).map(e=>"string"==typeof e?eM(e):e.toString()).join("|")})$`),e._zod.parse=(t,i)=>{let n=t.value;return s.has(n)||t.issues.push({code:"invalid_value",values:r,input:n,inst:e}),t}}),tb=e_("$ZodLiteral",(e,t)=>{if(tt.init(e,t),0===t.values.length)throw Error("Cannot create literal schema with no valid values");let i=new Set(t.values);e._zod.values=i,e._zod.pattern=RegExp(`^(${t.values.map(e=>"string"==typeof e?eM(e):e?eM(e.toString()):String(e)).join("|")})$`),e._zod.parse=(n,r)=>{let s=n.value;return i.has(s)||n.issues.push({code:"invalid_value",values:t.values,input:s,inst:e}),n}});function tw(e,t){return e.issues.length&&void 0===t?{issues:[],value:void 0}:e}let t_=e_("$ZodOptional",(e,t)=>{tt.init(e,t),e._zod.optin="optional",e._zod.optout="optional",eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)})?$`):void 0}),e._zod.parse=(e,i)=>{if("optional"===t.innerType._zod.optin){let n=t.innerType._zod.run(e,i);return n instanceof Promise?n.then(t=>tw(t,e.value)):tw(n,e.value)}return void 0===e.value?e:t.innerType._zod.run(e,i)}}),tz=e_("$ZodNullable",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.innerType._zod.optin),eP(e._zod,"optout",()=>t.innerType._zod.optout),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)}|null)$`):void 0}),eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,i)=>null===e.value?e:t.innerType._zod.run(e,i)}),tO=e_("$ZodPrefault",(e,t)=>{tt.init(e,t),e._zod.optin="optional",eP(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(e,i)=>("backward"===i.direction||void 0===e.value&&(e.value=t.defaultValue),t.innerType._zod.run(e,i))}),tS=e_("$ZodLazy",(e,t)=>{tt.init(e,t),eP(e._zod,"innerType",()=>t.getter()),eP(e._zod,"pattern",()=>e._zod.innerType?._zod?.pattern),eP(e._zod,"propValues",()=>e._zod.innerType?._zod?.propValues),eP(e._zod,"optin",()=>e._zod.innerType?._zod?.optin??void 0),eP(e._zod,"optout",()=>e._zod.innerType?._zod?.optout??void 0),e._zod.parse=(t,i)=>e._zod.innerType._zod.run(t,i)});Symbol("ZodOutput"),Symbol("ZodInput");function tE(e,t){return new e5({check:"min_length",...eq(t),minimum:e})}(a=globalThis).__zod_globalRegistry??(a.__zod_globalRegistry=new class e{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let i=t[0];return this._map.set(e,i),i&&"object"==typeof i&&"id"in i&&this._idmap.set(i.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&"object"==typeof t&&"id"in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let i={...this.get(t)??{}};delete i.id;let n={...i,...this._map.get(e)};return Object.keys(n).length?n:void 0}return this._map.get(e)}has(e){return this._map.has(e)}});let tx=e_("ZodMiniType",(e,t)=>{if(!e._zod)throw Error("Uninitialized schema in ZodMiniType.");tt.init(e,t),e.def=t,e.type=t.type,e.parse=(t,i)=>eK(e,t,i,{callee:e.parse}),e.safeParse=(t,i)=>eG(e,t,i),e.parseAsync=async(t,i)=>eW(e,t,i,{callee:e.parseAsync}),e.safeParseAsync=async(t,i)=>eX(e,t,i),e.check=(...i)=>e.clone({...t,checks:[...t.checks??[],...i.map(e=>"function"==typeof e?{_zod:{check:e,def:{check:"custom"},onattach:[]}}:e)]},{parent:!0}),e.with=e.check,e.clone=(t,i)=>eB(e,t,i),e.brand=()=>e,e.register=(t,i)=>(t.add(e,i),e),e.apply=t=>t(e)}),tk=e_("ZodMiniString",(e,t)=>{ti.init(e,t),tx.init(e,t)});function tI(e){return new tk({type:"string",...eq(e)})}let t$=e_("ZodMiniStringFormat",(e,t)=>{tn.init(e,t),tk.init(e,t)}),tP=e_("ZodMiniNumber",(e,t)=>{ts.init(e,t),tx.init(e,t)});function tj(e){return new tP({type:"number",checks:[],...eq(e)})}let tA=e_("ZodMiniBoolean",(e,t)=>{to.init(e,t),tx.init(e,t)});function tT(e){return new tA({type:"boolean",...eq(e)})}let tF=e_("ZodMiniNull",(e,t)=>{ta.init(e,t),tx.init(e,t)});function tC(e){return new tF({type:"null",...eq(e)})}let tR=e_("ZodMiniAny",(e,t)=>{tl.init(e,t),tx.init(e,t)});function tM(){return new tR({type:"any"})}let tB=e_("ZodMiniUnknown",(e,t)=>{tu.init(e,t),tx.init(e,t)}),tq=e_("ZodMiniArray",(e,t)=>{td.init(e,t),tx.init(e,t)});function tU(e,t){return new tq({type:"array",element:e,...eq(t)})}let tV=e_("ZodMiniObject",(e,t)=>{th.init(e,t),tx.init(e,t),eP(e,"shape",()=>t.shape)});function tN(e,t){return new tV({type:"object",shape:e??{},...eq(t)})}function tD(e,t){if(!eC(t))throw Error("Invalid input to extend: expected a plain object");let i=e._zod.def.checks;if(i&&i.length>0){let i=e._zod.def.shape;for(let e in t)if(void 0!==Object.getOwnPropertyDescriptor(i,e))throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}let n=eA(e._zod.def,{get shape(){let i={...e._zod.def.shape,...t};return ej(this,"shape",i),i}});return eB(e,n)}function tZ(e,t){return e.clone({...e._zod.def,catchall:t})}let tQ=e_("ZodMiniUnion",(e,t)=>{tv.init(e,t),tx.init(e,t)});function tL(e,t){return new tQ({type:"union",options:e,...eq(t)})}let tJ=e_("ZodMiniDiscriminatedUnion",(e,t)=>{ty.init(e,t),tx.init(e,t)});function tH(e,t,i){return new tJ({type:"union",options:t,discriminator:e,...eq(i)})}let tK=e_("ZodMiniRecord",(e,t)=>{tg.init(e,t),tx.init(e,t)});function tW(e,t,i){return new tK({type:"record",keyType:e,valueType:t,...eq(i)})}let tG=e_("ZodMiniEnum",(e,t)=>{tm.init(e,t),tx.init(e,t),e.options=Object.values(t.entries)});function tX(e,t){return new tG({type:"enum",entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...eq(t)})}let tY=e_("ZodMiniLiteral",(e,t)=>{tb.init(e,t),tx.init(e,t)});function t0(e,t){return new tY({type:"literal",values:Array.isArray(e)?e:[e],...eq(t)})}let t1=e_("ZodMiniOptional",(e,t)=>{t_.init(e,t),tx.init(e,t)});function t2(e){return new t1({type:"optional",innerType:e})}let t6=e_("ZodMiniNullable",(e,t)=>{tz.init(e,t),tx.init(e,t)});function t3(e){return new t6({type:"nullable",innerType:e})}let t4=e_("ZodMiniPrefault",(e,t)=>{tO.init(e,t),tx.init(e,t)});function t8(e,t){return new t4({type:"prefault",innerType:e,get defaultValue(){return"function"==typeof t?t():eC(t)?{...t}:Array.isArray(t)?[...t]:t}})}let t5=e_("ZodMiniLazy",(e,t)=>{tS.init(e,t),tx.init(e,t)});function t9(){let e=new t5({type:"lazy",getter:()=>tL([tI(),tj(),tT(),tC(),tU(e),tW(tI(),e)])});return e}let t7=e_("ZodMiniISODateTime",(e,t)=>{tr.init(e,t),t$.init(e,t)});function ie(e){return new t7({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...eq(e)})}let it=tZ(tN({}),t9()),ii=tN({sys:tN({type:t0("Link"),linkType:tI(),id:tI()})}),ir=tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:tI()})}),is=tN({sys:tN({type:t0("Link"),linkType:t0("Environment"),id:tI()})}),io=tN({sys:tN({type:t0("Link"),linkType:t0("Space"),id:tI()})}),ia=tN({sys:tN({type:t0("Link"),linkType:t0("TaxonomyConcept"),id:tI()})}),il=tN({sys:tN({type:t0("Link"),linkType:t0("Tag"),id:tI()})}),iu=tN({type:t0("Entry"),contentType:ir,publishedVersion:tj(),id:tI(),createdAt:tM(),updatedAt:tM(),locale:t2(tI()),revision:tj(),space:io,environment:is}),ic=tN({fields:it,metadata:tN({tags:tU(il),concepts:t2(tU(ia))}),sys:iu}),id=tD(it,{nt_audience_id:tI(),nt_name:t2(tI()),nt_description:t2(tI())}),ip=tD(ic,{fields:id});tN({contentTypeId:t0("nt_audience"),fields:id});let ih=tD(ic,{fields:tN({nt_name:tI(),nt_fallback:t2(tI()),nt_mergetag_id:tI()}),sys:tD(iu,{contentType:tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:t0("nt_mergetag")})})})}),iv=tN({id:tI(),hidden:t2(tT())}),iy=tN({type:t2(t0("EntryReplacement")),baseline:iv,variants:tU(iv)}),ig=tN({value:tL([tI(),tT(),tC(),tj(),tW(tI(),t9())])}),im=tX(["Boolean","Number","Object","String"]),ib=tH("type",[iy,tN({type:t0("InlineVariable"),key:tI(),valueType:im,baseline:ig,variants:tU(ig)})]),iw=tU(ib),i_=tN({distribution:t2(tU(tj())),traffic:t2(tj()),components:t2(iw),sticky:t2(tT())}),iz=tL([t0("nt_experiment"),t0("nt_personalization")]),iO=tD(it,{nt_name:tI(),nt_description:t2(t3(tI())),nt_type:iz,nt_config:t2(t3(i_)),nt_audience:t2(t3(ip)),nt_variants:t2(tU(tL([ii,ic]))),nt_experience_id:tI()}),iS=tD(ic,{fields:iO});tN({contentTypeId:t0("nt_experience"),fields:iO});let iE=tD(ic,{fields:tD(it,{nt_experiences:tU(tL([ii,iS]))})});function ix(e){return iS.safeParse(e).success}function ik(e){return iE.safeParse(e).success}let iI=t2(tN({name:tI(),version:tI()})),i$=tN({name:t2(tI()),source:t2(tI()),medium:t2(tI()),term:t2(tI()),content:t2(tI())}),iP=tL([t0("mobile"),t0("server"),t0("web")]),ij=tW(tI(),tI()),iA=tN({latitude:tj(),longitude:tj()}),iT=tN({coordinates:t2(iA),city:t2(tI()),postalCode:t2(tI()),region:t2(tI()),regionCode:t2(tI()),country:t2(tI()),countryCode:t2(tI().check(new e9({check:"length_equals",...eq(void 0),length:2}))),continent:t2(tI()),timezone:t2(tI())}),iF=tN({name:tI(),version:tI()}),iC=tZ(tN({path:tI(),query:ij,referrer:tI(),search:tI(),title:t2(tI()),url:tI()}),t9()),iR=tW(tI(),t9()),iM=tZ(tN({name:tI()}),t9()),iB=tW(tI(),t9()),iq=tN({app:iI,campaign:i$,gdpr:tN({isConsentGiven:tT()}),library:iF,locale:tI(),location:t2(iT),userAgent:t2(tI())}),iU=tN({channel:iP,context:tD(iq,{page:t2(iC),screen:t2(iM)}),messageId:tI(),originalTimestamp:ie(),sentAt:ie(),timestamp:ie(),userId:t2(tI())}),iV=tD(iU,{type:t0("alias")}),iN=tD(iU,{type:t0("group")}),iD=tD(iU,{type:t0("identify"),traits:iB}),iZ=tD(iq,{page:iC}),iQ=tD(iU,{type:t0("page"),name:t2(tI()),properties:iC,context:iZ}),iL=tD(iq,{screen:iM}),iJ=tD(iU,{type:t0("screen"),name:tI(),properties:t2(iR),context:iL}),iH=tD(iU,{type:t0("track"),event:tI(),properties:iR}),iK=tD(iU,{componentType:tL([t0("Entry"),t0("Variable")]),componentId:tI(),experienceId:t2(tI()),variantIndex:tj()}),iW=tD(iK,{type:t0("component"),viewDurationMs:t2(tj()),viewId:t2(tI())}),iG={anonymousId:tI()},iX=tU(tH("type",[tD(iV,iG),tD(iW,iG),tD(iN,iG),tD(iD,iG),tD(iQ,iG),tD(iJ,iG),tD(iH,iG)])),iY=tH("type",[iV,iW,iN,iD,iQ,iJ,iH]),i0=tU(iY),i1=tN({features:t2(tU(tI()))}),i2=tN({events:i0.check(tE(1)),options:t2(i1)}),i6=tN({events:iX.check(tE(1)),options:t2(i1)}),i3=tN({id:tI(),isReturningVisitor:tT(),landingPage:iC,count:tj(),activeSessionLength:tj(),averageSessionLength:tj()}),i4=tN({id:tI(),stableId:tI(),random:tj(),audiences:tU(tI()),traits:iB,location:iT,session:i3}),i8=tZ(tN({id:tI()}),t9()),i5=tN({data:tN(),message:tI(),error:t3(tT())}),i9=tD(i5,{data:tN({profiles:t2(tU(i4))})}),i7=tN({key:tI(),type:tL([tX(["Variable"]),tI()]),meta:tN({experienceId:tI(),variantIndex:tj()})}),ne=tL([tI(),tT(),tC(),tj(),tW(tI(),t9())]);tD(i7,{type:tI(),value:new tB({type:"unknown"})});let nt=tU(tH("type",[tD(i7,{type:t0("Variable"),value:ne})])),ni=tU(tN({experienceId:tI(),variantIndex:tj(),variants:tW(tI(),tI()),sticky:t2(t8(tT(),!1))})),nn=tD(i5,{data:tN({profile:i4,experiences:ni,changes:nt})}),nr=tH("type",[iW,tD(iK,{type:t0("component_click")}),tD(iK,{type:t0("component_hover"),hoverDurationMs:tj(),hoverId:tI()})]),ns=tN({profile:i8,events:tU(nr)}),no=tU(ns);function na(e,t){let i=e.safeParse(t);if(i.success)return i.data;throw Error(function(e){let t=[];for(let i of[...e.issues].sort((e,t)=>(e.path??[]).length-(t.path??[]).length))t.push(`✖ ${i.message}`),i.path?.length&&t.push(` → at ${function(e){let t=[];for(let i of e.map(e=>"object"==typeof e?e.key:e))"number"==typeof i?t.push(`[${i}]`):"symbol"==typeof i?t.push(`[${JSON.stringify(String(i))}]`):/[^\w$]/.test(i)?t.push(`[${JSON.stringify(i)}]`):(t.length&&t.push("."),t.push(i));return t.join("")}(i.path)}`);return t.join("\n")}(i.error))}eS({localeError:(r={string:{unit:"characters",verb:"to have"},file:{unit:"bytes",verb:"to have"},array:{unit:"items",verb:"to have"},set:{unit:"items",verb:"to have"},map:{unit:"entries",verb:"to have"}},s={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",mac:"MAC address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},o={nan:"NaN"},e=>{switch(e.code){case"invalid_type":{let t=o[e.expected]??e.expected,i=function(e){let t=typeof e;switch(t){case"number":return Number.isNaN(e)?"nan":"number";case"object":if(null===e)return"null";if(Array.isArray(e))return"array";if(e&&Object.getPrototypeOf(e)!==Object.prototype&&"constructor"in e&&e.constructor)return e.constructor.name}return t}(e.input),n=o[i]??i;return`Invalid input: expected ${t}, received ${n}`}case"invalid_value":if(1===e.values.length)return`Invalid input: expected ${eU(e.values[0])}`;return`Invalid option: expected one of ${eE(e.values,"|")}`;case"too_big":{let t=e.inclusive?"<=":"<",i=r[e.origin]??null;if(i)return`Too big: expected ${e.origin??"value"} to have ${t}${e.maximum.toString()} ${i.unit??"elements"}`;return`Too big: expected ${e.origin??"value"} to be ${t}${e.maximum.toString()}`}case"too_small":{let t=e.inclusive?">=":">",i=r[e.origin]??null;if(i)return`Too small: expected ${e.origin} to have ${t}${e.minimum.toString()} ${i.unit}`;return`Too small: expected ${e.origin} to be ${t}${e.minimum.toString()}`}case"invalid_format":if("starts_with"===e.format)return`Invalid string: must start with "${e.prefix}"`;if("ends_with"===e.format)return`Invalid string: must end with "${e.suffix}"`;if("includes"===e.format)return`Invalid string: must include "${e.includes}"`;if("regex"===e.format)return`Invalid string: must match pattern ${e.pattern}`;return`Invalid ${s[e.format]??e.format}`;case"not_multiple_of":return`Invalid number: must be a multiple of ${e.divisor}`;case"unrecognized_keys":return`Unrecognized key${e.keys.length>1?"s":""}: ${eE(e.keys,", ")}`;case"invalid_key":return`Invalid key in ${e.origin}`;case"invalid_union":default:return"Invalid input";case"invalid_element":return`Invalid value in ${e.origin}`}})});let nl=new class{name="@contentful/optimization";PREFIX_PARTS=["Ctfl","O10n"];DELIMITER=":";sinks=[];assembleLocationPrefix(e){return`[${[...this.PREFIX_PARTS,e].join(this.DELIMITER)}]`}addSink(e){this.sinks=[...this.sinks.filter(t=>t.name!==e.name),e]}removeSink(e){this.sinks=this.sinks.filter(t=>t.name!==e)}removeSinks(){this.sinks=[]}debug(e,t,...i){this.emit("debug",e,t,...i)}info(e,t,...i){this.emit("info",e,t,...i)}log(e,t,...i){this.emit("log",e,t,...i)}warn(e,t,...i){this.emit("warn",e,t,...i)}error(e,t,...i){this.emit("error",e,t,...i)}fatal(e,t,...i){this.emit("fatal",e,t,...i)}emit(e,t,i,...n){this.onLogEvent({name:this.name,level:e,messages:[`${this.assembleLocationPrefix(t)} ${String(i)}`,...n]})}onLogEvent(e){this.sinks.forEach(t=>{t.ingest(e)})}};function nu(e){return{debug:(t,...i)=>{nl.debug(e,t,...i)},info:(t,...i)=>{nl.info(e,t,...i)},log:(t,...i)=>{nl.log(e,t,...i)},warn:(t,...i)=>{nl.warn(e,t,...i)},error:(t,...i)=>{nl.error(e,t,...i)},fatal:(t,...i)=>{nl.fatal(e,t,...i)}}}let nc={fatal:60,error:50,warn:40,info:30,debug:20,log:10},nd=class{},np={debug:(...e)=>{console.debug(...e)},info:(...e)=>{console.info(...e)},log:(...e)=>{console.log(...e)},warn:(...e)=>{console.warn(...e)},error:(...e)=>{console.error(...e)},fatal:(...e)=>{console.error(...e)}};class nh extends nd{name="ConsoleLogSink";verbosity;constructor(e){super(),this.verbosity=e??"error"}ingest(e){nc[e.level]{i(void 0)},e),await t}let ng=nu("ApiClient:Timeout"),nm=nu("ApiClient:Fetch"),nb=function(e){try{let t=function({apiName:e="Optimization",fetchMethod:t=fetch,onRequestTimeout:i,requestTimeout:n=3e3}={}){return async(r,s)=>{let o=new AbortController,a=setTimeout(()=>{"function"==typeof i?i({apiName:e}):ng.error(`Request to "${r.toString()}" timed out`,Error("Request timeout")),o.abort()},n),l=await t(r,{...s,signal:o.signal});return clearTimeout(a),l}}(e);return function({apiName:e="Optimization",fetchMethod:t=fetch,intervalTimeout:i=0,onFailedAttempt:n,retries:r=1}={}){return async(s,o)=>{let a=new AbortController,l=r+1,u=function({apiName:e="Optimization",controller:t,fetchMethod:i=fetch,init:n,url:r}){return async()=>{try{let s=await i(r,n);if(503===s.status)throw new nv(`${e} API request to "${r.toString()}" failed with status: "[${s.status}] ${s.statusText}".`,503);if(!s.ok){let e=Error(`Request to "${r.toString()}" failed with status: [${s.status}] ${s.statusText} - traceparent: ${s.headers.get("traceparent")}`);nf.error("Request failed with non-OK status:",e),t.abort();return}return nf.debug(`Response from "${r.toString()}":`,s),s}catch(e){if(e instanceof nv&&503===e.status)throw e;nf.error(`Request to "${r.toString()}" failed:`,e),t.abort()}}}({apiName:e,controller:a,fetchMethod:t,init:o,url:s});for(let t=1;t<=l;t++)try{let e=await u();if(e)return e;break}catch(s){if(!(s instanceof nv)||503!==s.status)throw s;let r=l-t;if(n?.({apiName:e,error:s,attemptNumber:t,retriesLeft:r}),0===r)throw s;await ny(i)}throw Error(`${e} API request to "${s.toString()}" may not be retried.`)}}({...e,fetchMethod:t})}catch(e){throw e instanceof Error&&("AbortError"===e.name?nm.warn("Request aborted due to network issues. This request may not be retried."):nm.error("Request failed:",e)),e}},nw=nu("ApiClient"),n_=class{name;clientId;environment;fetch;constructor(e,{fetchOptions:t,clientId:i,environment:n}){this.clientId=i,this.environment=n??"main",this.name=e,this.fetch=nb({...t??{},apiName:e})}logRequestError(e,{requestName:t}){e instanceof Error&&("AbortError"===e.name?nw.warn(`[${this.name}] "${t}" request aborted due to network issues. This request may not be retried.`):nw.error(`[${this.name}] "${t}" request failed:`,e))}},nz=nu("ApiClient:Experience");class nO extends n_{baseUrl;enabledFeatures;ip;locale;plainText;preflight;constructor(e){super("Experience",e);const{baseUrl:t,enabledFeatures:i,ip:n,locale:r,plainText:s,preflight:o}=e;this.baseUrl=t||"https://experience.ninetailed.co/",this.enabledFeatures=i,this.ip=n,this.locale=r,this.plainText=s,this.preflight=o}async getProfile(e,t={}){if(!e)throw Error("Valid profile ID required.");let i="Get Profile";nz.info(`Sending "${i}" request`);try{let n=await this.fetch(this.constructUrl(`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,t),{method:"GET"}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await n.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async makeProfileMutationRequest({url:e,body:t,options:i}){return await this.fetch(this.constructUrl(e,i),{method:"POST",headers:this.constructHeaders(i),body:JSON.stringify(t),keepalive:!0})}async createProfile({events:e},t={}){let i="Create Profile";nz.info(`Sending "${i}" request`);let n=this.constructExperienceRequestBody(e,t);nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles`,body:n,options:t}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await e.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async updateProfile({profileId:e,events:t},i={}){if(!e)throw Error("Valid profile ID required.");let n="Update Profile";nz.info(`Sending "${n}" request`);let r=this.constructExperienceRequestBody(t,i);nz.debug(`"${n}" request body:`,r);try{let t=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,body:r,options:i}),{data:{changes:s,experiences:o,profile:a}}=na(nn,await t.json());return nz.debug(`"${n}" request successfully completed`),{changes:s,selectedOptimizations:o,profile:a}}catch(e){throw this.logRequestError(e,{requestName:n}),e}}async upsertProfile({profileId:e,events:t},i){return e?await this.updateProfile({profileId:e,events:t},i):await this.createProfile({events:t},i)}async upsertManyProfiles({events:e},t={}){let i="Upsert Many Profiles";nz.info(`Sending "${i}" request`);let n=na(i6,{events:e,options:this.constructBodyOptions(t)});nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/events`,body:n,options:{plainText:!1,...t}}),{data:{profiles:r}}=na(i9,await e.json());return nz.debug(`"${i}" request successfully completed`),r}catch(e){throw this.logRequestError(e,{requestName:i}),e}}constructUrl(e,t){let i=new URL(e,this.baseUrl),n=t.locale??this.locale,r=t.preflight??this.preflight;return n&&i.searchParams.set("locale",n),r&&i.searchParams.set("type","preflight"),i.toString()}constructHeaders({ip:e=this.ip,plainText:t=this.plainText}){let i=new Map;return e&&i.set("X-Force-IP",e),t??this.plainText??!0?i.set("Content-Type","text/plain"):i.set("Content-Type","application/json"),Object.fromEntries(i)}constructBodyOptions=({enabledFeatures:e=this.enabledFeatures})=>{let t={};return e&&Array.isArray(e)&&e.length>0?t.features=e:t.features=["ip-enrichment","location"],t};constructExperienceRequestBody(e,t){return i2.parse({events:na(i0,e),options:this.constructBodyOptions(t)})}}let nS=nu("ApiClient:Insights");class nE extends n_{baseUrl;beaconHandler;constructor(e){super("Insights",e);const{baseUrl:t,beaconHandler:i}=e;this.baseUrl=t||"https://ingest.insights.ninetailed.co/",this.beaconHandler=i}async sendBatchEvents(e,t={}){let{beaconHandler:i=this.beaconHandler}=t,n=new URL(`v1/organizations/${this.clientId}/environments/${this.environment}/events`,this.baseUrl),r=na(no,e);if("function"==typeof i){if(nS.debug("Queueing events via beaconHandler"),i(n,r))return!0;nS.warn("beaconHandler failed to queue events; events will be emitted immediately via fetch")}let s="Event Batches";nS.info(`Sending "${s}" request`),nS.debug(`"${s}" request body:`,r);try{return await this.fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r),keepalive:!0}),nS.debug(`"${s}" request successfully completed`),!0}catch(e){return this.logRequestError(e,{requestName:s}),!1}}}class nx{config;experience;insights;constructor(e){const{experience:t,insights:i,clientId:n,environment:r,fetchOptions:s}=e,o={clientId:n,environment:r,fetchOptions:s};this.config=o,this.experience=new nO({...o,...t}),this.insights=new nE({...o,...i})}}function nk(e){if(!e||"object"!=typeof e)return!1;let t=Object.getPrototypeOf(e);return(null===t||t===Object.prototype||null===Object.getPrototypeOf(t))&&"[object Object]"===Object.prototype.toString.call(e)}function nI(e){return nk(e)||Array.isArray(e)}let n$=tN({campaign:t2(i$),locale:t2(tI()),location:t2(iT),page:t2(iC),screen:t2(iM),userAgent:t2(tI())}),nP=tD(n$,{componentId:tI(),experienceId:t2(tI()),variantIndex:t2(tj())}),nj=tD(nP,{sticky:t2(tT()),viewId:tI(),viewDurationMs:tj()}),nA=tD(nP,{viewId:t2(tI()),viewDurationMs:t2(tj())}),nT=tD(nP,{hoverId:tI(),hoverDurationMs:tj()}),nF=tD(n$,{traits:t2(iB),userId:tI()}),nC=tD(n$,{properties:t2(function(e,t){var i=void 0;let n=e._zod.def.checks;if(n&&n.length>0)throw Error(".partial() cannot be used on object schemas containing refinements");let r=eA(e._zod.def,{get shape(){let t=e._zod.def.shape,n={...t};if(i)for(let e in i){if(!(e in t))throw Error(`Unrecognized key: "${e}"`);i[e]&&(n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e])}else for(let e in t)n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e];return ej(this,"shape",n),n},checks:[]});return eB(e,r)}(iC))}),nR=tD(n$,{name:tI(),properties:iR}),nM=tD(n$,{event:tI(),properties:t2(t8(iR,{}))}),nB={path:"",query:{},referrer:"",search:"",title:"",url:""},nq=class{app;channel;library;getLocale;getPageProperties;getUserAgent;constructor(e){const{app:t,channel:i,library:n,getLocale:r,getPageProperties:s,getUserAgent:o}=e;this.app=t,this.channel=i,this.library=n,this.getLocale=r??(()=>"en-US"),this.getPageProperties=s??(()=>nB),this.getUserAgent=o??(()=>void 0)}buildUniversalEventProperties({campaign:e={},locale:t,location:i,page:n,screen:r,userAgent:s}){let o=new Date().toISOString();return{channel:this.channel,context:{app:this.app,campaign:e,gdpr:{isConsentGiven:!0},library:this.library,locale:t??this.getLocale()??"en-US",location:i,page:n??this.getPageProperties(),screen:r,userAgent:s??this.getUserAgent()},messageId:crypto.randomUUID(),originalTimestamp:o,sentAt:o,timestamp:o}}buildEntryInteractionBase(e,t,i,n){return{...this.buildUniversalEventProperties(e),componentType:"Entry",componentId:t,experienceId:i,variantIndex:n??0}}buildView(e){let{componentId:t,viewId:i,experienceId:n,variantIndex:r,viewDurationMs:s,...o}=na(nj,e);return{...this.buildEntryInteractionBase(o,t,n,r),type:"component",viewId:i,viewDurationMs:s}}buildClick(e){let{componentId:t,experienceId:i,variantIndex:n,...r}=na(nP,e);return{...this.buildEntryInteractionBase(r,t,i,n),type:"component_click"}}buildHover(e){let{hoverId:t,componentId:i,experienceId:n,hoverDurationMs:r,variantIndex:s,...o}=na(nT,e);return{...this.buildEntryInteractionBase(o,i,n,s),type:"component_hover",hoverId:t,hoverDurationMs:r}}buildFlagView(e){let{componentId:t,experienceId:i,variantIndex:n,viewId:r,viewDurationMs:s,...o}=na(nA,e);return{...this.buildEntryInteractionBase(o,t,i,n),...void 0===s?{}:{viewDurationMs:s},...void 0===r?{}:{viewId:r},type:"component",componentType:"Variable"}}buildIdentify(e){let{traits:t={},userId:i,...n}=na(nF,e);return{...this.buildUniversalEventProperties(n),type:"identify",traits:t,userId:i}}buildPageView(e={}){let{properties:t={},...i}=na(nC,e),n=this.getPageProperties(),r=function e(t,i){let n=Object.keys(i);for(let r=0;re?e.reduce((e,{key:t,value:i})=>{let n="object"==typeof i&&null!==i&&"value"in i&&"object"==typeof i.value?i.value:i;return e[t]=n,e},{}):{}},nN=nu("Optimization"),nD="Could not resolve Merge Tag value:",nZ=(e,t)=>{if(!e||"object"!=typeof e)return;if(!t)return e;let i=e;for(let e of t.split(".").filter(Boolean)){if(!i||"object"!=typeof i&&"function"!=typeof i)return;i=Reflect.get(i,e)}return i},nQ={normalizeSelectors:e=>e.split("_").map((e,t,i)=>[i.slice(0,t).join("."),i.slice(t).join("_")].filter(e=>""!==e).join(".")),getValueFromProfile(e,t){let i=nQ.normalizeSelectors(e).find(e=>nZ(t,e));if(!i)return;let n=nZ(t,i);if(n&&("string"==typeof n||"number"==typeof n||"boolean"==typeof n))return`${n}`},resolve(e,t){if(!ih.safeParse(e).success)return void nN.warn(`${nD} supplied entry is not a Merge Tag entry`);let{fields:{nt_fallback:i}}=e;return i4.safeParse(t).success?nQ.getValueFromProfile(e.fields.nt_mergetag_id,t)??i:(nN.warn(`${nD} no valid profile`),i)}},nL=nu("Optimization"),nJ="Could not resolve optimized entry variant:",nH={getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ik(e))return e.fields.nt_experiences.filter(e=>ix(e)).find(e=>t.some(({experienceId:t})=>t===e.fields.nt_experience_id))},getSelectedOptimization({optimizationEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ix(e))return t.find(({experienceId:t})=>t===e.fields.nt_experience_id)},getSelectedVariant({optimizedEntry:e,optimizationEntry:t,selectedVariantIndex:i},n=!1){var r;if(!n&&(!ik(e)||!ix(t)))return;let s=(r=t.fields.nt_config,{distribution:r?.distribution===void 0?[]:[...r.distribution],traffic:r?.traffic??0,components:r?.components===void 0?[]:[...r.components],sticky:r?.sticky??!1}).components.filter(e=>("EntryReplacement"===e.type||void 0===e.type)&&!e.baseline.hidden).find(t=>t.baseline.id===e.sys.id)?.variants;if(s?.length)return s.at(i-1)},getSelectedVariantEntry({optimizationEntry:e,selectedVariant:t},i=!1){if(!i&&(!ix(e)||!iv.safeParse(t).success))return;let n=e.fields.nt_variants?.find(e=>e.sys.id===t.id);return ic.safeParse(n).success?n:void 0},resolve:function(e,t){if(nL.debug(`Resolving optimized entry for baseline entry ${e.sys.id}`),!t?.length)return nL.warn(`${nJ} no selectedOptimizations exist for the current profile`),{entry:e};if(!ik(e))return nL.warn(`${nJ} entry ${e.sys.id} is not optimized`),{entry:e};let i=nH.getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},!0);if(!i)return nL.warn(`${nJ} could not find an optimization entry for ${e.sys.id}`),{entry:e};let n=nH.getSelectedOptimization({optimizationEntry:i,selectedOptimizations:t},!0),r=n?.variantIndex??0;if(0===r)return nL.debug(`Resolved optimization entry for entry ${e.sys.id} is baseline`),{entry:e};let s=nH.getSelectedVariant({optimizedEntry:e,optimizationEntry:i,selectedVariantIndex:r},!0);if(!s)return nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e};let o=nH.getSelectedVariantEntry({optimizationEntry:i,selectedVariant:s},!0);return o?(nL.debug(`Entry ${e.sys.id} has been resolved to variant entry ${o.sys.id}`),{entry:o,selectedOptimization:n}):(nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e})}};class nK{api;eventBuilder;config;flagsResolver=nV;mergeTagValueResolver=nQ;optimizedEntryResolver=nH;interceptors={event:new nU,state:new nU};constructor(e,t={}){this.config=e;const{eventBuilder:i,logLevel:n,environment:r,clientId:s,fetchOptions:o}=e;nl.addSink(new nh(n));const a={clientId:s,environment:r,fetchOptions:o,experience:t.experience,insights:t.insights};this.api=new nx(a),this.eventBuilder=new nq(i??{channel:"server",library:{name:"@contentful/optimization-android-bridge",version:"0.0.0"}})}getFlag(e,t){return this.flagsResolver.resolve(t)[e]}resolveOptimizedEntry(e,t){return this.optimizedEntryResolver.resolve(e,t)}getMergeTagValue(e,t){return this.mergeTagValueResolver.resolve(e,t)}}let nW=nK;function nG(){}function nX(e,t){return function e(t,i,n,r,s,o,a){let l=a(t,i,n,r,s,o);if(void 0!==l)return l;if(typeof t==typeof i)switch(typeof t){case"bigint":case"string":case"boolean":case"symbol":case"undefined":case"function":return t===i;case"number":return t===i||Object.is(t,i)}return function t(i,n,r,s){if(Object.is(i,n))return!0;let o=F(i),a=F(n);if(o===q&&(o=L),a===q&&(a=L),o!==a)return!1;switch(o){case R:return i.toString()===n.toString();case M:{let e=i.valueOf(),t=n.valueOf();return e===t||Number.isNaN(e)&&Number.isNaN(t)}case B:case V:case U:return Object.is(i.valueOf(),n.valueOf());case C:return i.source===n.source&&i.flags===n.flags;case"[object Function]":return i===n}let l=(r=r??new Map).get(i),u=r.get(n);if(null!=l&&null!=u)return l===n;r.set(i,n),r.set(n,i);try{switch(o){case N:if(i.size!==n.size)return!1;for(let[t,o]of i.entries())if(!n.has(t)||!e(o,n.get(t),t,i,n,r,s))return!1;return!0;case D:{if(i.size!==n.size)return!1;let t=Array.from(i.values()),o=Array.from(n.values());for(let a=0;ae(l,t,void 0,i,n,r,s));if(-1===u)return!1;o.splice(u,1)}return!0}case Z:case H:case K:case W:case G:case"[object BigUint64Array]":case X:case Y:case ee:case"[object BigInt64Array]":case et:case ei:if(er(i)!==er(n)||i.length!==n.length)return!1;for(let t=0;t{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))})}return i}resolveOptimizedEntry(e,t=ey.value){return super.resolveOptimizedEntry(e,t)}getMergeTagValue(e,t=em.value){return super.getMergeTagValue(e,t)}async identify(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("identify",[e],this.eventBuilder.buildIdentify(i),t)}async page(e={}){let{profile:t,...i}=e;return await this.sendExperienceEvent("page",[e],this.eventBuilder.buildPageView(i),t)}async screen(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("screen",[e],this.eventBuilder.buildScreenView(i),t)}async track(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("track",[e],this.eventBuilder.buildTrack(i),t)}async trackView(e){let t,{profile:i,...n}=e;return e.sticky&&(t=await this.sendExperienceEvent("trackView",[e],this.eventBuilder.buildView(n),i)),await this.sendInsightsEvent("trackView",[e],this.eventBuilder.buildView(n),i),t}async trackClick(e){await this.sendInsightsEvent("trackClick",[e],this.eventBuilder.buildClick(e))}async trackHover(e){await this.sendInsightsEvent("trackHover",[e],this.eventBuilder.buildHover(e))}async trackFlagView(e){await this.sendInsightsEvent("trackFlagView",[e],this.eventBuilder.buildFlagView(e))}hasConsent(e){let{[e]:t}=n0,i=void 0!==t?this.allowedEventTypes.includes(t):this.allowedEventTypes.some(t=>t===e);return!!ed.value||i}onBlockedByConsent(e,t){nY.warn(`Event "${e}" was blocked due to lack of consent; payload: ${JSON.stringify(t)}`),this.reportBlockedEvent("consent",e,t)}async sendExperienceEvent(e,t,i,n){return this.hasConsent(e)?await this.experienceQueue.send(i):void this.onBlockedByConsent(e,t)}async sendInsightsEvent(e,t,i,n){this.hasConsent(e)?await this.insightsQueue.send(i):this.onBlockedByConsent(e,t)}buildFlagViewBuilderArgs(e,t=eu.value){let i=t?.find(t=>t.key===e);return{componentId:e,experienceId:i?.meta.experienceId,variantIndex:i?.meta.variantIndex}}getFlagObservable(e){var t;let i,n=this.flagObservables.get(e);if(n)return n;let r=this.trackFlagView.bind(this),s=this.buildFlagViewBuilderArgs.bind(this),o=(t=ew.computed(()=>super.getFlag(e,eu.value)),i=el(t),{get current(){return i.current},subscribe(e){let t=!1,n=ea(i.current);return i.subscribe(i=>{t&&nX(n,i)||(t=!0,n=ea(i),e(i))})},subscribeOnce:e=>i.subscribeOnce(e)}),a={get current(){let{current:t}=o;return r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t},subscribe:t=>o.subscribe(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)}),subscribeOnce:t=>o.subscribeOnce(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)})};return this.flagObservables.set(e,a),a}reportBlockedEvent(e,t,i){let n={reason:e,method:t,args:i};try{this.onEventBlocked?.(n)}catch(e){nY.warn(`onEventBlocked callback failed for method "${t}"`,e)}ec.value=n}}let n2=n1,n6=(e,t)=>!Number.isFinite(e)||void 0===e||e<1?t:Math.floor(e),n3={flushIntervalMs:3e4,baseBackoffMs:500,maxBackoffMs:3e4,jitterRatio:.2,maxConsecutiveFailures:8,circuitOpenMs:12e4},n4="__ctfl_optimization_stateful_runtime_lock__",n8=()=>{let e=globalThis;return e[n4]??={owner:void 0},e[n4]},n5=e=>{let t=n8();t.owner===e&&(t.owner=void 0)};class n9{circuitOpenUntil=0;flushFailureCount=0;flushInFlight=!1;nextFlushAllowedAt=0;onCallbackError;onRetry;policy;retryTimer;constructor(e){const{onCallbackError:t,onRetry:i,policy:n}=e;this.policy=n,this.onRetry=i,this.onCallbackError=t}reset(){this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.flushInFlight=!1,this.nextFlushAllowedAt=0}clearScheduledRetry(){void 0!==this.retryTimer&&(clearTimeout(this.retryTimer),this.retryTimer=void 0)}shouldSkip(e){let{force:t,isOnline:i}=e;if(this.flushInFlight)return!0;if(t)return!1;if(!i)return!0;let n=Date.now();return!!(this.nextFlushAllowedAt>n)||!!(this.circuitOpenUntil>n)}markFlushStarted(){this.flushInFlight=!0}markFlushFinished(){this.flushInFlight=!1}handleFlushSuccess(){let{flushFailureCount:e}=this;this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.nextFlushAllowedAt=0,e<=0||this.safeInvoke("onFlushRecovered",{consecutiveFailures:e})}handleFlushFailure(e){let{queuedBatches:t,queuedEvents:i}=e;this.flushFailureCount+=1;let n=(e=>{let{consecutiveFailures:t,policy:{baseBackoffMs:i,jitterRatio:n,maxBackoffMs:r}}=e,s=Math.min(r,i*2**Math.max(0,t-1)),o=s*n*Math.random();return Math.round(s+o)})({consecutiveFailures:this.flushFailureCount,policy:this.policy}),r=Date.now(),s={consecutiveFailures:this.flushFailureCount,queuedBatches:t,queuedEvents:i,retryDelayMs:n};this.safeInvoke("onFlushFailure",s);let{circuitOpenUntil:o,nextFlushAllowedAt:a,openedCircuit:l,retryDelayMs:u}=(e=>{let{consecutiveFailures:t,failureTimestamp:i,retryDelayMs:n,policy:{maxConsecutiveFailures:r,circuitOpenMs:s}}=e;if(t{this.retryTimer=void 0,this.onRetry()},e)}safeInvoke(...e){let[t,i]=e;try{if("onFlushRecovered"===t)return void this.policy.onFlushRecovered?.(i);if("onCircuitOpen"===t)return void this.policy.onCircuitOpen?.(i);this.policy.onFlushFailure?.(i)}catch(e){this.onCallbackError?.(t,e)}}}let n7=nu("CoreStateful");class re{experienceApi;eventInterceptors;flushRuntime;getAnonymousId;offlineMaxEvents;onOfflineDrop;queuedExperienceEvents=new Set;stateInterceptors;constructor(e){const{experienceApi:t,eventInterceptors:i,flushPolicy:n,getAnonymousId:r,offlineMaxEvents:s,onOfflineDrop:o,stateInterceptors:a}=e;this.experienceApi=t,this.eventInterceptors=i,this.getAnonymousId=r,this.offlineMaxEvents=s,this.onOfflineDrop=o,this.stateInterceptors=a,this.flushRuntime=new n9({policy:n,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{n7.warn(`Experience flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}async send(e){let t=na(iY,await this.eventInterceptors.run(e));if(ep.value=t,eh.value)return await this.upsertProfile([t]);n7.debug(`Queueing ${t.type} event`,t),this.enqueueEvent(t)}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!eh.value}))return;if(0===this.queuedExperienceEvents.size)return void this.flushRuntime.clearScheduledRetry();n7.debug("Flushing offline Experience event queue");let i=Array.from(this.queuedExperienceEvents);this.flushRuntime.markFlushStarted();try{await this.tryUpsertQueuedEvents(i)?(i.forEach(e=>{this.queuedExperienceEvents.delete(e)}),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:+(this.queuedExperienceEvents.size>0),queuedEvents:this.queuedExperienceEvents.size})}finally{this.flushRuntime.markFlushFinished()}}enqueueEvent(e){let t=[];if(this.queuedExperienceEvents.size>=this.offlineMaxEvents){let e=this.queuedExperienceEvents.size-this.offlineMaxEvents+1;(t=this.dropOldestEvents(e)).length>0&&n7.warn(`Dropped ${t.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`)}this.queuedExperienceEvents.add(e),t.length>0&&this.invokeOfflineDropCallback({droppedCount:t.length,droppedEvents:t,maxEvents:this.offlineMaxEvents,queuedEvents:this.queuedExperienceEvents.size})}dropOldestEvents(e){let t=[];for(let i=0;i{nX(eu.value,t)||(eu.value=t),nX(em.value,i)||(em.value=i),nX(ey.value,n)||(ey.value=n)})}}let rt=nu("CoreStateful");class ri{eventInterceptors;flushIntervalMs;flushRuntime;insightsApi;queuedInsightsByProfile=new Map;insightsPeriodicFlushTimer;constructor(e){const{eventInterceptors:t,flushPolicy:i,insightsApi:n}=e,{flushIntervalMs:r}=i;this.eventInterceptors=t,this.flushIntervalMs=r,this.insightsApi=n,this.flushRuntime=new n9({policy:i,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{rt.warn(`Insights flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}clearPeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer&&(clearInterval(this.insightsPeriodicFlushTimer),this.insightsPeriodicFlushTimer=void 0)}async send(e){let{value:t}=em;if(!t)return void rt.warn("Attempting to emit an event without an Optimization profile");let i=na(nr,await this.eventInterceptors.run(e));rt.debug(`Queueing ${i.type} event for profile ${t.id}`,i);let n=this.queuedInsightsByProfile.get(t.id);ep.value=i,n?(n.profile=t,n.events.push(i)):this.queuedInsightsByProfile.set(t.id,{profile:t,events:[i]}),this.ensurePeriodicFlushTimer(),this.getQueuedEventCount()>=25&&await this.flush(),this.reconcilePeriodicFlushTimer()}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!eh.value}))return;rt.debug("Flushing insights event queue");let i=this.createBatches();if(!i.length){this.flushRuntime.clearScheduledRetry(),this.reconcilePeriodicFlushTimer();return}this.flushRuntime.markFlushStarted();try{await this.trySendBatches(i)?(this.queuedInsightsByProfile.clear(),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:i.length,queuedEvents:this.getQueuedEventCount()})}finally{this.flushRuntime.markFlushFinished(),this.reconcilePeriodicFlushTimer()}}createBatches(){let e=[];return this.queuedInsightsByProfile.forEach(({profile:t,events:i})=>{e.push({profile:t,events:i})}),e}async trySendBatches(e){try{return await this.insightsApi.sendBatchEvents(e)}catch(e){return rt.warn("Insights queue flush request threw an error",e),!1}}getQueuedEventCount(){let e=0;return this.queuedInsightsByProfile.forEach(({events:t})=>{e+=t.length}),e}ensurePeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer||0!==this.getQueuedEventCount()&&(this.insightsPeriodicFlushTimer=setInterval(()=>{this.flush()},this.flushIntervalMs))}reconcilePeriodicFlushTimer(){this.getQueuedEventCount()>0?this.ensurePeriodicFlushTimer():this.clearPeriodicFlushTimer()}}let rn=Symbol.for("ctfl.optimization.preview.signals"),rr=Symbol.for("ctfl.optimization.preview.signalFns"),rs=nu("CoreStateful"),ro=["identify","page","screen"],ra=e=>Object.values(e).some(e=>void 0!==e),rl=0;class ru extends n2{singletonOwner;destroyed=!1;allowedEventTypes;experienceQueue;insightsQueue;onEventBlocked;states={blockedEventStream:el(ec),flag:e=>this.getFlagObservable(e),consent:el(ed),eventStream:el(ep),canOptimize:el(eg),selectedOptimizations:el(ey),previewPanelAttached:el(ef),previewPanelOpen:el(ev),profile:el(em)};constructor(e){super(e,{experience:(e=>{if(void 0===e)return;let t={baseUrl:e.experienceBaseUrl,enabledFeatures:e.enabledFeatures,ip:e.ip,locale:e.locale,plainText:e.plainText,preflight:e.preflight};return ra(t)?t:void 0})(e.api),insights:(e=>{if(void 0===e)return;let t={baseUrl:e.insightsBaseUrl,beaconHandler:e.beaconHandler};return ra(t)?t:void 0})(e.api)}),this.singletonOwner=`CoreStateful#${++rl}`,(e=>{let t=n8();if(t.owner)throw Error(`Stateful Optimization SDK already initialized (${t.owner}). Only one stateful instance is supported per runtime.`);t.owner=e})(this.singletonOwner);try{const{allowedEventTypes:t,defaults:i,getAnonymousId:n,onEventBlocked:r,queuePolicy:s}=e,{changes:o,consent:a,selectedOptimizations:l,profile:u}=i??{},c=(e=>({flush:((e,t=n3)=>{var i,n;let r=e??{},s=n6(r.baseBackoffMs,t.baseBackoffMs),o=Math.max(s,n6(r.maxBackoffMs,t.maxBackoffMs));return{flushIntervalMs:n6(r.flushIntervalMs,t.flushIntervalMs),baseBackoffMs:s,maxBackoffMs:o,jitterRatio:(i=r.jitterRatio,n=t.jitterRatio,Number.isFinite(i)&&void 0!==i?Math.min(1,Math.max(0,i)):n),maxConsecutiveFailures:n6(r.maxConsecutiveFailures,t.maxConsecutiveFailures),circuitOpenMs:n6(r.circuitOpenMs,t.circuitOpenMs),onCircuitOpen:r.onCircuitOpen,onFlushFailure:r.onFlushFailure,onFlushRecovered:r.onFlushRecovered}})(e?.flush),offlineMaxEvents:n6(e?.offlineMaxEvents,100),onOfflineDrop:e?.onOfflineDrop}))(s);this.allowedEventTypes=t??ro,this.onEventBlocked=r,this.insightsQueue=new ri({eventInterceptors:this.interceptors.event,flushPolicy:c.flush,insightsApi:this.api.insights}),this.experienceQueue=new re({experienceApi:this.api.experience,eventInterceptors:this.interceptors.event,flushPolicy:c.flush,getAnonymousId:n??(()=>void 0),offlineMaxEvents:c.offlineMaxEvents,onOfflineDrop:c.onOfflineDrop,stateInterceptors:this.interceptors.state}),void 0!==a&&(ed.value=a),p(()=>{void 0!==o&&(eu.value=o),void 0!==l&&(ey.value=l),void 0!==u&&(em.value=u)}),this.initializeEffects()}catch(e){throw n5(this.singletonOwner),e}}initializeEffects(){A(()=>{rs.debug(`Profile ${em.value&&`with ID ${em.value.id}`} has been ${em.value?"set":"cleared"}`)}),A(()=>{rs.debug(`Variants have been ${ey.value?.length?"populated":"cleared"}`)}),A(()=>{rs.info(`Core ${ed.value?"will":"will not"} emit gated events due to consent (${ed.value})`)}),A(()=>{eh.value&&(this.insightsQueue.clearScheduledRetry(),this.experienceQueue.clearScheduledRetry(),this.flushQueues({force:!0}))})}async flushQueues(e={}){await this.insightsQueue.flush(e),await this.experienceQueue.flush(e)}destroy(){this.destroyed||(this.destroyed=!0,this.insightsQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush insights queue during destroy()",String(e))}),this.experienceQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush Experience queue during destroy()",String(e))}),this.insightsQueue.clearPeriodicFlushTimer(),n5(this.singletonOwner))}reset(){p(()=>{ec.value=void 0,ep.value=void 0,eu.value=void 0,em.value=void 0,ey.value=void 0})}async flush(){await this.flushQueues()}consent(e){ed.value=e}get online(){return eh.value??!1}set online(e){eh.value=e}registerPreviewPanel(e){Reflect.set(e,rn,eb),Reflect.set(e,rr,ew)}}let rc="ALL_VISITORS";function rd(e,t){let{audiences:{[t]:i}}=e;return i?i.isActive?"on":"off":"default"}function rp(e,t){return"on"===e||"off"!==e&&t}function rh(e,t,i,n){let r=t[e.id]??0,s=void 0!==i.selectedOptimizations[e.id],o={...e,currentVariantIndex:r,isOverridden:s};if(s&&void 0!==n){let{[e.id]:t}=n;void 0!==t&&(o.naturalVariantIndex=t)}return o}function rf(e,t){let i=Object.values(t);if(0===i.length)return e;let n=e.map(e=>{let{[e.experienceId]:i}=t;return i?{...e,variantIndex:i.variantIndex}:e});for(let e of i)n.some(t=>t.experienceId===e.experienceId)||n.push({experienceId:e.experienceId,variantIndex:e.variantIndex,variants:{}});return n}nu("Preview");let rv=nu("PreviewOverrides"),ry={audiences:{},selectedOptimizations:{}};class rg{baselineSelectedOptimizations=null;baselineAudienceQualifications={};overrides={...ry,audiences:{},selectedOptimizations:{}};interceptorId=null;selectedOptimizations;profile;stateInterceptors;onOverridesChanged;constructor(e){const{selectedOptimizations:t,profile:i,stateInterceptors:n,onOverridesChanged:r}=e;this.selectedOptimizations=t,this.profile=i,this.stateInterceptors=n,this.onOverridesChanged=r;const{value:s}=t;s&&(this.baselineSelectedOptimizations=s,rv.debug("Captured initial signal state as baseline")),this.interceptorId=e.stateInterceptors.add(e=>{let{selectedOptimizations:t}=e;this.baselineSelectedOptimizations=t;let i=Object.keys(this.overrides.selectedOptimizations).length>0,n=i?{...e,selectedOptimizations:rf(e.selectedOptimizations,this.overrides.selectedOptimizations)}:{...e};return i&&rv.debug("Intercepting state update to preserve overrides"),this.notifyChanged(),n}),rv.info("State interceptor registered")}activateAudience(e,t){rv.info("Activating audience override:",e),this.setAudienceOverride(e,!0,1,t)}deactivateAudience(e,t){rv.info("Deactivating audience override:",e),this.setAudienceOverride(e,!1,0,t)}resetAudienceOverride(e){rv.info("Resetting audience override:",e);let{overrides:t}=this,{audiences:i,selectedOptimizations:n}=t,r=i[e]?.experienceIds??[],s=new Set(r),o=Object.fromEntries(Object.entries(n).filter(([e])=>!s.has(e))),a=Object.fromEntries(Object.entries(i).filter(([t])=>t!==e));this.overrides={audiences:a,selectedOptimizations:o},this.baselineAudienceQualifications=Object.fromEntries(Object.entries(this.baselineAudienceQualifications).filter(([t])=>t!==e)),r.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}setVariantOverride(e,t){rv.info("Setting variant override:",{experienceId:e,variantIndex:t}),this.overrides={...this.overrides,selectedOptimizations:{...this.overrides.selectedOptimizations,[e]:{experienceId:e,variantIndex:t}}},this.syncOverridesToSignal(),this.notifyChanged()}resetOptimizationOverride(e){rv.info("Resetting optimization override:",e);let{selectedOptimizations:t}={...this.overrides},i=Object.fromEntries(Object.entries(t).filter(([t])=>t!==e));this.overrides={...this.overrides,selectedOptimizations:i},this.syncOverridesToSignal(),this.notifyChanged()}resetAll(){rv.info("Resetting all overrides to baseline"),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineAudienceQualifications={};let{baselineSelectedOptimizations:e}=this;e&&(this.selectedOptimizations.value=e,rv.debug("Restored signal to baseline")),this.notifyChanged()}getOverrides(){return this.overrides}getBaselineSelectedOptimizations(){return this.baselineSelectedOptimizations}getBaselineAudienceQualifications(){return this.baselineAudienceQualifications}destroy(){null!==this.interceptorId&&(this.stateInterceptors.remove(this.interceptorId),rv.info("State interceptor removed"),this.interceptorId=null),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineSelectedOptimizations=null,this.baselineAudienceQualifications={}}snapshotAudienceQualification(e){if(!this.profile||e in this.baselineAudienceQualifications)return;let t=this.profile.value?.audiences.includes(e)??!1;this.baselineAudienceQualifications[e]=t}syncOverridesToSignal(){this.selectedOptimizations.value=rf(this.baselineSelectedOptimizations??[],this.overrides.selectedOptimizations),rv.debug("Synced overrides to signal")}setAudienceOverride(e,t,i,n){this.snapshotAudienceQualification(e);let r={...this.overrides.selectedOptimizations};for(let e of n)r[e]={experienceId:e,variantIndex:i};this.overrides={audiences:{...this.overrides.audiences,[e]:{audienceId:e,isActive:t,source:"manual",experienceIds:n}},selectedOptimizations:r},n.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}notifyChanged(){this.onOverridesChanged?.(this.overrides)}}let rm=null,rb=null,rw=null,r_=null,rz=null,rO=null,rS={},rE={},rx={initialize(e){rm&&rx.destroy(),rz=null,rO=null,rS={},rE={},rm=new ru({clientId:e.clientId,environment:e.environment,api:{experienceBaseUrl:e.experienceBaseUrl,insightsBaseUrl:e.insightsBaseUrl}}),e.defaults&&(void 0!==e.defaults.consent&&rm.consent(e.defaults.consent),void 0!==e.defaults.profile&&(eb.profile.value=e.defaults.profile),void 0!==e.defaults.changes&&(eb.changes.value=e.defaults.changes),void 0!==e.defaults.optimizations&&(eb.selectedOptimizations.value=e.defaults.optimizations)),rm.consent(!0);let t=globalThis;r_=new rg({selectedOptimizations:eb.selectedOptimizations,profile:eb.profile,stateInterceptors:rm.interceptors.state,onOverridesChanged:()=>{"function"==typeof t.__nativeOnOverridesChanged&&t.__nativeOnOverridesChanged(rx.getPreviewState())}}),rb=A(()=>{let e={profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null};"function"==typeof t.__nativeOnStateChange&&t.__nativeOnStateChange(JSON.stringify(e))}),rw=A(()=>{let e=eb.event.value;e&&"function"==typeof t.__nativeOnEventEmitted&&t.__nativeOnEventEmitted(JSON.stringify(e))})},identify(e,t,i){rm?rm.identify(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},page(e,t,i){rm?rm.page(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},screen(e,t,i){rm?rm.screen({name:e.name,properties:e.properties??{}}).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},flush(e,t){rm?rm.flush().then(()=>{e(JSON.stringify(null))}).catch(e=>{t(e instanceof Error?e.message:String(e))}):t("SDK not initialized. Call initialize() first.")},trackView(e,t,i){rm?rm.trackView(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},trackClick(e,t,i){rm?rm.trackClick(e).then(()=>{t(JSON.stringify(null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},consent(e){rm&&rm.consent(e)},reset(){rm&&(r_?.resetAll(),rm.reset())},setOnline(e){eb.online.value=e},personalizeEntry:(e,t)=>rm?JSON.stringify(rm.resolveOptimizedEntry(e,t)):JSON.stringify({entry:e}),setPreviewPanelOpen(e){rm&&(eb.previewPanelOpen.value=e)},overrideAudience(e,t,i){r_&&(t?r_.activateAudience(e,i):r_.deactivateAudience(e,i))},overrideVariant(e,t){r_?.setVariantOverride(e,t)},resetAudienceOverride(e){r_?.resetAudienceOverride(e)},resetVariantOverride(e){r_?.resetOptimizationOverride(e)},resetAllOverrides(){r_?.resetAll()},loadDefinitions(e,t){try{let i,n;for(let r of(rz=e.map(e=>{let t=e.fields;return"object"==typeof t&&null!==t?{id:t.nt_audience_id??e.sys.id,name:t.nt_name??t.nt_audience_id??e.sys.id,description:t.nt_description}:{id:e.sys.id,name:e.sys.id}}),i=new Map,t.forEach(e=>{(e.includes?.Entry??[]).forEach(e=>{i.set(e.sys.id,e)})}),rO=t.map(e=>{let t=e.fields;if("object"!=typeof t||null===t)return{id:e.sys.id,name:e.sys.id,type:"nt_personalization",distribution:[]};let{nt_config:n}=t,r=[];return n?.distribution&&n.distribution.forEach((e,t)=>{let s,o=(s=n.components?.[0],void 0===s||void 0!==s.type&&"EntryReplacement"!==s.type?"":0===t?s.baseline.id:s.variants[t-1]?.id??""),a=i.get(o);r.push({index:t,variantRef:o,percentage:Math.round(100*e),name:a?function(e){let t=e.fields;if("object"==typeof t&&null!==t)return t.internalTitle??t.title??t.name}(a):void 0})}),{id:t.nt_experience_id??e.sys.id,name:t.nt_name??e.sys.id,type:t.nt_type??"nt_personalization",distribution:r,audience:t.nt_audience?{id:t.nt_audience.sys.id}:void 0}}),n={},t.forEach(e=>{let t=e.fields;if("object"==typeof t&&null!==t){let i=t.nt_personalization_id??t.nt_experience_id??e.sys.id,{nt_name:r}=t;r&&(n[i]=r)}}),rE=n,rS={},rz))rS[r.id]=r.name;return JSON.stringify({audienceCount:rz.length,experienceCount:rO.length})}catch(e){return rz=null,rO=null,rS={},rE={},JSON.stringify({error:e instanceof Error?e.message:String(e)})}},getPreviewState(){let e=r_?.getOverrides()??{audiences:{},selectedOptimizations:{}},t=r_?.getBaselineSelectedOptimizations(),i={};for(let[t,n]of Object.entries(e.audiences))i[t]=n.isActive;let n={};for(let[t,i]of Object.entries(e.selectedOptimizations))n[t]=i.variantIndex;let r={};if(t)for(let e of t)void 0!==n[e.experienceId]&&(r[e.experienceId]=e.variantIndex);let s=rz&&rO?{...function(e){let{audienceDefinitions:t,experienceDefinitions:i,signals:n,overrides:r}=e,{profile:s,selectedOptimizations:o}=n,a=new Set(s?.audiences??[]),l={};if(o)for(let{experienceId:e,variantIndex:t}of o)l[e]=t;let u=null!=e.baselineSelectedOptimizations?Object.fromEntries(e.baselineSelectedOptimizations.map(e=>[e.experienceId,e.variantIndex])):void 0,c=new Set(t.map(e=>e.id)),d=i.filter(e=>!e.audience?.id||!c.has(e.audience.id)).map(e=>rh(e,l,r,u)),p=t.map(e=>{let t=i.filter(t=>t.audience?.id===e.id).map(e=>rh(e,l,r,u)),n=a.has(e.id),s=rd(r,e.id),o=rp(s,n);return{audience:e,experiences:t,isQualified:n,isActive:o,overrideState:s}});if(d.length>0){let e=rd(r,rc);p.push({audience:{id:rc,name:"All Visitors",description:"Experiences that apply to all visitors regardless of audience membership"},experiences:d,isQualified:!0,isActive:rp(e,!0),overrideState:e})}let h=t.length>0||i.length>0;return{audiencesWithExperiences:[...p].sort((e,t)=>e.audience.id===rc?-1:t.audience.id===rc?1:e.isActive!==t.isActive?e.isActive?-1:1:e.audience.name.localeCompare(t.audience.name,void 0,{sensitivity:"base"})),unassociatedExperiences:d,hasData:h,sdkVariantIndices:l}}({audienceDefinitions:rz,experienceDefinitions:rO,signals:{profile:eb.profile.value,selectedOptimizations:eb.selectedOptimizations.value,consent:eb.consent.value,isLoading:!1},overrides:e,baselineSelectedOptimizations:t}),audienceNameMap:rS,experienceNameMap:rE}:null;return JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null,previewPanelOpen:eb.previewPanelOpen.value,audienceOverrides:i,variantOverrides:n,defaultAudienceQualifications:r_?.getBaselineAudienceQualifications()??{},defaultVariantIndices:r,previewModel:s})},getProfile(){let e=eb.profile.value;return e?JSON.stringify(e):null},getState:()=>JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null}),destroy(){r_?.destroy(),r_=null,rz=null,rO=null,rS={},rE={},rw&&(rw(),rw=null),rb&&(rb(),rb=null),rm&&(rm.destroy(),rm=null)}};globalThis.__bridge=rx;let rk=rx;return u.default})()); +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.OptimizationBridge=t():e.OptimizationBridge=t()}(globalThis,()=>(()=>{"use strict";let e,t,i,n,r,s,o;var a,l={};l.d=(e,t)=>{for(var i in t)l.o(t,i)&&!l.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var u={};l.d(u,{default:()=>rI});var c=Symbol.for("preact-signals");function d(){if(g>1)g--;else{for(var e,t=!1;void 0!==y;){var i=y;for(y=void 0,m++;void 0!==i;){var n=i.o;if(i.o=void 0,i.f&=-3,!(8&i.f)&&O(i))try{i.c()}catch(i){t||(e=i,t=!0)}i=n}}if(m=0,g--,t)throw e}}function p(e){if(g>0)return e();g++;try{return e()}finally{d()}}var f=void 0;function h(e){var t=f;f=void 0;try{return e()}finally{f=t}}var v,y=void 0,g=0,m=0,b=0;function w(e){if(void 0!==f){var t=e.n;if(void 0===t||t.t!==f)return t={i:0,S:e,p:f.s,n:void 0,t:f,e:void 0,x:void 0,r:t},void 0!==f.s&&(f.s.n=t),f.s=t,e.n=t,32&f.f&&e.S(t),t;if(-1===t.i)return t.i=0,void 0!==t.n&&(t.n.p=t.p,void 0!==t.p&&(t.p.n=t.n),t.p=f.s,t.n=void 0,f.s.n=t,f.s=t),t}}function _(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function z(e,t){return new _(e,t)}function O(e){for(var t=e.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function S(e){for(var t=e.s;void 0!==t;t=t.n){var i=t.S.n;if(void 0!==i&&(t.r=i),t.S.n=t,t.i=-1,void 0===t.n){e.s=t;break}}}function E(e){for(var t=e.s,i=void 0;void 0!==t;){var n=t.p;-1===t.i?(t.S.U(t),void 0!==n&&(n.n=t.n),void 0!==t.n&&(t.n.p=n)):i=t,t.S.n=t.r,void 0!==t.r&&(t.r=void 0),t=n}e.s=i}function x(e,t){_.call(this,void 0),this.x=e,this.s=void 0,this.g=b-1,this.f=4,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function k(e,t){return new x(e,t)}function I(e){var t=e.u;if(e.u=void 0,"function"==typeof t){g++;var i=f;f=void 0;try{t()}catch(t){throw e.f&=-2,e.f|=8,$(e),t}finally{f=i,d()}}}function $(e){for(var t=e.s;void 0!==t;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,I(e)}function P(e){if(f!==this)throw Error("Out-of-order effect");E(this),f=e,this.f&=-2,8&this.f&&$(this),d()}function j(e,t){this.x=e,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=null==t?void 0:t.name,v&&v.push(this)}function A(e,t){var i=new j(e,t);try{i.c()}catch(e){throw i.d(),e}var n=i.d.bind(i);return n[Symbol.dispose]=n,n}function T(e){return Object.getOwnPropertySymbols(e).filter(t=>Object.prototype.propertyIsEnumerable.call(e,t))}function F(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}_.prototype.brand=c,_.prototype.h=function(){return!0},_.prototype.S=function(e){var t=this,i=this.t;i!==e&&void 0===e.e&&(e.x=i,this.t=e,void 0!==i?i.e=e:h(function(){var e;null==(e=t.W)||e.call(t)}))},_.prototype.U=function(e){var t=this;if(void 0!==this.t){var i=e.e,n=e.x;void 0!==i&&(i.x=n,e.e=void 0),void 0!==n&&(n.e=i,e.x=void 0),e===this.t&&(this.t=n,void 0===n&&h(function(){var e;null==(e=t.Z)||e.call(t)}))}},_.prototype.subscribe=function(e){var t=this;return A(function(){var i=t.value,n=f;f=void 0;try{e(i)}finally{f=n}},{name:"sub"})},_.prototype.valueOf=function(){return this.value},_.prototype.toString=function(){return this.value+""},_.prototype.toJSON=function(){return this.value},_.prototype.peek=function(){var e=f;f=void 0;try{return this.value}finally{f=e}},Object.defineProperty(_.prototype,"value",{get:function(){var e=w(this);return void 0!==e&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(m>100)throw Error("Cycle detected");this.v=e,this.i++,b++,g++;try{for(var t=this.t;void 0!==t;t=t.x)t.t.N()}finally{d()}}}}),x.prototype=new _,x.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if(32==(36&this.f)||(this.f&=-5,this.g===b))return!0;if(this.g=b,this.f|=1,this.i>0&&!O(this))return this.f&=-2,!0;var e=f;try{S(this),f=this;var t=this.x();(16&this.f||this.v!==t||0===this.i)&&(this.v=t,this.f&=-17,this.i++)}catch(e){this.v=e,this.f|=16,this.i++}return f=e,E(this),this.f&=-2,!0},x.prototype.S=function(e){if(void 0===this.t){this.f|=36;for(var t=this.s;void 0!==t;t=t.n)t.S.S(t)}_.prototype.S.call(this,e)},x.prototype.U=function(e){if(void 0!==this.t&&(_.prototype.U.call(this,e),void 0===this.t)){this.f&=-33;for(var t=this.s;void 0!==t;t=t.n)t.S.U(t)}},x.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;void 0!==e;e=e.x)e.t.N()}},Object.defineProperty(x.prototype,"value",{get:function(){if(1&this.f)throw Error("Cycle detected");var e=w(this);if(this.h(),void 0!==e&&(e.i=this.i),16&this.f)throw this.v;return this.v}}),j.prototype.c=function(){var e=this.S();try{if(8&this.f||void 0===this.x)return;var t=this.x();"function"==typeof t&&(this.u=t)}finally{e()}},j.prototype.S=function(){if(1&this.f)throw Error("Cycle detected");this.f|=1,this.f&=-9,I(this),S(this),g++;var e=f;return f=this,P.bind(this,e)},j.prototype.N=function(){2&this.f||(this.f|=2,this.o=y,y=this)},j.prototype.d=function(){this.f|=8,1&this.f||$(this)},j.prototype.dispose=function(){this.d()};let C="[object RegExp]",R="[object String]",M="[object Number]",B="[object Boolean]",q="[object Arguments]",U="[object Symbol]",V="[object Date]",N="[object Map]",D="[object Set]",Z="[object Array]",Q="[object ArrayBuffer]",L="[object Object]",J="[object DataView]",H="[object Uint8Array]",K="[object Uint8ClampedArray]",W="[object Uint16Array]",G="[object Uint32Array]",X="[object Int8Array]",Y="[object Int16Array]",ee="[object Int32Array]",et="[object Float32Array]",ei="[object Float64Array]",en="object"==typeof globalThis&&globalThis||"object"==typeof window&&window||"object"==typeof self&&self||"object"==typeof global&&global||function(){return this}()||Function("return this")();function er(e){return void 0!==en.Buffer&&en.Buffer.isBuffer(e)}function es(e,t,i,n=new Map,r){let s=r?.(e,t,i,n);if(void 0!==s)return s;if(null==e||"object"!=typeof e&&"function"!=typeof e)return e;if(n.has(e))return n.get(e);if(Array.isArray(e)){let t=Array(e.length);n.set(e,t);for(let s=0;stypeof SharedArrayBuffer&&e instanceof SharedArrayBuffer)return e.slice(0);if(e instanceof DataView){let t=new DataView(e.buffer.slice(0),e.byteOffset,e.byteLength);return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof File&&e instanceof File){let t=new File([e],e.name,{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof Blob&&e instanceof Blob){let t=new Blob([e],{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Error){let t=structuredClone(e);return n.set(e,t),t.message=e.message,t.name=e.name,t.stack=e.stack,t.cause=e.cause,t.constructor=e.constructor,eo(t,e,i,n,r),t}if(e instanceof Boolean){let t=new Boolean(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Number){let t=new Number(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof String){let t=new String(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if("object"==typeof e&&function(e){switch(F(e)){case q:case Z:case Q:case J:case B:case V:case et:case ei:case X:case Y:case ee:case N:case M:case L:case C:case D:case R:case U:case H:case K:case W:case G:return!0;default:return!1}}(e)){let t=Object.create(Object.getPrototypeOf(e));return n.set(e,t),eo(t,e,i,n,r),t}return e}function eo(e,t,i=e,n,r){let s=[...Object.keys(t),...T(t)];for(let o=0;o({unsubscribe:A(()=>{t(ea(e.value))})}),subscribeOnce(t){let i=!1,n=!1,r=()=>void 0;return r=A(()=>{if(i)return;let{value:s}=e;if(null==s)return;i=!0;let o=null;try{t(ea(s))}catch(e){o=e instanceof Error?e:Error(`Subscriber threw non-Error value: ${String(e)}`)}if(n?r():queueMicrotask(r),o)throw o}),n=!0,{unsubscribe:()=>{!i&&(i=!0,n&&r())}}}}}let eu=z(),ec=z(),ed=z(),ep=z(),ef=z(!0),eh=z(!1),ev=z(!1),ey=z(),eg=k(()=>void 0!==ey.value),em=z(),eb={blockedEvent:ec,changes:eu,consent:ed,event:ep,online:ef,previewPanelAttached:eh,previewPanelOpen:ev,selectedOptimizations:ey,canOptimize:eg,profile:em},ew={batch:p,computed:k,effect:A,untracked:h};function e_(e,t,i){function n(i,n){if(i._zod||Object.defineProperty(i,"_zod",{value:{def:n,constr:o,traits:new Set},enumerable:!1}),i._zod.traits.has(e))return;i._zod.traits.add(e),t(i,n);let r=o.prototype,s=Object.keys(r);for(let e=0;e!!i?.Parent&&t instanceof i.Parent||t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}Object.freeze({status:"aborted"}),Symbol("zod_brand");class ez extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}let eO={};function eS(e){return e&&Object.assign(eO,e),eO}function eE(e,t="|"){return e.map(e=>eU(e)).join(t)}function ex(e,t){return"bigint"==typeof t?t.toString():t}function ek(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}}}}function eI(e){let t=+!!e.startsWith("^"),i=e.endsWith("$")?e.length-1:e.length;return e.slice(t,i)}let e$=Symbol("evaluating");function eP(e,t,i){let n;Object.defineProperty(e,t,{get(){if(n!==e$)return void 0===n&&(n=e$,n=i()),n},set(i){Object.defineProperty(e,t,{value:i})},configurable:!0})}function ej(e,t,i){Object.defineProperty(e,t,{value:i,writable:!0,enumerable:!0,configurable:!0})}function eA(...e){let t={};for(let i of e)Object.assign(t,Object.getOwnPropertyDescriptors(i));return Object.defineProperties({},t)}let eT="captureStackTrace"in Error?Error.captureStackTrace:(...e)=>{};function eF(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function eC(e){if(!1===eF(e))return!1;let t=e.constructor;if(void 0===t||"function"!=typeof t)return!0;let i=t.prototype;return!1!==eF(i)&&!1!==Object.prototype.hasOwnProperty.call(i,"isPrototypeOf")}ek(()=>{if("u">typeof navigator&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{return Function(""),!0}catch(e){return!1}});let eR=new Set(["string","number","symbol"]);function eM(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function eB(e,t,i){let n=new e._zod.constr(t??e._zod.def);return(!t||i?.parent)&&(n._zod.parent=e),n}function eq(e){if(!e)return{};if("string"==typeof e)return{error:()=>e};if(e?.message!==void 0){if(e?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");e.error=e.message}return(delete e.message,"string"==typeof e.error)?{...e,error:()=>e.error}:e}function eU(e){return"bigint"==typeof e?e.toString()+"n":"string"==typeof e?`"${e}"`:`${e}`}function eV(e,t=0){if(!0===e.aborted)return!0;for(let i=t;i(t.path??(t.path=[]),t.path.unshift(e),t))}function eD(e){return"string"==typeof e?e:e?.message}function eZ(e,t,i){let n={...e,path:e.path??[]};return e.message||(n.message=eD(e.inst?._zod.def?.error?.(e))??eD(t?.error?.(e))??eD(i.customError?.(e))??eD(i.localeError?.(e))??"Invalid input"),delete n.inst,delete n.continue,t?.reportInput||delete n.input,n}function eQ(e){return Array.isArray(e)?"array":"string"==typeof e?"string":"unknown"}let eL=(e,t)=>{e.name="$ZodError",Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,ex,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},eJ=e_("$ZodError",eL),eH=e_("$ZodError",eL,{Parent:Error}),eK=(e=eH,(t,i,n,r)=>{let s=n?Object.assign(n,{async:!1}):{async:!1},o=t._zod.run({value:i,issues:[]},s);if(o instanceof Promise)throw new ez;if(o.issues.length){let t=new(r?.Err??e)(o.issues.map(e=>eZ(e,s,eS())));throw eT(t,r?.callee),t}return o.value}),eW=(t=eH,async(e,i,n,r)=>{let s=n?Object.assign(n,{async:!0}):{async:!0},o=e._zod.run({value:i,issues:[]},s);if(o instanceof Promise&&(o=await o),o.issues.length){let e=new(r?.Err??t)(o.issues.map(e=>eZ(e,s,eS())));throw eT(e,r?.callee),e}return o.value}),eG=(i=eH,(e,t,n)=>{let r=n?{...n,async:!1}:{async:!1},s=e._zod.run({value:t,issues:[]},r);if(s instanceof Promise)throw new ez;return s.issues.length?{success:!1,error:new(i??eJ)(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eX=(n=eH,async(e,t,i)=>{let r=i?Object.assign(i,{async:!0}):{async:!0},s=e._zod.run({value:t,issues:[]},r);return s instanceof Promise&&(s=await s),s.issues.length?{success:!1,error:new n(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eY=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,e0="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",e1=RegExp(`^${e0}$`);function e2(e){let t="(?:[01]\\d|2[0-3]):[0-5]\\d";return"number"==typeof e.precision?-1===e.precision?`${t}`:0===e.precision?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}let e6=/^-?\d+(?:\.\d+)?$/,e3=/^(?:true|false)$/i,e4=/^null$/i,e8=e_("$ZodCheck",(e,t)=>{var i;e._zod??(e._zod={}),e._zod.def=t,(i=e._zod).onattach??(i.onattach=[])}),e5=e_("$ZodCheckMinLength",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag.minimum??-1/0;t.minimum>i&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=i=>{let n=i.value;if(n.length>=t.minimum)return;let r=eQ(n);i.issues.push({origin:r,code:"too_small",minimum:t.minimum,inclusive:!0,input:n,inst:e,continue:!t.abort})}}),e9=e_("$ZodCheckLengthEquals",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag;i.minimum=t.length,i.maximum=t.length,i.length=t.length}),e._zod.check=i=>{let n=i.value,r=n.length;if(r===t.length)return;let s=eQ(n),o=r>t.length;i.issues.push({origin:s,...o?{code:"too_big",maximum:t.length}:{code:"too_small",minimum:t.length},inclusive:!0,exact:!0,input:i.value,inst:e,continue:!t.abort})}}),e7=e_("$ZodCheckStringFormat",(e,t)=>{var i,n;e8.init(e,t),e._zod.onattach.push(e=>{let i=e._zod.bag;i.format=t.format,t.pattern&&(i.patterns??(i.patterns=new Set),i.patterns.add(t.pattern))}),t.pattern?(i=e._zod).check??(i.check=i=>{t.pattern.lastIndex=0,t.pattern.test(i.value)||i.issues.push({origin:"string",code:"invalid_format",format:t.format,input:i.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(n=e._zod).check??(n.check=()=>{})}),te={major:4,minor:3,patch:6},tt=e_("$ZodType",(e,t)=>{var i;e??(e={}),e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=te;let n=[...e._zod.def.checks??[]];for(let t of(e._zod.traits.has("$ZodCheck")&&n.unshift(e),n))for(let i of t._zod.onattach)i(e);if(0===n.length)(i=e._zod).deferred??(i.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,i)=>{let n,r=eV(e);for(let s of t){if(s._zod.def.when){if(!s._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=s._zod.check(e);if(o instanceof Promise&&i?.async===!1)throw new ez;if(n||o instanceof Promise)n=(n??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||(r=eV(e,t)))});else{if(e.issues.length===t)continue;r||(r=eV(e,t))}}return n?n.then(()=>e):e},i=(i,r,s)=>{if(eV(i))return i.aborted=!0,i;let o=t(r,n,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(t=>e._zod.parse(t,s))}return e._zod.parse(o,s)};e._zod.run=(r,s)=>{if(s.skipChecks)return e._zod.parse(r,s);if("backward"===s.direction){let t=e._zod.parse({value:r.value,issues:[]},{...s,skipChecks:!0});return t instanceof Promise?t.then(e=>i(e,r,s)):i(t,r,s)}let o=e._zod.parse(r,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(e=>t(e,n,s))}return t(o,n,s)}}eP(e,"~standard",()=>({validate:t=>{try{let i=eG(e,t);return i.success?{value:i.data}:{issues:i.error?.issues}}catch(i){return eX(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:"zod",version:1}))}),ti=e_("$ZodString",(e,t)=>{var i;let n;tt.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??(n=(i=e._zod.bag)?`[\\s\\S]{${i?.minimum??0},${i?.maximum??""}}`:"[\\s\\S]*",RegExp(`^${n}$`)),e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=String(i.value)}catch(e){}return"string"==typeof i.value||i.issues.push({expected:"string",code:"invalid_type",input:i.value,inst:e}),i}}),tn=e_("$ZodStringFormat",(e,t)=>{e7.init(e,t),ti.init(e,t)}),tr=e_("$ZodISODateTime",(e,t)=>{let i,n,r;t.pattern??(i=e2({precision:t.precision}),n=["Z"],t.local&&n.push(""),t.offset&&n.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)"),r=`${i}(?:${n.join("|")})`,t.pattern=RegExp(`^${e0}T(?:${r})$`)),tn.init(e,t)}),ts=((e,t)=>{t.pattern??(t.pattern=e1),tn.init(e,t)},e_("$ZodNumber",(e,t)=>{tt.init(e,t),e._zod.pattern=e._zod.bag.pattern??e6,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=Number(i.value)}catch(e){}let r=i.value;if("number"==typeof r&&!Number.isNaN(r)&&Number.isFinite(r))return i;let s="number"==typeof r?Number.isNaN(r)?"NaN":Number.isFinite(r)?void 0:"Infinity":void 0;return i.issues.push({expected:"number",code:"invalid_type",input:r,inst:e,...s?{received:s}:{}}),i}})),to=e_("$ZodBoolean",(e,t)=>{tt.init(e,t),e._zod.pattern=e3,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=!!i.value}catch(e){}let r=i.value;return"boolean"==typeof r||i.issues.push({expected:"boolean",code:"invalid_type",input:r,inst:e}),i}}),ta=e_("$ZodNull",(e,t)=>{tt.init(e,t),e._zod.pattern=e4,e._zod.values=new Set([null]),e._zod.parse=(t,i)=>{let n=t.value;return null===n||t.issues.push({expected:"null",code:"invalid_type",input:n,inst:e}),t}}),tl=e_("$ZodAny",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e}),tu=e_("$ZodUnknown",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e});function tc(e,t,i){e.issues.length&&t.issues.push(...eN(i,e.issues)),t.value[i]=e.value}let td=e_("$ZodArray",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!Array.isArray(r))return i.issues.push({expected:"array",code:"invalid_type",input:r,inst:e}),i;i.value=Array(r.length);let s=[];for(let e=0;etc(t,i,e))):tc(a,i,e)}return s.length?Promise.all(s).then(()=>i):i}});function tp(e,t,i,n,r){if(e.issues.length){if(r&&!(i in n))return;t.issues.push(...eN(i,e.issues))}void 0===e.value?i in n&&(t.value[i]=void 0):t.value[i]=e.value}let tf=e_("$ZodObject",(e,t)=>{let i;tt.init(e,t);let n=Object.getOwnPropertyDescriptor(t,"shape");if(!n?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let i={...e};return Object.defineProperty(t,"shape",{value:i}),i}})}let r=ek(()=>(function(e){var t;let i=Object.keys(e.shape);for(let t of i)if(!e.shape?.[t]?._zod?.traits?.has("$ZodType"))throw Error(`Invalid element at key "${t}": expected a Zod schema`);let n=Object.keys(t=e.shape).filter(e=>"optional"===t[e]._zod.optin&&"optional"===t[e]._zod.optout);return{...e,keys:i,keySet:new Set(i),numKeys:i.length,optionalKeys:new Set(n)}})(t));eP(e._zod,"propValues",()=>{let e=t.shape,i={};for(let t in e){let n=e[t]._zod;if(n.values)for(let e of(i[t]??(i[t]=new Set),n.values))i[t].add(e)}return i});let s=t.catchall;e._zod.parse=(t,n)=>{i??(i=r.value);let o=t.value;if(!eF(o))return t.issues.push({expected:"object",code:"invalid_type",input:o,inst:e}),t;t.value={};let a=[],l=i.shape;for(let e of i.keys){let i=l[e],r="optional"===i._zod.optout,s=i._zod.run({value:o[e],issues:[]},n);s instanceof Promise?a.push(s.then(i=>tp(i,t,e,o,r))):tp(s,t,e,o,r)}return s?function(e,t,i,n,r,s){let o=[],a=r.keySet,l=r.catchall._zod,u=l.def.type,c="optional"===l.optout;for(let r in t){if(a.has(r))continue;if("never"===u){o.push(r);continue}let s=l.run({value:t[r],issues:[]},n);s instanceof Promise?e.push(s.then(e=>tp(e,i,r,t,c))):tp(s,i,r,t,c)}return(o.length&&i.issues.push({code:"unrecognized_keys",keys:o,input:t,inst:s}),e.length)?Promise.all(e).then(()=>i):i}(a,o,t,n,r.value,e):a.length?Promise.all(a).then(()=>t):t}});function th(e,t,i,n){for(let i of e)if(0===i.issues.length)return t.value=i.value,t;let r=e.filter(e=>!eV(e));return 1===r.length?(t.value=r[0].value,r[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:i,errors:e.map(e=>e.issues.map(e=>eZ(e,n,eS())))}),t)}let tv=e_("$ZodUnion",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.options.some(e=>"optional"===e._zod.optin)?"optional":void 0),eP(e._zod,"optout",()=>t.options.some(e=>"optional"===e._zod.optout)?"optional":void 0),eP(e._zod,"values",()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),eP(e._zod,"pattern",()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>eI(e.source)).join("|")})$`)}});let i=1===t.options.length,n=t.options[0]._zod.run;e._zod.parse=(r,s)=>{if(i)return n(r,s);let o=!1,a=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},s);if(t instanceof Promise)a.push(t),o=!0;else{if(0===t.issues.length)return t;a.push(t)}}return o?Promise.all(a).then(t=>th(t,r,e,s)):th(a,r,e,s)}}),ty=e_("$ZodDiscriminatedUnion",(e,t)=>{t.inclusive=!1,tv.init(e,t);let i=e._zod.parse;eP(e._zod,"propValues",()=>{let e={};for(let i of t.options){let n=i._zod.propValues;if(!n||0===Object.keys(n).length)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(i)}"`);for(let[t,i]of Object.entries(n))for(let n of(e[t]||(e[t]=new Set),i))e[t].add(n)}return e});let n=ek(()=>{let e=t.options,i=new Map;for(let n of e){let e=n._zod.propValues?.[t.discriminator];if(!e||0===e.size)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(n)}"`);for(let t of e){if(i.has(t))throw Error(`Duplicate discriminator value "${String(t)}"`);i.set(t,n)}}return i});e._zod.parse=(r,s)=>{let o=r.value;if(!eF(o))return r.issues.push({code:"invalid_type",expected:"object",input:o,inst:e}),r;let a=n.value.get(o?.[t.discriminator]);return a?a._zod.run(r,s):t.unionFallback?i(r,s):(r.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:t.discriminator,input:o,path:[t.discriminator],inst:e}),r)}}),tg=e_("$ZodRecord",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!eC(r))return i.issues.push({expected:"record",code:"invalid_type",input:r,inst:e}),i;let s=[],o=t.keyType._zod.values;if(o){let a;i.value={};let l=new Set;for(let e of o)if("string"==typeof e||"number"==typeof e||"symbol"==typeof e){l.add("number"==typeof e?e.toString():e);let o=t.valueType._zod.run({value:r[e],issues:[]},n);o instanceof Promise?s.push(o.then(t=>{t.issues.length&&i.issues.push(...eN(e,t.issues)),i.value[e]=t.value})):(o.issues.length&&i.issues.push(...eN(e,o.issues)),i.value[e]=o.value)}for(let e in r)l.has(e)||(a=a??[]).push(e);a&&a.length>0&&i.issues.push({code:"unrecognized_keys",input:r,inst:e,keys:a})}else for(let o of(i.value={},Reflect.ownKeys(r))){if("__proto__"===o)continue;let a=t.keyType._zod.run({value:o,issues:[]},n);if(a instanceof Promise)throw Error("Async schemas not supported in object keys currently");if("string"==typeof o&&e6.test(o)&&a.issues.length){let e=t.keyType._zod.run({value:Number(o),issues:[]},n);if(e instanceof Promise)throw Error("Async schemas not supported in object keys currently");0===e.issues.length&&(a=e)}if(a.issues.length){"loose"===t.mode?i.value[o]=r[o]:i.issues.push({code:"invalid_key",origin:"record",issues:a.issues.map(e=>eZ(e,n,eS())),input:o,path:[o],inst:e});continue}let l=t.valueType._zod.run({value:r[o],issues:[]},n);l instanceof Promise?s.push(l.then(e=>{e.issues.length&&i.issues.push(...eN(o,e.issues)),i.value[a.value]=e.value})):(l.issues.length&&i.issues.push(...eN(o,l.issues)),i.value[a.value]=l.value)}return s.length?Promise.all(s).then(()=>i):i}}),tm=e_("$ZodEnum",(e,t)=>{var i;let n;tt.init(e,t);let r=(n=Object.values(i=t.entries).filter(e=>"number"==typeof e),Object.entries(i).filter(([e,t])=>-1===n.indexOf(+e)).map(([e,t])=>t)),s=new Set(r);e._zod.values=s,e._zod.pattern=RegExp(`^(${r.filter(e=>eR.has(typeof e)).map(e=>"string"==typeof e?eM(e):e.toString()).join("|")})$`),e._zod.parse=(t,i)=>{let n=t.value;return s.has(n)||t.issues.push({code:"invalid_value",values:r,input:n,inst:e}),t}}),tb=e_("$ZodLiteral",(e,t)=>{if(tt.init(e,t),0===t.values.length)throw Error("Cannot create literal schema with no valid values");let i=new Set(t.values);e._zod.values=i,e._zod.pattern=RegExp(`^(${t.values.map(e=>"string"==typeof e?eM(e):e?eM(e.toString()):String(e)).join("|")})$`),e._zod.parse=(n,r)=>{let s=n.value;return i.has(s)||n.issues.push({code:"invalid_value",values:t.values,input:s,inst:e}),n}});function tw(e,t){return e.issues.length&&void 0===t?{issues:[],value:void 0}:e}let t_=e_("$ZodOptional",(e,t)=>{tt.init(e,t),e._zod.optin="optional",e._zod.optout="optional",eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)})?$`):void 0}),e._zod.parse=(e,i)=>{if("optional"===t.innerType._zod.optin){let n=t.innerType._zod.run(e,i);return n instanceof Promise?n.then(t=>tw(t,e.value)):tw(n,e.value)}return void 0===e.value?e:t.innerType._zod.run(e,i)}}),tz=e_("$ZodNullable",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.innerType._zod.optin),eP(e._zod,"optout",()=>t.innerType._zod.optout),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)}|null)$`):void 0}),eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,i)=>null===e.value?e:t.innerType._zod.run(e,i)}),tO=e_("$ZodPrefault",(e,t)=>{tt.init(e,t),e._zod.optin="optional",eP(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(e,i)=>("backward"===i.direction||void 0===e.value&&(e.value=t.defaultValue),t.innerType._zod.run(e,i))}),tS=e_("$ZodLazy",(e,t)=>{tt.init(e,t),eP(e._zod,"innerType",()=>t.getter()),eP(e._zod,"pattern",()=>e._zod.innerType?._zod?.pattern),eP(e._zod,"propValues",()=>e._zod.innerType?._zod?.propValues),eP(e._zod,"optin",()=>e._zod.innerType?._zod?.optin??void 0),eP(e._zod,"optout",()=>e._zod.innerType?._zod?.optout??void 0),e._zod.parse=(t,i)=>e._zod.innerType._zod.run(t,i)});Symbol("ZodOutput"),Symbol("ZodInput");function tE(e,t){return new e5({check:"min_length",...eq(t),minimum:e})}(a=globalThis).__zod_globalRegistry??(a.__zod_globalRegistry=new class e{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let i=t[0];return this._map.set(e,i),i&&"object"==typeof i&&"id"in i&&this._idmap.set(i.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&"object"==typeof t&&"id"in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let i={...this.get(t)??{}};delete i.id;let n={...i,...this._map.get(e)};return Object.keys(n).length?n:void 0}return this._map.get(e)}has(e){return this._map.has(e)}});let tx=e_("ZodMiniType",(e,t)=>{if(!e._zod)throw Error("Uninitialized schema in ZodMiniType.");tt.init(e,t),e.def=t,e.type=t.type,e.parse=(t,i)=>eK(e,t,i,{callee:e.parse}),e.safeParse=(t,i)=>eG(e,t,i),e.parseAsync=async(t,i)=>eW(e,t,i,{callee:e.parseAsync}),e.safeParseAsync=async(t,i)=>eX(e,t,i),e.check=(...i)=>e.clone({...t,checks:[...t.checks??[],...i.map(e=>"function"==typeof e?{_zod:{check:e,def:{check:"custom"},onattach:[]}}:e)]},{parent:!0}),e.with=e.check,e.clone=(t,i)=>eB(e,t,i),e.brand=()=>e,e.register=(t,i)=>(t.add(e,i),e),e.apply=t=>t(e)}),tk=e_("ZodMiniString",(e,t)=>{ti.init(e,t),tx.init(e,t)});function tI(e){return new tk({type:"string",...eq(e)})}let t$=e_("ZodMiniStringFormat",(e,t)=>{tn.init(e,t),tk.init(e,t)}),tP=e_("ZodMiniNumber",(e,t)=>{ts.init(e,t),tx.init(e,t)});function tj(e){return new tP({type:"number",checks:[],...eq(e)})}let tA=e_("ZodMiniBoolean",(e,t)=>{to.init(e,t),tx.init(e,t)});function tT(e){return new tA({type:"boolean",...eq(e)})}let tF=e_("ZodMiniNull",(e,t)=>{ta.init(e,t),tx.init(e,t)});function tC(e){return new tF({type:"null",...eq(e)})}let tR=e_("ZodMiniAny",(e,t)=>{tl.init(e,t),tx.init(e,t)});function tM(){return new tR({type:"any"})}let tB=e_("ZodMiniUnknown",(e,t)=>{tu.init(e,t),tx.init(e,t)}),tq=e_("ZodMiniArray",(e,t)=>{td.init(e,t),tx.init(e,t)});function tU(e,t){return new tq({type:"array",element:e,...eq(t)})}let tV=e_("ZodMiniObject",(e,t)=>{tf.init(e,t),tx.init(e,t),eP(e,"shape",()=>t.shape)});function tN(e,t){return new tV({type:"object",shape:e??{},...eq(t)})}function tD(e,t){if(!eC(t))throw Error("Invalid input to extend: expected a plain object");let i=e._zod.def.checks;if(i&&i.length>0){let i=e._zod.def.shape;for(let e in t)if(void 0!==Object.getOwnPropertyDescriptor(i,e))throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}let n=eA(e._zod.def,{get shape(){let i={...e._zod.def.shape,...t};return ej(this,"shape",i),i}});return eB(e,n)}function tZ(e,t){return e.clone({...e._zod.def,catchall:t})}let tQ=e_("ZodMiniUnion",(e,t)=>{tv.init(e,t),tx.init(e,t)});function tL(e,t){return new tQ({type:"union",options:e,...eq(t)})}let tJ=e_("ZodMiniDiscriminatedUnion",(e,t)=>{ty.init(e,t),tx.init(e,t)});function tH(e,t,i){return new tJ({type:"union",options:t,discriminator:e,...eq(i)})}let tK=e_("ZodMiniRecord",(e,t)=>{tg.init(e,t),tx.init(e,t)});function tW(e,t,i){return new tK({type:"record",keyType:e,valueType:t,...eq(i)})}let tG=e_("ZodMiniEnum",(e,t)=>{tm.init(e,t),tx.init(e,t),e.options=Object.values(t.entries)});function tX(e,t){return new tG({type:"enum",entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...eq(t)})}let tY=e_("ZodMiniLiteral",(e,t)=>{tb.init(e,t),tx.init(e,t)});function t0(e,t){return new tY({type:"literal",values:Array.isArray(e)?e:[e],...eq(t)})}let t1=e_("ZodMiniOptional",(e,t)=>{t_.init(e,t),tx.init(e,t)});function t2(e){return new t1({type:"optional",innerType:e})}let t6=e_("ZodMiniNullable",(e,t)=>{tz.init(e,t),tx.init(e,t)});function t3(e){return new t6({type:"nullable",innerType:e})}let t4=e_("ZodMiniPrefault",(e,t)=>{tO.init(e,t),tx.init(e,t)});function t8(e,t){return new t4({type:"prefault",innerType:e,get defaultValue(){return"function"==typeof t?t():eC(t)?{...t}:Array.isArray(t)?[...t]:t}})}let t5=e_("ZodMiniLazy",(e,t)=>{tS.init(e,t),tx.init(e,t)});function t9(){let e=new t5({type:"lazy",getter:()=>tL([tI(),tj(),tT(),tC(),tU(e),tW(tI(),e)])});return e}let t7=e_("ZodMiniISODateTime",(e,t)=>{tr.init(e,t),t$.init(e,t)});function ie(e){return new t7({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...eq(e)})}let it=tZ(tN({}),t9()),ii=tN({sys:tN({type:t0("Link"),linkType:tI(),id:tI()})}),ir=tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:tI()})}),is=tN({sys:tN({type:t0("Link"),linkType:t0("Environment"),id:tI()})}),io=tN({sys:tN({type:t0("Link"),linkType:t0("Space"),id:tI()})}),ia=tN({sys:tN({type:t0("Link"),linkType:t0("TaxonomyConcept"),id:tI()})}),il=tN({sys:tN({type:t0("Link"),linkType:t0("Tag"),id:tI()})}),iu=tN({type:t0("Entry"),contentType:ir,publishedVersion:tj(),id:tI(),createdAt:tM(),updatedAt:tM(),locale:t2(tI()),revision:tj(),space:io,environment:is}),ic=tN({fields:it,metadata:tN({tags:tU(il),concepts:t2(tU(ia))}),sys:iu}),id=tD(it,{nt_audience_id:tI(),nt_name:t2(tI()),nt_description:t2(tI())}),ip=tD(ic,{fields:id});tN({contentTypeId:t0("nt_audience"),fields:id});let ih=tD(ic,{fields:tN({nt_name:tI(),nt_fallback:t2(tI()),nt_mergetag_id:tI()}),sys:tD(iu,{contentType:tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:t0("nt_mergetag")})})})}),iv=tN({id:tI(),hidden:t2(tT())}),iy=tN({type:t2(t0("EntryReplacement")),baseline:iv,variants:tU(iv)}),ig=tN({value:tL([tI(),tT(),tC(),tj(),tW(tI(),t9())])}),im=tX(["Boolean","Number","Object","String"]),ib=tH("type",[iy,tN({type:t0("InlineVariable"),key:tI(),valueType:im,baseline:ig,variants:tU(ig)})]),iw=tU(ib),i_=tN({distribution:t2(tU(tj())),traffic:t2(tj()),components:t2(iw),sticky:t2(tT())}),iz=tL([t0("nt_experiment"),t0("nt_personalization")]),iO=tD(it,{nt_name:tI(),nt_description:t2(t3(tI())),nt_type:iz,nt_config:t2(t3(i_)),nt_audience:t2(t3(ip)),nt_variants:t2(tU(tL([ii,ic]))),nt_experience_id:tI()}),iS=tD(ic,{fields:iO});tN({contentTypeId:t0("nt_experience"),fields:iO});let iE=tD(ic,{fields:tD(it,{nt_experiences:tU(tL([ii,iS]))})});function ix(e){return iS.safeParse(e).success}function ik(e){return iE.safeParse(e).success}let iI=t2(tN({name:tI(),version:tI()})),i$=tN({name:t2(tI()),source:t2(tI()),medium:t2(tI()),term:t2(tI()),content:t2(tI())}),iP=tL([t0("mobile"),t0("server"),t0("web")]),ij=tW(tI(),tI()),iA=tN({latitude:tj(),longitude:tj()}),iT=tN({coordinates:t2(iA),city:t2(tI()),postalCode:t2(tI()),region:t2(tI()),regionCode:t2(tI()),country:t2(tI()),countryCode:t2(tI().check(new e9({check:"length_equals",...eq(void 0),length:2}))),continent:t2(tI()),timezone:t2(tI())}),iF=tN({name:tI(),version:tI()}),iC=tZ(tN({path:tI(),query:ij,referrer:tI(),search:tI(),title:t2(tI()),url:tI()}),t9()),iR=tW(tI(),t9()),iM=tZ(tN({name:tI()}),t9()),iB=tW(tI(),t9()),iq=tN({app:iI,campaign:i$,gdpr:tN({isConsentGiven:tT()}),library:iF,locale:tI(),location:t2(iT),userAgent:t2(tI())}),iU=tN({channel:iP,context:tD(iq,{page:t2(iC),screen:t2(iM)}),messageId:tI(),originalTimestamp:ie(),sentAt:ie(),timestamp:ie(),userId:t2(tI())}),iV=tD(iU,{type:t0("alias")}),iN=tD(iU,{type:t0("group")}),iD=tD(iU,{type:t0("identify"),traits:iB}),iZ=tD(iq,{page:iC}),iQ=tD(iU,{type:t0("page"),name:t2(tI()),properties:iC,context:iZ}),iL=tD(iq,{screen:iM}),iJ=tD(iU,{type:t0("screen"),name:tI(),properties:t2(iR),context:iL}),iH=tD(iU,{type:t0("track"),event:tI(),properties:iR}),iK=tD(iU,{componentType:tL([t0("Entry"),t0("Variable")]),componentId:tI(),experienceId:t2(tI()),variantIndex:tj()}),iW=tD(iK,{type:t0("component"),viewDurationMs:t2(tj()),viewId:t2(tI())}),iG={anonymousId:tI()},iX=tU(tH("type",[tD(iV,iG),tD(iW,iG),tD(iN,iG),tD(iD,iG),tD(iQ,iG),tD(iJ,iG),tD(iH,iG)])),iY=tH("type",[iV,iW,iN,iD,iQ,iJ,iH]),i0=tU(iY),i1=tN({features:t2(tU(tI()))}),i2=tN({events:i0.check(tE(1)),options:t2(i1)}),i6=tN({events:iX.check(tE(1)),options:t2(i1)}),i3=tN({id:tI(),isReturningVisitor:tT(),landingPage:iC,count:tj(),activeSessionLength:tj(),averageSessionLength:tj()}),i4=tN({id:tI(),stableId:tI(),random:tj(),audiences:tU(tI()),traits:iB,location:iT,session:i3}),i8=tZ(tN({id:tI()}),t9()),i5=tN({data:tN(),message:tI(),error:t3(tT())}),i9=tD(i5,{data:tN({profiles:t2(tU(i4))})}),i7=tN({key:tI(),type:tL([tX(["Variable"]),tI()]),meta:tN({experienceId:tI(),variantIndex:tj()})}),ne=tL([tI(),tT(),tC(),tj(),tW(tI(),t9())]);tD(i7,{type:tI(),value:new tB({type:"unknown"})});let nt=tU(tH("type",[tD(i7,{type:t0("Variable"),value:ne})])),ni=tU(tN({experienceId:tI(),variantIndex:tj(),variants:tW(tI(),tI()),sticky:t2(t8(tT(),!1))})),nn=tD(i5,{data:tN({profile:i4,experiences:ni,changes:nt})}),nr=tH("type",[iW,tD(iK,{type:t0("component_click")}),tD(iK,{type:t0("component_hover"),hoverDurationMs:tj(),hoverId:tI()})]),ns=tN({profile:i8,events:tU(nr)}),no=tU(ns);function na(e,t){let i=e.safeParse(t);if(i.success)return i.data;throw Error(function(e){let t=[];for(let i of[...e.issues].sort((e,t)=>(e.path??[]).length-(t.path??[]).length))t.push(`✖ ${i.message}`),i.path?.length&&t.push(` → at ${function(e){let t=[];for(let i of e.map(e=>"object"==typeof e?e.key:e))"number"==typeof i?t.push(`[${i}]`):"symbol"==typeof i?t.push(`[${JSON.stringify(String(i))}]`):/[^\w$]/.test(i)?t.push(`[${JSON.stringify(i)}]`):(t.length&&t.push("."),t.push(i));return t.join("")}(i.path)}`);return t.join("\n")}(i.error))}eS({localeError:(r={string:{unit:"characters",verb:"to have"},file:{unit:"bytes",verb:"to have"},array:{unit:"items",verb:"to have"},set:{unit:"items",verb:"to have"},map:{unit:"entries",verb:"to have"}},s={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",mac:"MAC address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},o={nan:"NaN"},e=>{switch(e.code){case"invalid_type":{let t=o[e.expected]??e.expected,i=function(e){let t=typeof e;switch(t){case"number":return Number.isNaN(e)?"nan":"number";case"object":if(null===e)return"null";if(Array.isArray(e))return"array";if(e&&Object.getPrototypeOf(e)!==Object.prototype&&"constructor"in e&&e.constructor)return e.constructor.name}return t}(e.input),n=o[i]??i;return`Invalid input: expected ${t}, received ${n}`}case"invalid_value":if(1===e.values.length)return`Invalid input: expected ${eU(e.values[0])}`;return`Invalid option: expected one of ${eE(e.values,"|")}`;case"too_big":{let t=e.inclusive?"<=":"<",i=r[e.origin]??null;if(i)return`Too big: expected ${e.origin??"value"} to have ${t}${e.maximum.toString()} ${i.unit??"elements"}`;return`Too big: expected ${e.origin??"value"} to be ${t}${e.maximum.toString()}`}case"too_small":{let t=e.inclusive?">=":">",i=r[e.origin]??null;if(i)return`Too small: expected ${e.origin} to have ${t}${e.minimum.toString()} ${i.unit}`;return`Too small: expected ${e.origin} to be ${t}${e.minimum.toString()}`}case"invalid_format":if("starts_with"===e.format)return`Invalid string: must start with "${e.prefix}"`;if("ends_with"===e.format)return`Invalid string: must end with "${e.suffix}"`;if("includes"===e.format)return`Invalid string: must include "${e.includes}"`;if("regex"===e.format)return`Invalid string: must match pattern ${e.pattern}`;return`Invalid ${s[e.format]??e.format}`;case"not_multiple_of":return`Invalid number: must be a multiple of ${e.divisor}`;case"unrecognized_keys":return`Unrecognized key${e.keys.length>1?"s":""}: ${eE(e.keys,", ")}`;case"invalid_key":return`Invalid key in ${e.origin}`;case"invalid_union":default:return"Invalid input";case"invalid_element":return`Invalid value in ${e.origin}`}})});let nl=new class{name="@contentful/optimization";PREFIX_PARTS=["Ctfl","O10n"];DELIMITER=":";sinks=[];assembleLocationPrefix(e){return`[${[...this.PREFIX_PARTS,e].join(this.DELIMITER)}]`}addSink(e){this.sinks=[...this.sinks.filter(t=>t.name!==e.name),e]}removeSink(e){this.sinks=this.sinks.filter(t=>t.name!==e)}removeSinks(){this.sinks=[]}debug(e,t,...i){this.emit("debug",e,t,...i)}info(e,t,...i){this.emit("info",e,t,...i)}log(e,t,...i){this.emit("log",e,t,...i)}warn(e,t,...i){this.emit("warn",e,t,...i)}error(e,t,...i){this.emit("error",e,t,...i)}fatal(e,t,...i){this.emit("fatal",e,t,...i)}emit(e,t,i,...n){this.onLogEvent({name:this.name,level:e,messages:[`${this.assembleLocationPrefix(t)} ${String(i)}`,...n]})}onLogEvent(e){this.sinks.forEach(t=>{t.ingest(e)})}};function nu(e){return{debug:(t,...i)=>{nl.debug(e,t,...i)},info:(t,...i)=>{nl.info(e,t,...i)},log:(t,...i)=>{nl.log(e,t,...i)},warn:(t,...i)=>{nl.warn(e,t,...i)},error:(t,...i)=>{nl.error(e,t,...i)},fatal:(t,...i)=>{nl.fatal(e,t,...i)}}}let nc={fatal:60,error:50,warn:40,info:30,debug:20,log:10},nd=class{},np={debug:(...e)=>{console.debug(...e)},info:(...e)=>{console.info(...e)},log:(...e)=>{console.log(...e)},warn:(...e)=>{console.warn(...e)},error:(...e)=>{console.error(...e)},fatal:(...e)=>{console.error(...e)}};class nf extends nd{name="ConsoleLogSink";verbosity;constructor(e){super(),this.verbosity=e??"error"}ingest(e){nc[e.level]{i(void 0)},e),await t}let ng=nu("ApiClient:Timeout"),nm=nu("ApiClient:Fetch"),nb=function(e){try{let t=function({apiName:e="Optimization",fetchMethod:t=fetch,onRequestTimeout:i,requestTimeout:n=3e3}={}){return async(r,s)=>{let o=new AbortController,a=setTimeout(()=>{"function"==typeof i?i({apiName:e}):ng.error(`Request to "${r.toString()}" timed out`,Error("Request timeout")),o.abort()},n),l=await t(r,{...s,signal:o.signal});return clearTimeout(a),l}}(e);return function({apiName:e="Optimization",fetchMethod:t=fetch,intervalTimeout:i=0,onFailedAttempt:n,retries:r=1}={}){return async(s,o)=>{let a=new AbortController,l=r+1,u=function({apiName:e="Optimization",controller:t,fetchMethod:i=fetch,init:n,url:r}){return async()=>{try{let s=await i(r,n);if(503===s.status)throw new nv(`${e} API request to "${r.toString()}" failed with status: "[${s.status}] ${s.statusText}".`,503);if(!s.ok){let e=Error(`Request to "${r.toString()}" failed with status: [${s.status}] ${s.statusText} - traceparent: ${s.headers.get("traceparent")}`);nh.error("Request failed with non-OK status:",e),t.abort();return}return nh.debug(`Response from "${r.toString()}":`,s),s}catch(e){if(e instanceof nv&&503===e.status)throw e;nh.error(`Request to "${r.toString()}" failed:`,e),t.abort()}}}({apiName:e,controller:a,fetchMethod:t,init:o,url:s});for(let t=1;t<=l;t++)try{let e=await u();if(e)return e;break}catch(s){if(!(s instanceof nv)||503!==s.status)throw s;let r=l-t;if(n?.({apiName:e,error:s,attemptNumber:t,retriesLeft:r}),0===r)throw s;await ny(i)}throw Error(`${e} API request to "${s.toString()}" may not be retried.`)}}({...e,fetchMethod:t})}catch(e){throw e instanceof Error&&("AbortError"===e.name?nm.warn("Request aborted due to network issues. This request may not be retried."):nm.error("Request failed:",e)),e}},nw=nu("ApiClient"),n_=class{name;clientId;environment;fetch;constructor(e,{fetchOptions:t,clientId:i,environment:n}){this.clientId=i,this.environment=n??"main",this.name=e,this.fetch=nb({...t??{},apiName:e})}logRequestError(e,{requestName:t}){e instanceof Error&&("AbortError"===e.name?nw.warn(`[${this.name}] "${t}" request aborted due to network issues. This request may not be retried.`):nw.error(`[${this.name}] "${t}" request failed:`,e))}},nz=nu("ApiClient:Experience");class nO extends n_{baseUrl;enabledFeatures;ip;locale;plainText;preflight;constructor(e){super("Experience",e);const{baseUrl:t,enabledFeatures:i,ip:n,locale:r,plainText:s,preflight:o}=e;this.baseUrl=t||"https://experience.ninetailed.co/",this.enabledFeatures=i,this.ip=n,this.locale=r,this.plainText=s,this.preflight=o}async getProfile(e,t={}){if(!e)throw Error("Valid profile ID required.");let i="Get Profile";nz.info(`Sending "${i}" request`);try{let n=await this.fetch(this.constructUrl(`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,t),{method:"GET"}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await n.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async makeProfileMutationRequest({url:e,body:t,options:i}){return await this.fetch(this.constructUrl(e,i),{method:"POST",headers:this.constructHeaders(i),body:JSON.stringify(t),keepalive:!0})}async createProfile({events:e},t={}){let i="Create Profile";nz.info(`Sending "${i}" request`);let n=this.constructExperienceRequestBody(e,t);nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles`,body:n,options:t}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await e.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async updateProfile({profileId:e,events:t},i={}){if(!e)throw Error("Valid profile ID required.");let n="Update Profile";nz.info(`Sending "${n}" request`);let r=this.constructExperienceRequestBody(t,i);nz.debug(`"${n}" request body:`,r);try{let t=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,body:r,options:i}),{data:{changes:s,experiences:o,profile:a}}=na(nn,await t.json());return nz.debug(`"${n}" request successfully completed`),{changes:s,selectedOptimizations:o,profile:a}}catch(e){throw this.logRequestError(e,{requestName:n}),e}}async upsertProfile({profileId:e,events:t},i){return e?await this.updateProfile({profileId:e,events:t},i):await this.createProfile({events:t},i)}async upsertManyProfiles({events:e},t={}){let i="Upsert Many Profiles";nz.info(`Sending "${i}" request`);let n=na(i6,{events:e,options:this.constructBodyOptions(t)});nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/events`,body:n,options:{plainText:!1,...t}}),{data:{profiles:r}}=na(i9,await e.json());return nz.debug(`"${i}" request successfully completed`),r}catch(e){throw this.logRequestError(e,{requestName:i}),e}}constructUrl(e,t){let i=new URL(e,this.baseUrl),n=t.locale??this.locale,r=t.preflight??this.preflight;return n&&i.searchParams.set("locale",n),r&&i.searchParams.set("type","preflight"),i.toString()}constructHeaders({ip:e=this.ip,plainText:t=this.plainText}){let i=new Map;return e&&i.set("X-Force-IP",e),t??this.plainText??!0?i.set("Content-Type","text/plain"):i.set("Content-Type","application/json"),Object.fromEntries(i)}constructBodyOptions=({enabledFeatures:e=this.enabledFeatures})=>{let t={};return e&&Array.isArray(e)&&e.length>0?t.features=e:t.features=["ip-enrichment","location"],t};constructExperienceRequestBody(e,t){return i2.parse({events:na(i0,e),options:this.constructBodyOptions(t)})}}let nS=nu("ApiClient:Insights");class nE extends n_{baseUrl;beaconHandler;constructor(e){super("Insights",e);const{baseUrl:t,beaconHandler:i}=e;this.baseUrl=t||"https://ingest.insights.ninetailed.co/",this.beaconHandler=i}async sendBatchEvents(e,t={}){let{beaconHandler:i=this.beaconHandler}=t,n=new URL(`v1/organizations/${this.clientId}/environments/${this.environment}/events`,this.baseUrl),r=na(no,e);if("function"==typeof i){if(nS.debug("Queueing events via beaconHandler"),i(n,r))return!0;nS.warn("beaconHandler failed to queue events; events will be emitted immediately via fetch")}let s="Event Batches";nS.info(`Sending "${s}" request`),nS.debug(`"${s}" request body:`,r);try{return await this.fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r),keepalive:!0}),nS.debug(`"${s}" request successfully completed`),!0}catch(e){return this.logRequestError(e,{requestName:s}),!1}}}class nx{config;experience;insights;constructor(e){const{experience:t,insights:i,clientId:n,environment:r,fetchOptions:s}=e,o={clientId:n,environment:r,fetchOptions:s};this.config=o,this.experience=new nO({...o,...t}),this.insights=new nE({...o,...i})}}function nk(e){if(!e||"object"!=typeof e)return!1;let t=Object.getPrototypeOf(e);return(null===t||t===Object.prototype||null===Object.getPrototypeOf(t))&&"[object Object]"===Object.prototype.toString.call(e)}function nI(e){return nk(e)||Array.isArray(e)}let n$=tN({campaign:t2(i$),locale:t2(tI()),location:t2(iT),page:t2(iC),screen:t2(iM),userAgent:t2(tI())}),nP=tD(n$,{componentId:tI(),experienceId:t2(tI()),variantIndex:t2(tj())}),nj=tD(nP,{sticky:t2(tT()),viewId:tI(),viewDurationMs:tj()}),nA=tD(nP,{viewId:t2(tI()),viewDurationMs:t2(tj())}),nT=tD(nP,{hoverId:tI(),hoverDurationMs:tj()}),nF=tD(n$,{traits:t2(iB),userId:tI()}),nC=tD(n$,{properties:t2(function(e,t){var i=void 0;let n=e._zod.def.checks;if(n&&n.length>0)throw Error(".partial() cannot be used on object schemas containing refinements");let r=eA(e._zod.def,{get shape(){let t=e._zod.def.shape,n={...t};if(i)for(let e in i){if(!(e in t))throw Error(`Unrecognized key: "${e}"`);i[e]&&(n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e])}else for(let e in t)n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e];return ej(this,"shape",n),n},checks:[]});return eB(e,r)}(iC))}),nR=tD(n$,{name:tI(),properties:iR}),nM=tD(n$,{event:tI(),properties:t2(t8(iR,{}))}),nB={path:"",query:{},referrer:"",search:"",title:"",url:""},nq=class{app;channel;library;getLocale;getPageProperties;getUserAgent;constructor(e){const{app:t,channel:i,library:n,getLocale:r,getPageProperties:s,getUserAgent:o}=e;this.app=t,this.channel=i,this.library=n,this.getLocale=r??(()=>"en-US"),this.getPageProperties=s??(()=>nB),this.getUserAgent=o??(()=>void 0)}buildUniversalEventProperties({campaign:e={},locale:t,location:i,page:n,screen:r,userAgent:s}){let o=new Date().toISOString();return{channel:this.channel,context:{app:this.app,campaign:e,gdpr:{isConsentGiven:!0},library:this.library,locale:t??this.getLocale()??"en-US",location:i,page:n??this.getPageProperties(),screen:r,userAgent:s??this.getUserAgent()},messageId:crypto.randomUUID(),originalTimestamp:o,sentAt:o,timestamp:o}}buildEntryInteractionBase(e,t,i,n){return{...this.buildUniversalEventProperties(e),componentType:"Entry",componentId:t,experienceId:i,variantIndex:n??0}}buildView(e){let{componentId:t,viewId:i,experienceId:n,variantIndex:r,viewDurationMs:s,...o}=na(nj,e);return{...this.buildEntryInteractionBase(o,t,n,r),type:"component",viewId:i,viewDurationMs:s}}buildClick(e){let{componentId:t,experienceId:i,variantIndex:n,...r}=na(nP,e);return{...this.buildEntryInteractionBase(r,t,i,n),type:"component_click"}}buildHover(e){let{hoverId:t,componentId:i,experienceId:n,hoverDurationMs:r,variantIndex:s,...o}=na(nT,e);return{...this.buildEntryInteractionBase(o,i,n,s),type:"component_hover",hoverId:t,hoverDurationMs:r}}buildFlagView(e){let{componentId:t,experienceId:i,variantIndex:n,viewId:r,viewDurationMs:s,...o}=na(nA,e);return{...this.buildEntryInteractionBase(o,t,i,n),...void 0===s?{}:{viewDurationMs:s},...void 0===r?{}:{viewId:r},type:"component",componentType:"Variable"}}buildIdentify(e){let{traits:t={},userId:i,...n}=na(nF,e);return{...this.buildUniversalEventProperties(n),type:"identify",traits:t,userId:i}}buildPageView(e={}){let{properties:t={},...i}=na(nC,e),n=this.getPageProperties(),r=function e(t,i){let n=Object.keys(i);for(let r=0;re?e.reduce((e,{key:t,value:i})=>{let n="object"==typeof i&&null!==i&&"value"in i&&"object"==typeof i.value?i.value:i;return e[t]=n,e},{}):{}},nN=nu("Optimization"),nD="Could not resolve Merge Tag value:",nZ=(e,t)=>{if(!e||"object"!=typeof e)return;if(!t)return e;let i=e;for(let e of t.split(".").filter(Boolean)){if(!i||"object"!=typeof i&&"function"!=typeof i)return;i=Reflect.get(i,e)}return i},nQ={normalizeSelectors:e=>e.split("_").map((e,t,i)=>[i.slice(0,t).join("."),i.slice(t).join("_")].filter(e=>""!==e).join(".")),getValueFromProfile(e,t){let i=nQ.normalizeSelectors(e).find(e=>nZ(t,e));if(!i)return;let n=nZ(t,i);if(n&&("string"==typeof n||"number"==typeof n||"boolean"==typeof n))return`${n}`},resolve(e,t){if(!ih.safeParse(e).success)return void nN.warn(`${nD} supplied entry is not a Merge Tag entry`);let{fields:{nt_fallback:i}}=e;return i4.safeParse(t).success?nQ.getValueFromProfile(e.fields.nt_mergetag_id,t)??i:(nN.warn(`${nD} no valid profile`),i)}},nL=nu("Optimization"),nJ="Could not resolve optimized entry variant:",nH={getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ik(e))return e.fields.nt_experiences.filter(e=>ix(e)).find(e=>t.some(({experienceId:t})=>t===e.fields.nt_experience_id))},getSelectedOptimization({optimizationEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ix(e))return t.find(({experienceId:t})=>t===e.fields.nt_experience_id)},getSelectedVariant({optimizedEntry:e,optimizationEntry:t,selectedVariantIndex:i},n=!1){var r;if(!n&&(!ik(e)||!ix(t)))return;let s=(r=t.fields.nt_config,{distribution:r?.distribution===void 0?[]:[...r.distribution],traffic:r?.traffic??0,components:r?.components===void 0?[]:[...r.components],sticky:r?.sticky??!1}).components.filter(e=>("EntryReplacement"===e.type||void 0===e.type)&&!e.baseline.hidden).find(t=>t.baseline.id===e.sys.id)?.variants;if(s?.length)return s.at(i-1)},getSelectedVariantEntry({optimizationEntry:e,selectedVariant:t},i=!1){if(!i&&(!ix(e)||!iv.safeParse(t).success))return;let n=e.fields.nt_variants?.find(e=>e.sys.id===t.id);return ic.safeParse(n).success?n:void 0},resolve:function(e,t){if(nL.debug(`Resolving optimized entry for baseline entry ${e.sys.id}`),!t?.length)return nL.warn(`${nJ} no selectedOptimizations exist for the current profile`),{entry:e};if(!ik(e))return nL.warn(`${nJ} entry ${e.sys.id} is not optimized`),{entry:e};let i=nH.getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},!0);if(!i)return nL.warn(`${nJ} could not find an optimization entry for ${e.sys.id}`),{entry:e};let n=nH.getSelectedOptimization({optimizationEntry:i,selectedOptimizations:t},!0),r=n?.variantIndex??0;if(0===r)return nL.debug(`Resolved optimization entry for entry ${e.sys.id} is baseline`),{entry:e};let s=nH.getSelectedVariant({optimizedEntry:e,optimizationEntry:i,selectedVariantIndex:r},!0);if(!s)return nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e};let o=nH.getSelectedVariantEntry({optimizationEntry:i,selectedVariant:s},!0);return o?(nL.debug(`Entry ${e.sys.id} has been resolved to variant entry ${o.sys.id}`),{entry:o,selectedOptimization:n}):(nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e})}};class nK{api;eventBuilder;config;flagsResolver=nV;mergeTagValueResolver=nQ;optimizedEntryResolver=nH;interceptors={event:new nU,state:new nU};constructor(e,t={}){this.config=e;const{eventBuilder:i,logLevel:n,environment:r,clientId:s,fetchOptions:o}=e;nl.addSink(new nf(n));const a={clientId:s,environment:r,fetchOptions:o,experience:t.experience,insights:t.insights};this.api=new nx(a),this.eventBuilder=new nq(i??{channel:"server",library:{name:"@contentful/optimization-android-bridge",version:"0.0.0"}})}getFlag(e,t){return this.flagsResolver.resolve(t)[e]}resolveOptimizedEntry(e,t){return this.optimizedEntryResolver.resolve(e,t)}getMergeTagValue(e,t){return this.mergeTagValueResolver.resolve(e,t)}}let nW=nK;function nG(){}function nX(e,t){return function e(t,i,n,r,s,o,a){let l=a(t,i,n,r,s,o);if(void 0!==l)return l;if(typeof t==typeof i)switch(typeof t){case"bigint":case"string":case"boolean":case"symbol":case"undefined":case"function":return t===i;case"number":return t===i||Object.is(t,i)}return function t(i,n,r,s){if(Object.is(i,n))return!0;let o=F(i),a=F(n);if(o===q&&(o=L),a===q&&(a=L),o!==a)return!1;switch(o){case R:return i.toString()===n.toString();case M:{let e=i.valueOf(),t=n.valueOf();return e===t||Number.isNaN(e)&&Number.isNaN(t)}case B:case V:case U:return Object.is(i.valueOf(),n.valueOf());case C:return i.source===n.source&&i.flags===n.flags;case"[object Function]":return i===n}let l=(r=r??new Map).get(i),u=r.get(n);if(null!=l&&null!=u)return l===n;r.set(i,n),r.set(n,i);try{switch(o){case N:if(i.size!==n.size)return!1;for(let[t,o]of i.entries())if(!n.has(t)||!e(o,n.get(t),t,i,n,r,s))return!1;return!0;case D:{if(i.size!==n.size)return!1;let t=Array.from(i.values()),o=Array.from(n.values());for(let a=0;ae(l,t,void 0,i,n,r,s));if(-1===u)return!1;o.splice(u,1)}return!0}case Z:case H:case K:case W:case G:case"[object BigUint64Array]":case X:case Y:case ee:case"[object BigInt64Array]":case et:case ei:if(er(i)!==er(n)||i.length!==n.length)return!1;for(let t=0;t{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))})}return i}resolveOptimizedEntry(e,t=ey.value){return super.resolveOptimizedEntry(e,t)}getMergeTagValue(e,t=em.value){return super.getMergeTagValue(e,t)}async identify(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("identify",[e],this.eventBuilder.buildIdentify(i),t)}async page(e={}){let{profile:t,...i}=e;return await this.sendExperienceEvent("page",[e],this.eventBuilder.buildPageView(i),t)}async screen(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("screen",[e],this.eventBuilder.buildScreenView(i),t)}async track(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("track",[e],this.eventBuilder.buildTrack(i),t)}async trackView(e){let t,{profile:i,...n}=e;return e.sticky&&(t=await this.sendExperienceEvent("trackView",[e],this.eventBuilder.buildView(n),i)),await this.sendInsightsEvent("trackView",[e],this.eventBuilder.buildView(n),i),t}async trackClick(e){await this.sendInsightsEvent("trackClick",[e],this.eventBuilder.buildClick(e))}async trackHover(e){await this.sendInsightsEvent("trackHover",[e],this.eventBuilder.buildHover(e))}async trackFlagView(e){await this.sendInsightsEvent("trackFlagView",[e],this.eventBuilder.buildFlagView(e))}hasConsent(e){let{[e]:t}=n0,i=void 0!==t?this.allowedEventTypes.includes(t):this.allowedEventTypes.some(t=>t===e);return!!ed.value||i}onBlockedByConsent(e,t){nY.warn(`Event "${e}" was blocked due to lack of consent; payload: ${JSON.stringify(t)}`),this.reportBlockedEvent("consent",e,t)}async sendExperienceEvent(e,t,i,n){return this.hasConsent(e)?await this.experienceQueue.send(i):void this.onBlockedByConsent(e,t)}async sendInsightsEvent(e,t,i,n){this.hasConsent(e)?await this.insightsQueue.send(i):this.onBlockedByConsent(e,t)}buildFlagViewBuilderArgs(e,t=eu.value){let i=t?.find(t=>t.key===e);return{componentId:e,experienceId:i?.meta.experienceId,variantIndex:i?.meta.variantIndex}}getFlagObservable(e){var t;let i,n=this.flagObservables.get(e);if(n)return n;let r=this.trackFlagView.bind(this),s=this.buildFlagViewBuilderArgs.bind(this),o=(t=ew.computed(()=>super.getFlag(e,eu.value)),i=el(t),{get current(){return i.current},subscribe(e){let t=!1,n=ea(i.current);return i.subscribe(i=>{t&&nX(n,i)||(t=!0,n=ea(i),e(i))})},subscribeOnce:e=>i.subscribeOnce(e)}),a={get current(){let{current:t}=o;return r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t},subscribe:t=>o.subscribe(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)}),subscribeOnce:t=>o.subscribeOnce(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)})};return this.flagObservables.set(e,a),a}reportBlockedEvent(e,t,i){let n={reason:e,method:t,args:i};try{this.onEventBlocked?.(n)}catch(e){nY.warn(`onEventBlocked callback failed for method "${t}"`,e)}ec.value=n}}let n2=n1,n6=(e,t)=>!Number.isFinite(e)||void 0===e||e<1?t:Math.floor(e),n3={flushIntervalMs:3e4,baseBackoffMs:500,maxBackoffMs:3e4,jitterRatio:.2,maxConsecutiveFailures:8,circuitOpenMs:12e4},n4="__ctfl_optimization_stateful_runtime_lock__",n8=()=>{let e=globalThis;return e[n4]??={owner:void 0},e[n4]},n5=e=>{let t=n8();t.owner===e&&(t.owner=void 0)};class n9{circuitOpenUntil=0;flushFailureCount=0;flushInFlight=!1;nextFlushAllowedAt=0;onCallbackError;onRetry;policy;retryTimer;constructor(e){const{onCallbackError:t,onRetry:i,policy:n}=e;this.policy=n,this.onRetry=i,this.onCallbackError=t}reset(){this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.flushInFlight=!1,this.nextFlushAllowedAt=0}clearScheduledRetry(){void 0!==this.retryTimer&&(clearTimeout(this.retryTimer),this.retryTimer=void 0)}shouldSkip(e){let{force:t,isOnline:i}=e;if(this.flushInFlight)return!0;if(t)return!1;if(!i)return!0;let n=Date.now();return!!(this.nextFlushAllowedAt>n)||!!(this.circuitOpenUntil>n)}markFlushStarted(){this.flushInFlight=!0}markFlushFinished(){this.flushInFlight=!1}handleFlushSuccess(){let{flushFailureCount:e}=this;this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.nextFlushAllowedAt=0,e<=0||this.safeInvoke("onFlushRecovered",{consecutiveFailures:e})}handleFlushFailure(e){let{queuedBatches:t,queuedEvents:i}=e;this.flushFailureCount+=1;let n=(e=>{let{consecutiveFailures:t,policy:{baseBackoffMs:i,jitterRatio:n,maxBackoffMs:r}}=e,s=Math.min(r,i*2**Math.max(0,t-1)),o=s*n*Math.random();return Math.round(s+o)})({consecutiveFailures:this.flushFailureCount,policy:this.policy}),r=Date.now(),s={consecutiveFailures:this.flushFailureCount,queuedBatches:t,queuedEvents:i,retryDelayMs:n};this.safeInvoke("onFlushFailure",s);let{circuitOpenUntil:o,nextFlushAllowedAt:a,openedCircuit:l,retryDelayMs:u}=(e=>{let{consecutiveFailures:t,failureTimestamp:i,retryDelayMs:n,policy:{maxConsecutiveFailures:r,circuitOpenMs:s}}=e;if(t{this.retryTimer=void 0,this.onRetry()},e)}safeInvoke(...e){let[t,i]=e;try{if("onFlushRecovered"===t)return void this.policy.onFlushRecovered?.(i);if("onCircuitOpen"===t)return void this.policy.onCircuitOpen?.(i);this.policy.onFlushFailure?.(i)}catch(e){this.onCallbackError?.(t,e)}}}let n7=nu("CoreStateful");class re{experienceApi;eventInterceptors;flushRuntime;getAnonymousId;offlineMaxEvents;onOfflineDrop;queuedExperienceEvents=new Set;stateInterceptors;constructor(e){const{experienceApi:t,eventInterceptors:i,flushPolicy:n,getAnonymousId:r,offlineMaxEvents:s,onOfflineDrop:o,stateInterceptors:a}=e;this.experienceApi=t,this.eventInterceptors=i,this.getAnonymousId=r,this.offlineMaxEvents=s,this.onOfflineDrop=o,this.stateInterceptors=a,this.flushRuntime=new n9({policy:n,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{n7.warn(`Experience flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}async send(e){let t=na(iY,await this.eventInterceptors.run(e));if(ep.value=t,ef.value)return await this.upsertProfile([t]);n7.debug(`Queueing ${t.type} event`,t),this.enqueueEvent(t)}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!ef.value}))return;if(0===this.queuedExperienceEvents.size)return void this.flushRuntime.clearScheduledRetry();n7.debug("Flushing offline Experience event queue");let i=Array.from(this.queuedExperienceEvents);this.flushRuntime.markFlushStarted();try{await this.tryUpsertQueuedEvents(i)?(i.forEach(e=>{this.queuedExperienceEvents.delete(e)}),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:+(this.queuedExperienceEvents.size>0),queuedEvents:this.queuedExperienceEvents.size})}finally{this.flushRuntime.markFlushFinished()}}enqueueEvent(e){let t=[];if(this.queuedExperienceEvents.size>=this.offlineMaxEvents){let e=this.queuedExperienceEvents.size-this.offlineMaxEvents+1;(t=this.dropOldestEvents(e)).length>0&&n7.warn(`Dropped ${t.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`)}this.queuedExperienceEvents.add(e),t.length>0&&this.invokeOfflineDropCallback({droppedCount:t.length,droppedEvents:t,maxEvents:this.offlineMaxEvents,queuedEvents:this.queuedExperienceEvents.size})}dropOldestEvents(e){let t=[];for(let i=0;i{nX(eu.value,t)||(eu.value=t),nX(em.value,i)||(em.value=i),nX(ey.value,n)||(ey.value=n)})}}let rt=nu("CoreStateful");class ri{eventInterceptors;flushIntervalMs;flushRuntime;insightsApi;queuedInsightsByProfile=new Map;insightsPeriodicFlushTimer;constructor(e){const{eventInterceptors:t,flushPolicy:i,insightsApi:n}=e,{flushIntervalMs:r}=i;this.eventInterceptors=t,this.flushIntervalMs=r,this.insightsApi=n,this.flushRuntime=new n9({policy:i,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{rt.warn(`Insights flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}clearPeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer&&(clearInterval(this.insightsPeriodicFlushTimer),this.insightsPeriodicFlushTimer=void 0)}async send(e){let{value:t}=em;if(!t)return void rt.warn("Attempting to emit an event without an Optimization profile");let i=na(nr,await this.eventInterceptors.run(e));rt.debug(`Queueing ${i.type} event for profile ${t.id}`,i);let n=this.queuedInsightsByProfile.get(t.id);ep.value=i,n?(n.profile=t,n.events.push(i)):this.queuedInsightsByProfile.set(t.id,{profile:t,events:[i]}),this.ensurePeriodicFlushTimer(),this.getQueuedEventCount()>=25&&await this.flush(),this.reconcilePeriodicFlushTimer()}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!ef.value}))return;rt.debug("Flushing insights event queue");let i=this.createBatches();if(!i.length){this.flushRuntime.clearScheduledRetry(),this.reconcilePeriodicFlushTimer();return}this.flushRuntime.markFlushStarted();try{await this.trySendBatches(i)?(this.queuedInsightsByProfile.clear(),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:i.length,queuedEvents:this.getQueuedEventCount()})}finally{this.flushRuntime.markFlushFinished(),this.reconcilePeriodicFlushTimer()}}createBatches(){let e=[];return this.queuedInsightsByProfile.forEach(({profile:t,events:i})=>{e.push({profile:t,events:i})}),e}async trySendBatches(e){try{return await this.insightsApi.sendBatchEvents(e)}catch(e){return rt.warn("Insights queue flush request threw an error",e),!1}}getQueuedEventCount(){let e=0;return this.queuedInsightsByProfile.forEach(({events:t})=>{e+=t.length}),e}ensurePeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer||0!==this.getQueuedEventCount()&&(this.insightsPeriodicFlushTimer=setInterval(()=>{this.flush()},this.flushIntervalMs))}reconcilePeriodicFlushTimer(){this.getQueuedEventCount()>0?this.ensurePeriodicFlushTimer():this.clearPeriodicFlushTimer()}}let rn=Symbol.for("ctfl.optimization.preview.signals"),rr=Symbol.for("ctfl.optimization.preview.signalFns"),rs=nu("CoreStateful"),ro=["identify","page","screen"],ra=e=>Object.values(e).some(e=>void 0!==e),rl=0;class ru extends n2{singletonOwner;destroyed=!1;allowedEventTypes;experienceQueue;insightsQueue;onEventBlocked;states={blockedEventStream:el(ec),flag:e=>this.getFlagObservable(e),consent:el(ed),eventStream:el(ep),canOptimize:el(eg),selectedOptimizations:el(ey),previewPanelAttached:el(eh),previewPanelOpen:el(ev),profile:el(em)};constructor(e){super(e,{experience:(e=>{if(void 0===e)return;let t={baseUrl:e.experienceBaseUrl,enabledFeatures:e.enabledFeatures,ip:e.ip,locale:e.locale,plainText:e.plainText,preflight:e.preflight};return ra(t)?t:void 0})(e.api),insights:(e=>{if(void 0===e)return;let t={baseUrl:e.insightsBaseUrl,beaconHandler:e.beaconHandler};return ra(t)?t:void 0})(e.api)}),this.singletonOwner=`CoreStateful#${++rl}`,(e=>{let t=n8();if(t.owner)throw Error(`Stateful Optimization SDK already initialized (${t.owner}). Only one stateful instance is supported per runtime.`);t.owner=e})(this.singletonOwner);try{const{allowedEventTypes:t,defaults:i,getAnonymousId:n,onEventBlocked:r,queuePolicy:s}=e,{changes:o,consent:a,selectedOptimizations:l,profile:u}=i??{},c=(e=>({flush:((e,t=n3)=>{var i,n;let r=e??{},s=n6(r.baseBackoffMs,t.baseBackoffMs),o=Math.max(s,n6(r.maxBackoffMs,t.maxBackoffMs));return{flushIntervalMs:n6(r.flushIntervalMs,t.flushIntervalMs),baseBackoffMs:s,maxBackoffMs:o,jitterRatio:(i=r.jitterRatio,n=t.jitterRatio,Number.isFinite(i)&&void 0!==i?Math.min(1,Math.max(0,i)):n),maxConsecutiveFailures:n6(r.maxConsecutiveFailures,t.maxConsecutiveFailures),circuitOpenMs:n6(r.circuitOpenMs,t.circuitOpenMs),onCircuitOpen:r.onCircuitOpen,onFlushFailure:r.onFlushFailure,onFlushRecovered:r.onFlushRecovered}})(e?.flush),offlineMaxEvents:n6(e?.offlineMaxEvents,100),onOfflineDrop:e?.onOfflineDrop}))(s);this.allowedEventTypes=t??ro,this.onEventBlocked=r,this.insightsQueue=new ri({eventInterceptors:this.interceptors.event,flushPolicy:c.flush,insightsApi:this.api.insights}),this.experienceQueue=new re({experienceApi:this.api.experience,eventInterceptors:this.interceptors.event,flushPolicy:c.flush,getAnonymousId:n??(()=>void 0),offlineMaxEvents:c.offlineMaxEvents,onOfflineDrop:c.onOfflineDrop,stateInterceptors:this.interceptors.state}),void 0!==a&&(ed.value=a),p(()=>{void 0!==o&&(eu.value=o),void 0!==l&&(ey.value=l),void 0!==u&&(em.value=u)}),this.initializeEffects()}catch(e){throw n5(this.singletonOwner),e}}initializeEffects(){A(()=>{rs.debug(`Profile ${em.value&&`with ID ${em.value.id}`} has been ${em.value?"set":"cleared"}`)}),A(()=>{rs.debug(`Variants have been ${ey.value?.length?"populated":"cleared"}`)}),A(()=>{rs.info(`Core ${ed.value?"will":"will not"} emit gated events due to consent (${ed.value})`)}),A(()=>{ef.value&&(this.insightsQueue.clearScheduledRetry(),this.experienceQueue.clearScheduledRetry(),this.flushQueues({force:!0}))})}async flushQueues(e={}){await this.insightsQueue.flush(e),await this.experienceQueue.flush(e)}destroy(){this.destroyed||(this.destroyed=!0,this.insightsQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush insights queue during destroy()",String(e))}),this.experienceQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush Experience queue during destroy()",String(e))}),this.insightsQueue.clearPeriodicFlushTimer(),n5(this.singletonOwner))}reset(){p(()=>{ec.value=void 0,ep.value=void 0,eu.value=void 0,em.value=void 0,ey.value=void 0})}async flush(){await this.flushQueues()}consent(e){ed.value=e}get online(){return ef.value??!1}set online(e){ef.value=e}registerPreviewPanel(e){Reflect.set(e,rn,eb),Reflect.set(e,rr,ew)}}let rc="ALL_VISITORS";function rd(e,t){let{audiences:{[t]:i}}=e;return i?i.isActive?"on":"off":"default"}function rp(e,t){return"on"===e||"off"!==e&&t}function rf(e,t,i,n){let r=t[e.id]??0,s=void 0!==i.selectedOptimizations[e.id],o={...e,currentVariantIndex:r,isOverridden:s};if(s&&void 0!==n){let{[e.id]:t}=n;void 0!==t&&(o.naturalVariantIndex=t)}return o}function rh(e,t){let i=Object.values(t);if(0===i.length)return e;let n=e.map(e=>{let{[e.experienceId]:i}=t;return i?{...e,variantIndex:i.variantIndex}:e});for(let e of i)n.some(t=>t.experienceId===e.experienceId)||n.push({experienceId:e.experienceId,variantIndex:e.variantIndex,variants:{}});return n}nu("Preview");let rv=nu("PreviewOverrides"),ry={audiences:{},selectedOptimizations:{}};class rg{baselineSelectedOptimizations=null;baselineAudienceQualifications={};overrides={...ry,audiences:{},selectedOptimizations:{}};interceptorId=null;selectedOptimizations;profile;stateInterceptors;onOverridesChanged;constructor(e){const{selectedOptimizations:t,profile:i,stateInterceptors:n,onOverridesChanged:r}=e;this.selectedOptimizations=t,this.profile=i,this.stateInterceptors=n,this.onOverridesChanged=r;const{value:s}=t;s&&(this.baselineSelectedOptimizations=s,rv.debug("Captured initial signal state as baseline")),this.interceptorId=e.stateInterceptors.add(e=>{let{selectedOptimizations:t}=e;this.baselineSelectedOptimizations=t;let i=Object.keys(this.overrides.selectedOptimizations).length>0,n=i?{...e,selectedOptimizations:rh(e.selectedOptimizations,this.overrides.selectedOptimizations)}:{...e};return i&&rv.debug("Intercepting state update to preserve overrides"),this.notifyChanged(),n}),rv.info("State interceptor registered")}activateAudience(e,t){rv.info("Activating audience override:",e),this.setAudienceOverride(e,!0,1,t)}deactivateAudience(e,t){rv.info("Deactivating audience override:",e),this.setAudienceOverride(e,!1,0,t)}resetAudienceOverride(e){rv.info("Resetting audience override:",e);let{overrides:t}=this,{audiences:i,selectedOptimizations:n}=t,r=i[e]?.experienceIds??[],s=new Set(r),o=Object.fromEntries(Object.entries(n).filter(([e])=>!s.has(e))),a=Object.fromEntries(Object.entries(i).filter(([t])=>t!==e));this.overrides={audiences:a,selectedOptimizations:o},this.baselineAudienceQualifications=Object.fromEntries(Object.entries(this.baselineAudienceQualifications).filter(([t])=>t!==e)),r.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}setVariantOverride(e,t){rv.info("Setting variant override:",{experienceId:e,variantIndex:t}),this.overrides={...this.overrides,selectedOptimizations:{...this.overrides.selectedOptimizations,[e]:{experienceId:e,variantIndex:t}}},this.syncOverridesToSignal(),this.notifyChanged()}resetOptimizationOverride(e){rv.info("Resetting optimization override:",e);let{selectedOptimizations:t}={...this.overrides},i=Object.fromEntries(Object.entries(t).filter(([t])=>t!==e));this.overrides={...this.overrides,selectedOptimizations:i},this.syncOverridesToSignal(),this.notifyChanged()}resetAll(){rv.info("Resetting all overrides to baseline"),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineAudienceQualifications={};let{baselineSelectedOptimizations:e}=this;e&&(this.selectedOptimizations.value=e,rv.debug("Restored signal to baseline")),this.notifyChanged()}getOverrides(){return this.overrides}getBaselineSelectedOptimizations(){return this.baselineSelectedOptimizations}getBaselineAudienceQualifications(){return this.baselineAudienceQualifications}destroy(){null!==this.interceptorId&&(this.stateInterceptors.remove(this.interceptorId),rv.info("State interceptor removed"),this.interceptorId=null),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineSelectedOptimizations=null,this.baselineAudienceQualifications={}}snapshotAudienceQualification(e){if(!this.profile||e in this.baselineAudienceQualifications)return;let t=this.profile.value?.audiences.includes(e)??!1;this.baselineAudienceQualifications[e]=t}syncOverridesToSignal(){this.selectedOptimizations.value=rh(this.baselineSelectedOptimizations??[],this.overrides.selectedOptimizations),rv.debug("Synced overrides to signal")}setAudienceOverride(e,t,i,n){this.snapshotAudienceQualification(e);let r={...this.overrides.selectedOptimizations};for(let e of n)r[e]={experienceId:e,variantIndex:i};this.overrides={audiences:{...this.overrides.audiences,[e]:{audienceId:e,isActive:t,source:"manual",experienceIds:n}},selectedOptimizations:r},n.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}notifyChanged(){this.onOverridesChanged?.(this.overrides)}}let rm=null,rb=null,rw=null,r_=[],rz=null,rO=null,rS=null,rE={},rx={},rk={initialize(e){rm&&rk.destroy(),rO=null,rS=null,rE={},rx={},rm=new ru({clientId:e.clientId,environment:e.environment,api:{experienceBaseUrl:e.experienceBaseUrl,insightsBaseUrl:e.insightsBaseUrl}}),e.defaults&&(void 0!==e.defaults.consent&&rm.consent(e.defaults.consent),void 0!==e.defaults.profile&&(eb.profile.value=e.defaults.profile),void 0!==e.defaults.changes&&(eb.changes.value=e.defaults.changes),void 0!==e.defaults.optimizations&&(eb.selectedOptimizations.value=e.defaults.optimizations)),rm.consent(!0);let t=globalThis;rz=new rg({selectedOptimizations:eb.selectedOptimizations,profile:eb.profile,stateInterceptors:rm.interceptors.state,onOverridesChanged:()=>{"function"==typeof t.__nativeOnOverridesChanged&&t.__nativeOnOverridesChanged(rk.getPreviewState())}}),rb=A(()=>{let e={profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null};"function"==typeof t.__nativeOnStateChange&&t.__nativeOnStateChange(JSON.stringify(e))}),rw=A(()=>{let e=eb.event.value;e&&"function"==typeof t.__nativeOnEventEmitted&&t.__nativeOnEventEmitted(JSON.stringify(e))})},identify(e,t,i){rm?rm.identify(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},page(e,t,i){rm?rm.page(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},screen(e,t,i){rm?rm.screen({name:e.name,properties:e.properties??{}}).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},flush(e,t){rm?rm.flush().then(()=>{e(JSON.stringify(null))}).catch(e=>{t(e instanceof Error?e.message:String(e))}):t("SDK not initialized. Call initialize() first.")},trackView(e,t,i){rm?rm.trackView(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},trackClick(e,t,i){rm?rm.trackClick(e).then(()=>{t(JSON.stringify(null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},consent(e){rm&&rm.consent(e)},reset(){rm&&(rz?.resetAll(),rm.reset())},setOnline(e){eb.online.value=e},flag(e){rm&&r_.push(rm.states.flag(e).subscribe(()=>void 0))},personalizeEntry:(e,t)=>rm?JSON.stringify(rm.resolveOptimizedEntry(e,t)):JSON.stringify({entry:e}),getMergeTagValue:e=>rm?rm.getMergeTagValue(e)??null:null,setPreviewPanelOpen(e){rm&&(eb.previewPanelOpen.value=e)},overrideAudience(e,t,i){rz&&(t?rz.activateAudience(e,i):rz.deactivateAudience(e,i))},overrideVariant(e,t){rz?.setVariantOverride(e,t)},resetAudienceOverride(e){rz?.resetAudienceOverride(e)},resetVariantOverride(e){rz?.resetOptimizationOverride(e)},resetAllOverrides(){rz?.resetAll()},loadDefinitions(e,t){try{let i,n;for(let r of(rO=e.map(e=>{let t=e.fields;return"object"==typeof t&&null!==t?{id:t.nt_audience_id??e.sys.id,name:t.nt_name??t.nt_audience_id??e.sys.id,description:t.nt_description}:{id:e.sys.id,name:e.sys.id}}),i=new Map,t.forEach(e=>{(e.includes?.Entry??[]).forEach(e=>{i.set(e.sys.id,e)})}),rS=t.map(e=>{let t=e.fields;if("object"!=typeof t||null===t)return{id:e.sys.id,name:e.sys.id,type:"nt_personalization",distribution:[]};let{nt_config:n}=t,r=[];return n?.distribution&&n.distribution.forEach((e,t)=>{let s,o=(s=n.components?.[0],void 0===s||void 0!==s.type&&"EntryReplacement"!==s.type?"":0===t?s.baseline.id:s.variants[t-1]?.id??""),a=i.get(o);r.push({index:t,variantRef:o,percentage:Math.round(100*e),name:a?function(e){let t=e.fields;if("object"==typeof t&&null!==t)return t.internalTitle??t.title??t.name}(a):void 0})}),{id:t.nt_experience_id??e.sys.id,name:t.nt_name??e.sys.id,type:t.nt_type??"nt_personalization",distribution:r,audience:t.nt_audience?{id:t.nt_audience.sys.id}:void 0}}),n={},t.forEach(e=>{let t=e.fields;if("object"==typeof t&&null!==t){let i=t.nt_personalization_id??t.nt_experience_id??e.sys.id,{nt_name:r}=t;r&&(n[i]=r)}}),rx=n,rE={},rO))rE[r.id]=r.name;return JSON.stringify({audienceCount:rO.length,experienceCount:rS.length})}catch(e){return rO=null,rS=null,rE={},rx={},JSON.stringify({error:e instanceof Error?e.message:String(e)})}},getPreviewState(){let e=rz?.getOverrides()??{audiences:{},selectedOptimizations:{}},t=rz?.getBaselineSelectedOptimizations(),i={};for(let[t,n]of Object.entries(e.audiences))i[t]=n.isActive;let n={};for(let[t,i]of Object.entries(e.selectedOptimizations))n[t]=i.variantIndex;let r={};if(t)for(let e of t)void 0!==n[e.experienceId]&&(r[e.experienceId]=e.variantIndex);let s=rO&&rS?{...function(e){let{audienceDefinitions:t,experienceDefinitions:i,signals:n,overrides:r}=e,{profile:s,selectedOptimizations:o}=n,a=new Set(s?.audiences??[]),l={};if(o)for(let{experienceId:e,variantIndex:t}of o)l[e]=t;let u=null!=e.baselineSelectedOptimizations?Object.fromEntries(e.baselineSelectedOptimizations.map(e=>[e.experienceId,e.variantIndex])):void 0,c=new Set(t.map(e=>e.id)),d=i.filter(e=>!e.audience?.id||!c.has(e.audience.id)).map(e=>rf(e,l,r,u)),p=t.map(e=>{let t=i.filter(t=>t.audience?.id===e.id).map(e=>rf(e,l,r,u)),n=a.has(e.id),s=rd(r,e.id),o=rp(s,n);return{audience:e,experiences:t,isQualified:n,isActive:o,overrideState:s}});if(d.length>0){let e=rd(r,rc);p.push({audience:{id:rc,name:"All Visitors",description:"Experiences that apply to all visitors regardless of audience membership"},experiences:d,isQualified:!0,isActive:rp(e,!0),overrideState:e})}let f=t.length>0||i.length>0;return{audiencesWithExperiences:[...p].sort((e,t)=>e.audience.id===rc?-1:t.audience.id===rc?1:e.isActive!==t.isActive?e.isActive?-1:1:e.audience.name.localeCompare(t.audience.name,void 0,{sensitivity:"base"})),unassociatedExperiences:d,hasData:f,sdkVariantIndices:l}}({audienceDefinitions:rO,experienceDefinitions:rS,signals:{profile:eb.profile.value,selectedOptimizations:eb.selectedOptimizations.value,consent:eb.consent.value,isLoading:!1},overrides:e,baselineSelectedOptimizations:t}),audienceNameMap:rE,experienceNameMap:rx}:null;return JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null,previewPanelOpen:eb.previewPanelOpen.value,audienceOverrides:i,variantOverrides:n,defaultAudienceQualifications:rz?.getBaselineAudienceQualifications()??{},defaultVariantIndices:r,previewModel:s})},getProfile(){let e=eb.profile.value;return e?JSON.stringify(e):null},getState:()=>JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null}),destroy(){for(let e of(rz?.destroy(),rz=null,rO=null,rS=null,rE={},rx={},r_))e.unsubscribe();r_=[],rw&&(rw(),rw=null),rb&&(rb(),rb=null),rm&&(rm.destroy(),rm=null)}};globalThis.__bridge=rk;let rI=rk;return u.default})()); //# sourceMappingURL=optimization-android-bridge.umd.js.map \ No newline at end of file diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js index 7ab38285..fd1dc408 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Resources/optimization-ios-bridge.umd.js @@ -1,2 +1,2 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.OptimizationBridge=t():e.OptimizationBridge=t()}(globalThis,()=>(()=>{"use strict";let e,t,i,n,r,s,o;var a,l={};l.d=(e,t)=>{for(var i in t)l.o(t,i)&&!l.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var u={};l.d(u,{default:()=>rk});var c=Symbol.for("preact-signals");function d(){if(g>1)g--;else{for(var e,t=!1;void 0!==y;){var i=y;for(y=void 0,m++;void 0!==i;){var n=i.o;if(i.o=void 0,i.f&=-3,!(8&i.f)&&O(i))try{i.c()}catch(i){t||(e=i,t=!0)}i=n}}if(m=0,g--,t)throw e}}function p(e){if(g>0)return e();g++;try{return e()}finally{d()}}var h=void 0;function f(e){var t=h;h=void 0;try{return e()}finally{h=t}}var v,y=void 0,g=0,m=0,b=0;function w(e){if(void 0!==h){var t=e.n;if(void 0===t||t.t!==h)return t={i:0,S:e,p:h.s,n:void 0,t:h,e:void 0,x:void 0,r:t},void 0!==h.s&&(h.s.n=t),h.s=t,e.n=t,32&h.f&&e.S(t),t;if(-1===t.i)return t.i=0,void 0!==t.n&&(t.n.p=t.p,void 0!==t.p&&(t.p.n=t.n),t.p=h.s,t.n=void 0,h.s.n=t,h.s=t),t}}function _(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function z(e,t){return new _(e,t)}function O(e){for(var t=e.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function S(e){for(var t=e.s;void 0!==t;t=t.n){var i=t.S.n;if(void 0!==i&&(t.r=i),t.S.n=t,t.i=-1,void 0===t.n){e.s=t;break}}}function E(e){for(var t=e.s,i=void 0;void 0!==t;){var n=t.p;-1===t.i?(t.S.U(t),void 0!==n&&(n.n=t.n),void 0!==t.n&&(t.n.p=n)):i=t,t.S.n=t.r,void 0!==t.r&&(t.r=void 0),t=n}e.s=i}function x(e,t){_.call(this,void 0),this.x=e,this.s=void 0,this.g=b-1,this.f=4,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function k(e,t){return new x(e,t)}function I(e){var t=e.u;if(e.u=void 0,"function"==typeof t){g++;var i=h;h=void 0;try{t()}catch(t){throw e.f&=-2,e.f|=8,$(e),t}finally{h=i,d()}}}function $(e){for(var t=e.s;void 0!==t;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,I(e)}function P(e){if(h!==this)throw Error("Out-of-order effect");E(this),h=e,this.f&=-2,8&this.f&&$(this),d()}function j(e,t){this.x=e,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=null==t?void 0:t.name,v&&v.push(this)}function A(e,t){var i=new j(e,t);try{i.c()}catch(e){throw i.d(),e}var n=i.d.bind(i);return n[Symbol.dispose]=n,n}function T(e){return Object.getOwnPropertySymbols(e).filter(t=>Object.prototype.propertyIsEnumerable.call(e,t))}function F(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}_.prototype.brand=c,_.prototype.h=function(){return!0},_.prototype.S=function(e){var t=this,i=this.t;i!==e&&void 0===e.e&&(e.x=i,this.t=e,void 0!==i?i.e=e:f(function(){var e;null==(e=t.W)||e.call(t)}))},_.prototype.U=function(e){var t=this;if(void 0!==this.t){var i=e.e,n=e.x;void 0!==i&&(i.x=n,e.e=void 0),void 0!==n&&(n.e=i,e.x=void 0),e===this.t&&(this.t=n,void 0===n&&f(function(){var e;null==(e=t.Z)||e.call(t)}))}},_.prototype.subscribe=function(e){var t=this;return A(function(){var i=t.value,n=h;h=void 0;try{e(i)}finally{h=n}},{name:"sub"})},_.prototype.valueOf=function(){return this.value},_.prototype.toString=function(){return this.value+""},_.prototype.toJSON=function(){return this.value},_.prototype.peek=function(){var e=h;h=void 0;try{return this.value}finally{h=e}},Object.defineProperty(_.prototype,"value",{get:function(){var e=w(this);return void 0!==e&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(m>100)throw Error("Cycle detected");this.v=e,this.i++,b++,g++;try{for(var t=this.t;void 0!==t;t=t.x)t.t.N()}finally{d()}}}}),x.prototype=new _,x.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if(32==(36&this.f)||(this.f&=-5,this.g===b))return!0;if(this.g=b,this.f|=1,this.i>0&&!O(this))return this.f&=-2,!0;var e=h;try{S(this),h=this;var t=this.x();(16&this.f||this.v!==t||0===this.i)&&(this.v=t,this.f&=-17,this.i++)}catch(e){this.v=e,this.f|=16,this.i++}return h=e,E(this),this.f&=-2,!0},x.prototype.S=function(e){if(void 0===this.t){this.f|=36;for(var t=this.s;void 0!==t;t=t.n)t.S.S(t)}_.prototype.S.call(this,e)},x.prototype.U=function(e){if(void 0!==this.t&&(_.prototype.U.call(this,e),void 0===this.t)){this.f&=-33;for(var t=this.s;void 0!==t;t=t.n)t.S.U(t)}},x.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;void 0!==e;e=e.x)e.t.N()}},Object.defineProperty(x.prototype,"value",{get:function(){if(1&this.f)throw Error("Cycle detected");var e=w(this);if(this.h(),void 0!==e&&(e.i=this.i),16&this.f)throw this.v;return this.v}}),j.prototype.c=function(){var e=this.S();try{if(8&this.f||void 0===this.x)return;var t=this.x();"function"==typeof t&&(this.u=t)}finally{e()}},j.prototype.S=function(){if(1&this.f)throw Error("Cycle detected");this.f|=1,this.f&=-9,I(this),S(this),g++;var e=h;return h=this,P.bind(this,e)},j.prototype.N=function(){2&this.f||(this.f|=2,this.o=y,y=this)},j.prototype.d=function(){this.f|=8,1&this.f||$(this)},j.prototype.dispose=function(){this.d()};let C="[object RegExp]",R="[object String]",M="[object Number]",B="[object Boolean]",q="[object Arguments]",U="[object Symbol]",V="[object Date]",N="[object Map]",D="[object Set]",Z="[object Array]",Q="[object ArrayBuffer]",L="[object Object]",J="[object DataView]",H="[object Uint8Array]",K="[object Uint8ClampedArray]",W="[object Uint16Array]",G="[object Uint32Array]",X="[object Int8Array]",Y="[object Int16Array]",ee="[object Int32Array]",et="[object Float32Array]",ei="[object Float64Array]",en="object"==typeof globalThis&&globalThis||"object"==typeof window&&window||"object"==typeof self&&self||"object"==typeof global&&global||function(){return this}()||Function("return this")();function er(e){return void 0!==en.Buffer&&en.Buffer.isBuffer(e)}function es(e,t,i,n=new Map,r){let s=r?.(e,t,i,n);if(void 0!==s)return s;if(null==e||"object"!=typeof e&&"function"!=typeof e)return e;if(n.has(e))return n.get(e);if(Array.isArray(e)){let t=Array(e.length);n.set(e,t);for(let s=0;stypeof SharedArrayBuffer&&e instanceof SharedArrayBuffer)return e.slice(0);if(e instanceof DataView){let t=new DataView(e.buffer.slice(0),e.byteOffset,e.byteLength);return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof File&&e instanceof File){let t=new File([e],e.name,{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof Blob&&e instanceof Blob){let t=new Blob([e],{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Error){let t=structuredClone(e);return n.set(e,t),t.message=e.message,t.name=e.name,t.stack=e.stack,t.cause=e.cause,t.constructor=e.constructor,eo(t,e,i,n,r),t}if(e instanceof Boolean){let t=new Boolean(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Number){let t=new Number(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof String){let t=new String(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if("object"==typeof e&&function(e){switch(F(e)){case q:case Z:case Q:case J:case B:case V:case et:case ei:case X:case Y:case ee:case N:case M:case L:case C:case D:case R:case U:case H:case K:case W:case G:return!0;default:return!1}}(e)){let t=Object.create(Object.getPrototypeOf(e));return n.set(e,t),eo(t,e,i,n,r),t}return e}function eo(e,t,i=e,n,r){let s=[...Object.keys(t),...T(t)];for(let o=0;o({unsubscribe:A(()=>{t(ea(e.value))})}),subscribeOnce(t){let i=!1,n=!1,r=()=>void 0;return r=A(()=>{if(i)return;let{value:s}=e;if(null==s)return;i=!0;let o=null;try{t(ea(s))}catch(e){o=e instanceof Error?e:Error(`Subscriber threw non-Error value: ${String(e)}`)}if(n?r():queueMicrotask(r),o)throw o}),n=!0,{unsubscribe:()=>{!i&&(i=!0,n&&r())}}}}}let eu=z(),ec=z(),ed=z(),ep=z(),eh=z(!0),ef=z(!1),ev=z(!1),ey=z(),eg=k(()=>void 0!==ey.value),em=z(),eb={blockedEvent:ec,changes:eu,consent:ed,event:ep,online:eh,previewPanelAttached:ef,previewPanelOpen:ev,selectedOptimizations:ey,canOptimize:eg,profile:em},ew={batch:p,computed:k,effect:A,untracked:f};function e_(e,t,i){function n(i,n){if(i._zod||Object.defineProperty(i,"_zod",{value:{def:n,constr:o,traits:new Set},enumerable:!1}),i._zod.traits.has(e))return;i._zod.traits.add(e),t(i,n);let r=o.prototype,s=Object.keys(r);for(let e=0;e!!i?.Parent&&t instanceof i.Parent||t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}Object.freeze({status:"aborted"}),Symbol("zod_brand");class ez extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}let eO={};function eS(e){return e&&Object.assign(eO,e),eO}function eE(e,t="|"){return e.map(e=>eU(e)).join(t)}function ex(e,t){return"bigint"==typeof t?t.toString():t}function ek(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}}}}function eI(e){let t=+!!e.startsWith("^"),i=e.endsWith("$")?e.length-1:e.length;return e.slice(t,i)}let e$=Symbol("evaluating");function eP(e,t,i){let n;Object.defineProperty(e,t,{get(){if(n!==e$)return void 0===n&&(n=e$,n=i()),n},set(i){Object.defineProperty(e,t,{value:i})},configurable:!0})}function ej(e,t,i){Object.defineProperty(e,t,{value:i,writable:!0,enumerable:!0,configurable:!0})}function eA(...e){let t={};for(let i of e)Object.assign(t,Object.getOwnPropertyDescriptors(i));return Object.defineProperties({},t)}let eT="captureStackTrace"in Error?Error.captureStackTrace:(...e)=>{};function eF(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function eC(e){if(!1===eF(e))return!1;let t=e.constructor;if(void 0===t||"function"!=typeof t)return!0;let i=t.prototype;return!1!==eF(i)&&!1!==Object.prototype.hasOwnProperty.call(i,"isPrototypeOf")}ek(()=>{if("u">typeof navigator&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{return Function(""),!0}catch(e){return!1}});let eR=new Set(["string","number","symbol"]);function eM(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function eB(e,t,i){let n=new e._zod.constr(t??e._zod.def);return(!t||i?.parent)&&(n._zod.parent=e),n}function eq(e){if(!e)return{};if("string"==typeof e)return{error:()=>e};if(e?.message!==void 0){if(e?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");e.error=e.message}return(delete e.message,"string"==typeof e.error)?{...e,error:()=>e.error}:e}function eU(e){return"bigint"==typeof e?e.toString()+"n":"string"==typeof e?`"${e}"`:`${e}`}function eV(e,t=0){if(!0===e.aborted)return!0;for(let i=t;i(t.path??(t.path=[]),t.path.unshift(e),t))}function eD(e){return"string"==typeof e?e:e?.message}function eZ(e,t,i){let n={...e,path:e.path??[]};return e.message||(n.message=eD(e.inst?._zod.def?.error?.(e))??eD(t?.error?.(e))??eD(i.customError?.(e))??eD(i.localeError?.(e))??"Invalid input"),delete n.inst,delete n.continue,t?.reportInput||delete n.input,n}function eQ(e){return Array.isArray(e)?"array":"string"==typeof e?"string":"unknown"}let eL=(e,t)=>{e.name="$ZodError",Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,ex,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},eJ=e_("$ZodError",eL),eH=e_("$ZodError",eL,{Parent:Error}),eK=(e=eH,(t,i,n,r)=>{let s=n?Object.assign(n,{async:!1}):{async:!1},o=t._zod.run({value:i,issues:[]},s);if(o instanceof Promise)throw new ez;if(o.issues.length){let t=new(r?.Err??e)(o.issues.map(e=>eZ(e,s,eS())));throw eT(t,r?.callee),t}return o.value}),eW=(t=eH,async(e,i,n,r)=>{let s=n?Object.assign(n,{async:!0}):{async:!0},o=e._zod.run({value:i,issues:[]},s);if(o instanceof Promise&&(o=await o),o.issues.length){let e=new(r?.Err??t)(o.issues.map(e=>eZ(e,s,eS())));throw eT(e,r?.callee),e}return o.value}),eG=(i=eH,(e,t,n)=>{let r=n?{...n,async:!1}:{async:!1},s=e._zod.run({value:t,issues:[]},r);if(s instanceof Promise)throw new ez;return s.issues.length?{success:!1,error:new(i??eJ)(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eX=(n=eH,async(e,t,i)=>{let r=i?Object.assign(i,{async:!0}):{async:!0},s=e._zod.run({value:t,issues:[]},r);return s instanceof Promise&&(s=await s),s.issues.length?{success:!1,error:new n(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eY=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,e0="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",e1=RegExp(`^${e0}$`);function e2(e){let t="(?:[01]\\d|2[0-3]):[0-5]\\d";return"number"==typeof e.precision?-1===e.precision?`${t}`:0===e.precision?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}let e6=/^-?\d+(?:\.\d+)?$/,e3=/^(?:true|false)$/i,e4=/^null$/i,e8=e_("$ZodCheck",(e,t)=>{var i;e._zod??(e._zod={}),e._zod.def=t,(i=e._zod).onattach??(i.onattach=[])}),e5=e_("$ZodCheckMinLength",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag.minimum??-1/0;t.minimum>i&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=i=>{let n=i.value;if(n.length>=t.minimum)return;let r=eQ(n);i.issues.push({origin:r,code:"too_small",minimum:t.minimum,inclusive:!0,input:n,inst:e,continue:!t.abort})}}),e9=e_("$ZodCheckLengthEquals",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag;i.minimum=t.length,i.maximum=t.length,i.length=t.length}),e._zod.check=i=>{let n=i.value,r=n.length;if(r===t.length)return;let s=eQ(n),o=r>t.length;i.issues.push({origin:s,...o?{code:"too_big",maximum:t.length}:{code:"too_small",minimum:t.length},inclusive:!0,exact:!0,input:i.value,inst:e,continue:!t.abort})}}),e7=e_("$ZodCheckStringFormat",(e,t)=>{var i,n;e8.init(e,t),e._zod.onattach.push(e=>{let i=e._zod.bag;i.format=t.format,t.pattern&&(i.patterns??(i.patterns=new Set),i.patterns.add(t.pattern))}),t.pattern?(i=e._zod).check??(i.check=i=>{t.pattern.lastIndex=0,t.pattern.test(i.value)||i.issues.push({origin:"string",code:"invalid_format",format:t.format,input:i.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(n=e._zod).check??(n.check=()=>{})}),te={major:4,minor:3,patch:6},tt=e_("$ZodType",(e,t)=>{var i;e??(e={}),e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=te;let n=[...e._zod.def.checks??[]];for(let t of(e._zod.traits.has("$ZodCheck")&&n.unshift(e),n))for(let i of t._zod.onattach)i(e);if(0===n.length)(i=e._zod).deferred??(i.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,i)=>{let n,r=eV(e);for(let s of t){if(s._zod.def.when){if(!s._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=s._zod.check(e);if(o instanceof Promise&&i?.async===!1)throw new ez;if(n||o instanceof Promise)n=(n??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||(r=eV(e,t)))});else{if(e.issues.length===t)continue;r||(r=eV(e,t))}}return n?n.then(()=>e):e},i=(i,r,s)=>{if(eV(i))return i.aborted=!0,i;let o=t(r,n,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(t=>e._zod.parse(t,s))}return e._zod.parse(o,s)};e._zod.run=(r,s)=>{if(s.skipChecks)return e._zod.parse(r,s);if("backward"===s.direction){let t=e._zod.parse({value:r.value,issues:[]},{...s,skipChecks:!0});return t instanceof Promise?t.then(e=>i(e,r,s)):i(t,r,s)}let o=e._zod.parse(r,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(e=>t(e,n,s))}return t(o,n,s)}}eP(e,"~standard",()=>({validate:t=>{try{let i=eG(e,t);return i.success?{value:i.data}:{issues:i.error?.issues}}catch(i){return eX(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:"zod",version:1}))}),ti=e_("$ZodString",(e,t)=>{var i;let n;tt.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??(n=(i=e._zod.bag)?`[\\s\\S]{${i?.minimum??0},${i?.maximum??""}}`:"[\\s\\S]*",RegExp(`^${n}$`)),e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=String(i.value)}catch(e){}return"string"==typeof i.value||i.issues.push({expected:"string",code:"invalid_type",input:i.value,inst:e}),i}}),tn=e_("$ZodStringFormat",(e,t)=>{e7.init(e,t),ti.init(e,t)}),tr=e_("$ZodISODateTime",(e,t)=>{let i,n,r;t.pattern??(i=e2({precision:t.precision}),n=["Z"],t.local&&n.push(""),t.offset&&n.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)"),r=`${i}(?:${n.join("|")})`,t.pattern=RegExp(`^${e0}T(?:${r})$`)),tn.init(e,t)}),ts=((e,t)=>{t.pattern??(t.pattern=e1),tn.init(e,t)},e_("$ZodNumber",(e,t)=>{tt.init(e,t),e._zod.pattern=e._zod.bag.pattern??e6,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=Number(i.value)}catch(e){}let r=i.value;if("number"==typeof r&&!Number.isNaN(r)&&Number.isFinite(r))return i;let s="number"==typeof r?Number.isNaN(r)?"NaN":Number.isFinite(r)?void 0:"Infinity":void 0;return i.issues.push({expected:"number",code:"invalid_type",input:r,inst:e,...s?{received:s}:{}}),i}})),to=e_("$ZodBoolean",(e,t)=>{tt.init(e,t),e._zod.pattern=e3,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=!!i.value}catch(e){}let r=i.value;return"boolean"==typeof r||i.issues.push({expected:"boolean",code:"invalid_type",input:r,inst:e}),i}}),ta=e_("$ZodNull",(e,t)=>{tt.init(e,t),e._zod.pattern=e4,e._zod.values=new Set([null]),e._zod.parse=(t,i)=>{let n=t.value;return null===n||t.issues.push({expected:"null",code:"invalid_type",input:n,inst:e}),t}}),tl=e_("$ZodAny",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e}),tu=e_("$ZodUnknown",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e});function tc(e,t,i){e.issues.length&&t.issues.push(...eN(i,e.issues)),t.value[i]=e.value}let td=e_("$ZodArray",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!Array.isArray(r))return i.issues.push({expected:"array",code:"invalid_type",input:r,inst:e}),i;i.value=Array(r.length);let s=[];for(let e=0;etc(t,i,e))):tc(a,i,e)}return s.length?Promise.all(s).then(()=>i):i}});function tp(e,t,i,n,r){if(e.issues.length){if(r&&!(i in n))return;t.issues.push(...eN(i,e.issues))}void 0===e.value?i in n&&(t.value[i]=void 0):t.value[i]=e.value}let th=e_("$ZodObject",(e,t)=>{let i;tt.init(e,t);let n=Object.getOwnPropertyDescriptor(t,"shape");if(!n?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let i={...e};return Object.defineProperty(t,"shape",{value:i}),i}})}let r=ek(()=>(function(e){var t;let i=Object.keys(e.shape);for(let t of i)if(!e.shape?.[t]?._zod?.traits?.has("$ZodType"))throw Error(`Invalid element at key "${t}": expected a Zod schema`);let n=Object.keys(t=e.shape).filter(e=>"optional"===t[e]._zod.optin&&"optional"===t[e]._zod.optout);return{...e,keys:i,keySet:new Set(i),numKeys:i.length,optionalKeys:new Set(n)}})(t));eP(e._zod,"propValues",()=>{let e=t.shape,i={};for(let t in e){let n=e[t]._zod;if(n.values)for(let e of(i[t]??(i[t]=new Set),n.values))i[t].add(e)}return i});let s=t.catchall;e._zod.parse=(t,n)=>{i??(i=r.value);let o=t.value;if(!eF(o))return t.issues.push({expected:"object",code:"invalid_type",input:o,inst:e}),t;t.value={};let a=[],l=i.shape;for(let e of i.keys){let i=l[e],r="optional"===i._zod.optout,s=i._zod.run({value:o[e],issues:[]},n);s instanceof Promise?a.push(s.then(i=>tp(i,t,e,o,r))):tp(s,t,e,o,r)}return s?function(e,t,i,n,r,s){let o=[],a=r.keySet,l=r.catchall._zod,u=l.def.type,c="optional"===l.optout;for(let r in t){if(a.has(r))continue;if("never"===u){o.push(r);continue}let s=l.run({value:t[r],issues:[]},n);s instanceof Promise?e.push(s.then(e=>tp(e,i,r,t,c))):tp(s,i,r,t,c)}return(o.length&&i.issues.push({code:"unrecognized_keys",keys:o,input:t,inst:s}),e.length)?Promise.all(e).then(()=>i):i}(a,o,t,n,r.value,e):a.length?Promise.all(a).then(()=>t):t}});function tf(e,t,i,n){for(let i of e)if(0===i.issues.length)return t.value=i.value,t;let r=e.filter(e=>!eV(e));return 1===r.length?(t.value=r[0].value,r[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:i,errors:e.map(e=>e.issues.map(e=>eZ(e,n,eS())))}),t)}let tv=e_("$ZodUnion",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.options.some(e=>"optional"===e._zod.optin)?"optional":void 0),eP(e._zod,"optout",()=>t.options.some(e=>"optional"===e._zod.optout)?"optional":void 0),eP(e._zod,"values",()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),eP(e._zod,"pattern",()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>eI(e.source)).join("|")})$`)}});let i=1===t.options.length,n=t.options[0]._zod.run;e._zod.parse=(r,s)=>{if(i)return n(r,s);let o=!1,a=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},s);if(t instanceof Promise)a.push(t),o=!0;else{if(0===t.issues.length)return t;a.push(t)}}return o?Promise.all(a).then(t=>tf(t,r,e,s)):tf(a,r,e,s)}}),ty=e_("$ZodDiscriminatedUnion",(e,t)=>{t.inclusive=!1,tv.init(e,t);let i=e._zod.parse;eP(e._zod,"propValues",()=>{let e={};for(let i of t.options){let n=i._zod.propValues;if(!n||0===Object.keys(n).length)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(i)}"`);for(let[t,i]of Object.entries(n))for(let n of(e[t]||(e[t]=new Set),i))e[t].add(n)}return e});let n=ek(()=>{let e=t.options,i=new Map;for(let n of e){let e=n._zod.propValues?.[t.discriminator];if(!e||0===e.size)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(n)}"`);for(let t of e){if(i.has(t))throw Error(`Duplicate discriminator value "${String(t)}"`);i.set(t,n)}}return i});e._zod.parse=(r,s)=>{let o=r.value;if(!eF(o))return r.issues.push({code:"invalid_type",expected:"object",input:o,inst:e}),r;let a=n.value.get(o?.[t.discriminator]);return a?a._zod.run(r,s):t.unionFallback?i(r,s):(r.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:t.discriminator,input:o,path:[t.discriminator],inst:e}),r)}}),tg=e_("$ZodRecord",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!eC(r))return i.issues.push({expected:"record",code:"invalid_type",input:r,inst:e}),i;let s=[],o=t.keyType._zod.values;if(o){let a;i.value={};let l=new Set;for(let e of o)if("string"==typeof e||"number"==typeof e||"symbol"==typeof e){l.add("number"==typeof e?e.toString():e);let o=t.valueType._zod.run({value:r[e],issues:[]},n);o instanceof Promise?s.push(o.then(t=>{t.issues.length&&i.issues.push(...eN(e,t.issues)),i.value[e]=t.value})):(o.issues.length&&i.issues.push(...eN(e,o.issues)),i.value[e]=o.value)}for(let e in r)l.has(e)||(a=a??[]).push(e);a&&a.length>0&&i.issues.push({code:"unrecognized_keys",input:r,inst:e,keys:a})}else for(let o of(i.value={},Reflect.ownKeys(r))){if("__proto__"===o)continue;let a=t.keyType._zod.run({value:o,issues:[]},n);if(a instanceof Promise)throw Error("Async schemas not supported in object keys currently");if("string"==typeof o&&e6.test(o)&&a.issues.length){let e=t.keyType._zod.run({value:Number(o),issues:[]},n);if(e instanceof Promise)throw Error("Async schemas not supported in object keys currently");0===e.issues.length&&(a=e)}if(a.issues.length){"loose"===t.mode?i.value[o]=r[o]:i.issues.push({code:"invalid_key",origin:"record",issues:a.issues.map(e=>eZ(e,n,eS())),input:o,path:[o],inst:e});continue}let l=t.valueType._zod.run({value:r[o],issues:[]},n);l instanceof Promise?s.push(l.then(e=>{e.issues.length&&i.issues.push(...eN(o,e.issues)),i.value[a.value]=e.value})):(l.issues.length&&i.issues.push(...eN(o,l.issues)),i.value[a.value]=l.value)}return s.length?Promise.all(s).then(()=>i):i}}),tm=e_("$ZodEnum",(e,t)=>{var i;let n;tt.init(e,t);let r=(n=Object.values(i=t.entries).filter(e=>"number"==typeof e),Object.entries(i).filter(([e,t])=>-1===n.indexOf(+e)).map(([e,t])=>t)),s=new Set(r);e._zod.values=s,e._zod.pattern=RegExp(`^(${r.filter(e=>eR.has(typeof e)).map(e=>"string"==typeof e?eM(e):e.toString()).join("|")})$`),e._zod.parse=(t,i)=>{let n=t.value;return s.has(n)||t.issues.push({code:"invalid_value",values:r,input:n,inst:e}),t}}),tb=e_("$ZodLiteral",(e,t)=>{if(tt.init(e,t),0===t.values.length)throw Error("Cannot create literal schema with no valid values");let i=new Set(t.values);e._zod.values=i,e._zod.pattern=RegExp(`^(${t.values.map(e=>"string"==typeof e?eM(e):e?eM(e.toString()):String(e)).join("|")})$`),e._zod.parse=(n,r)=>{let s=n.value;return i.has(s)||n.issues.push({code:"invalid_value",values:t.values,input:s,inst:e}),n}});function tw(e,t){return e.issues.length&&void 0===t?{issues:[],value:void 0}:e}let t_=e_("$ZodOptional",(e,t)=>{tt.init(e,t),e._zod.optin="optional",e._zod.optout="optional",eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)})?$`):void 0}),e._zod.parse=(e,i)=>{if("optional"===t.innerType._zod.optin){let n=t.innerType._zod.run(e,i);return n instanceof Promise?n.then(t=>tw(t,e.value)):tw(n,e.value)}return void 0===e.value?e:t.innerType._zod.run(e,i)}}),tz=e_("$ZodNullable",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.innerType._zod.optin),eP(e._zod,"optout",()=>t.innerType._zod.optout),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)}|null)$`):void 0}),eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,i)=>null===e.value?e:t.innerType._zod.run(e,i)}),tO=e_("$ZodPrefault",(e,t)=>{tt.init(e,t),e._zod.optin="optional",eP(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(e,i)=>("backward"===i.direction||void 0===e.value&&(e.value=t.defaultValue),t.innerType._zod.run(e,i))}),tS=e_("$ZodLazy",(e,t)=>{tt.init(e,t),eP(e._zod,"innerType",()=>t.getter()),eP(e._zod,"pattern",()=>e._zod.innerType?._zod?.pattern),eP(e._zod,"propValues",()=>e._zod.innerType?._zod?.propValues),eP(e._zod,"optin",()=>e._zod.innerType?._zod?.optin??void 0),eP(e._zod,"optout",()=>e._zod.innerType?._zod?.optout??void 0),e._zod.parse=(t,i)=>e._zod.innerType._zod.run(t,i)});Symbol("ZodOutput"),Symbol("ZodInput");function tE(e,t){return new e5({check:"min_length",...eq(t),minimum:e})}(a=globalThis).__zod_globalRegistry??(a.__zod_globalRegistry=new class e{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let i=t[0];return this._map.set(e,i),i&&"object"==typeof i&&"id"in i&&this._idmap.set(i.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&"object"==typeof t&&"id"in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let i={...this.get(t)??{}};delete i.id;let n={...i,...this._map.get(e)};return Object.keys(n).length?n:void 0}return this._map.get(e)}has(e){return this._map.has(e)}});let tx=e_("ZodMiniType",(e,t)=>{if(!e._zod)throw Error("Uninitialized schema in ZodMiniType.");tt.init(e,t),e.def=t,e.type=t.type,e.parse=(t,i)=>eK(e,t,i,{callee:e.parse}),e.safeParse=(t,i)=>eG(e,t,i),e.parseAsync=async(t,i)=>eW(e,t,i,{callee:e.parseAsync}),e.safeParseAsync=async(t,i)=>eX(e,t,i),e.check=(...i)=>e.clone({...t,checks:[...t.checks??[],...i.map(e=>"function"==typeof e?{_zod:{check:e,def:{check:"custom"},onattach:[]}}:e)]},{parent:!0}),e.with=e.check,e.clone=(t,i)=>eB(e,t,i),e.brand=()=>e,e.register=(t,i)=>(t.add(e,i),e),e.apply=t=>t(e)}),tk=e_("ZodMiniString",(e,t)=>{ti.init(e,t),tx.init(e,t)});function tI(e){return new tk({type:"string",...eq(e)})}let t$=e_("ZodMiniStringFormat",(e,t)=>{tn.init(e,t),tk.init(e,t)}),tP=e_("ZodMiniNumber",(e,t)=>{ts.init(e,t),tx.init(e,t)});function tj(e){return new tP({type:"number",checks:[],...eq(e)})}let tA=e_("ZodMiniBoolean",(e,t)=>{to.init(e,t),tx.init(e,t)});function tT(e){return new tA({type:"boolean",...eq(e)})}let tF=e_("ZodMiniNull",(e,t)=>{ta.init(e,t),tx.init(e,t)});function tC(e){return new tF({type:"null",...eq(e)})}let tR=e_("ZodMiniAny",(e,t)=>{tl.init(e,t),tx.init(e,t)});function tM(){return new tR({type:"any"})}let tB=e_("ZodMiniUnknown",(e,t)=>{tu.init(e,t),tx.init(e,t)}),tq=e_("ZodMiniArray",(e,t)=>{td.init(e,t),tx.init(e,t)});function tU(e,t){return new tq({type:"array",element:e,...eq(t)})}let tV=e_("ZodMiniObject",(e,t)=>{th.init(e,t),tx.init(e,t),eP(e,"shape",()=>t.shape)});function tN(e,t){return new tV({type:"object",shape:e??{},...eq(t)})}function tD(e,t){if(!eC(t))throw Error("Invalid input to extend: expected a plain object");let i=e._zod.def.checks;if(i&&i.length>0){let i=e._zod.def.shape;for(let e in t)if(void 0!==Object.getOwnPropertyDescriptor(i,e))throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}let n=eA(e._zod.def,{get shape(){let i={...e._zod.def.shape,...t};return ej(this,"shape",i),i}});return eB(e,n)}function tZ(e,t){return e.clone({...e._zod.def,catchall:t})}let tQ=e_("ZodMiniUnion",(e,t)=>{tv.init(e,t),tx.init(e,t)});function tL(e,t){return new tQ({type:"union",options:e,...eq(t)})}let tJ=e_("ZodMiniDiscriminatedUnion",(e,t)=>{ty.init(e,t),tx.init(e,t)});function tH(e,t,i){return new tJ({type:"union",options:t,discriminator:e,...eq(i)})}let tK=e_("ZodMiniRecord",(e,t)=>{tg.init(e,t),tx.init(e,t)});function tW(e,t,i){return new tK({type:"record",keyType:e,valueType:t,...eq(i)})}let tG=e_("ZodMiniEnum",(e,t)=>{tm.init(e,t),tx.init(e,t),e.options=Object.values(t.entries)});function tX(e,t){return new tG({type:"enum",entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...eq(t)})}let tY=e_("ZodMiniLiteral",(e,t)=>{tb.init(e,t),tx.init(e,t)});function t0(e,t){return new tY({type:"literal",values:Array.isArray(e)?e:[e],...eq(t)})}let t1=e_("ZodMiniOptional",(e,t)=>{t_.init(e,t),tx.init(e,t)});function t2(e){return new t1({type:"optional",innerType:e})}let t6=e_("ZodMiniNullable",(e,t)=>{tz.init(e,t),tx.init(e,t)});function t3(e){return new t6({type:"nullable",innerType:e})}let t4=e_("ZodMiniPrefault",(e,t)=>{tO.init(e,t),tx.init(e,t)});function t8(e,t){return new t4({type:"prefault",innerType:e,get defaultValue(){return"function"==typeof t?t():eC(t)?{...t}:Array.isArray(t)?[...t]:t}})}let t5=e_("ZodMiniLazy",(e,t)=>{tS.init(e,t),tx.init(e,t)});function t9(){let e=new t5({type:"lazy",getter:()=>tL([tI(),tj(),tT(),tC(),tU(e),tW(tI(),e)])});return e}let t7=e_("ZodMiniISODateTime",(e,t)=>{tr.init(e,t),t$.init(e,t)});function ie(e){return new t7({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...eq(e)})}let it=tZ(tN({}),t9()),ii=tN({sys:tN({type:t0("Link"),linkType:tI(),id:tI()})}),ir=tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:tI()})}),is=tN({sys:tN({type:t0("Link"),linkType:t0("Environment"),id:tI()})}),io=tN({sys:tN({type:t0("Link"),linkType:t0("Space"),id:tI()})}),ia=tN({sys:tN({type:t0("Link"),linkType:t0("TaxonomyConcept"),id:tI()})}),il=tN({sys:tN({type:t0("Link"),linkType:t0("Tag"),id:tI()})}),iu=tN({type:t0("Entry"),contentType:ir,publishedVersion:tj(),id:tI(),createdAt:tM(),updatedAt:tM(),locale:t2(tI()),revision:tj(),space:io,environment:is}),ic=tN({fields:it,metadata:tN({tags:tU(il),concepts:t2(tU(ia))}),sys:iu}),id=tD(it,{nt_audience_id:tI(),nt_name:t2(tI()),nt_description:t2(tI())}),ip=tD(ic,{fields:id});tN({contentTypeId:t0("nt_audience"),fields:id});let ih=tD(ic,{fields:tN({nt_name:tI(),nt_fallback:t2(tI()),nt_mergetag_id:tI()}),sys:tD(iu,{contentType:tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:t0("nt_mergetag")})})})}),iv=tN({id:tI(),hidden:t2(tT())}),iy=tN({type:t2(t0("EntryReplacement")),baseline:iv,variants:tU(iv)}),ig=tN({value:tL([tI(),tT(),tC(),tj(),tW(tI(),t9())])}),im=tX(["Boolean","Number","Object","String"]),ib=tH("type",[iy,tN({type:t0("InlineVariable"),key:tI(),valueType:im,baseline:ig,variants:tU(ig)})]),iw=tU(ib),i_=tN({distribution:t2(tU(tj())),traffic:t2(tj()),components:t2(iw),sticky:t2(tT())}),iz=tL([t0("nt_experiment"),t0("nt_personalization")]),iO=tD(it,{nt_name:tI(),nt_description:t2(t3(tI())),nt_type:iz,nt_config:t2(t3(i_)),nt_audience:t2(t3(ip)),nt_variants:t2(tU(tL([ii,ic]))),nt_experience_id:tI()}),iS=tD(ic,{fields:iO});tN({contentTypeId:t0("nt_experience"),fields:iO});let iE=tD(ic,{fields:tD(it,{nt_experiences:tU(tL([ii,iS]))})});function ix(e){return iS.safeParse(e).success}function ik(e){return iE.safeParse(e).success}let iI=t2(tN({name:tI(),version:tI()})),i$=tN({name:t2(tI()),source:t2(tI()),medium:t2(tI()),term:t2(tI()),content:t2(tI())}),iP=tL([t0("mobile"),t0("server"),t0("web")]),ij=tW(tI(),tI()),iA=tN({latitude:tj(),longitude:tj()}),iT=tN({coordinates:t2(iA),city:t2(tI()),postalCode:t2(tI()),region:t2(tI()),regionCode:t2(tI()),country:t2(tI()),countryCode:t2(tI().check(new e9({check:"length_equals",...eq(void 0),length:2}))),continent:t2(tI()),timezone:t2(tI())}),iF=tN({name:tI(),version:tI()}),iC=tZ(tN({path:tI(),query:ij,referrer:tI(),search:tI(),title:t2(tI()),url:tI()}),t9()),iR=tW(tI(),t9()),iM=tZ(tN({name:tI()}),t9()),iB=tW(tI(),t9()),iq=tN({app:iI,campaign:i$,gdpr:tN({isConsentGiven:tT()}),library:iF,locale:tI(),location:t2(iT),userAgent:t2(tI())}),iU=tN({channel:iP,context:tD(iq,{page:t2(iC),screen:t2(iM)}),messageId:tI(),originalTimestamp:ie(),sentAt:ie(),timestamp:ie(),userId:t2(tI())}),iV=tD(iU,{type:t0("alias")}),iN=tD(iU,{type:t0("group")}),iD=tD(iU,{type:t0("identify"),traits:iB}),iZ=tD(iq,{page:iC}),iQ=tD(iU,{type:t0("page"),name:t2(tI()),properties:iC,context:iZ}),iL=tD(iq,{screen:iM}),iJ=tD(iU,{type:t0("screen"),name:tI(),properties:t2(iR),context:iL}),iH=tD(iU,{type:t0("track"),event:tI(),properties:iR}),iK=tD(iU,{componentType:tL([t0("Entry"),t0("Variable")]),componentId:tI(),experienceId:t2(tI()),variantIndex:tj()}),iW=tD(iK,{type:t0("component"),viewDurationMs:t2(tj()),viewId:t2(tI())}),iG={anonymousId:tI()},iX=tU(tH("type",[tD(iV,iG),tD(iW,iG),tD(iN,iG),tD(iD,iG),tD(iQ,iG),tD(iJ,iG),tD(iH,iG)])),iY=tH("type",[iV,iW,iN,iD,iQ,iJ,iH]),i0=tU(iY),i1=tN({features:t2(tU(tI()))}),i2=tN({events:i0.check(tE(1)),options:t2(i1)}),i6=tN({events:iX.check(tE(1)),options:t2(i1)}),i3=tN({id:tI(),isReturningVisitor:tT(),landingPage:iC,count:tj(),activeSessionLength:tj(),averageSessionLength:tj()}),i4=tN({id:tI(),stableId:tI(),random:tj(),audiences:tU(tI()),traits:iB,location:iT,session:i3}),i8=tZ(tN({id:tI()}),t9()),i5=tN({data:tN(),message:tI(),error:t3(tT())}),i9=tD(i5,{data:tN({profiles:t2(tU(i4))})}),i7=tN({key:tI(),type:tL([tX(["Variable"]),tI()]),meta:tN({experienceId:tI(),variantIndex:tj()})}),ne=tL([tI(),tT(),tC(),tj(),tW(tI(),t9())]);tD(i7,{type:tI(),value:new tB({type:"unknown"})});let nt=tU(tH("type",[tD(i7,{type:t0("Variable"),value:ne})])),ni=tU(tN({experienceId:tI(),variantIndex:tj(),variants:tW(tI(),tI()),sticky:t2(t8(tT(),!1))})),nn=tD(i5,{data:tN({profile:i4,experiences:ni,changes:nt})}),nr=tH("type",[iW,tD(iK,{type:t0("component_click")}),tD(iK,{type:t0("component_hover"),hoverDurationMs:tj(),hoverId:tI()})]),ns=tN({profile:i8,events:tU(nr)}),no=tU(ns);function na(e,t){let i=e.safeParse(t);if(i.success)return i.data;throw Error(function(e){let t=[];for(let i of[...e.issues].sort((e,t)=>(e.path??[]).length-(t.path??[]).length))t.push(`✖ ${i.message}`),i.path?.length&&t.push(` → at ${function(e){let t=[];for(let i of e.map(e=>"object"==typeof e?e.key:e))"number"==typeof i?t.push(`[${i}]`):"symbol"==typeof i?t.push(`[${JSON.stringify(String(i))}]`):/[^\w$]/.test(i)?t.push(`[${JSON.stringify(i)}]`):(t.length&&t.push("."),t.push(i));return t.join("")}(i.path)}`);return t.join("\n")}(i.error))}eS({localeError:(r={string:{unit:"characters",verb:"to have"},file:{unit:"bytes",verb:"to have"},array:{unit:"items",verb:"to have"},set:{unit:"items",verb:"to have"},map:{unit:"entries",verb:"to have"}},s={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",mac:"MAC address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},o={nan:"NaN"},e=>{switch(e.code){case"invalid_type":{let t=o[e.expected]??e.expected,i=function(e){let t=typeof e;switch(t){case"number":return Number.isNaN(e)?"nan":"number";case"object":if(null===e)return"null";if(Array.isArray(e))return"array";if(e&&Object.getPrototypeOf(e)!==Object.prototype&&"constructor"in e&&e.constructor)return e.constructor.name}return t}(e.input),n=o[i]??i;return`Invalid input: expected ${t}, received ${n}`}case"invalid_value":if(1===e.values.length)return`Invalid input: expected ${eU(e.values[0])}`;return`Invalid option: expected one of ${eE(e.values,"|")}`;case"too_big":{let t=e.inclusive?"<=":"<",i=r[e.origin]??null;if(i)return`Too big: expected ${e.origin??"value"} to have ${t}${e.maximum.toString()} ${i.unit??"elements"}`;return`Too big: expected ${e.origin??"value"} to be ${t}${e.maximum.toString()}`}case"too_small":{let t=e.inclusive?">=":">",i=r[e.origin]??null;if(i)return`Too small: expected ${e.origin} to have ${t}${e.minimum.toString()} ${i.unit}`;return`Too small: expected ${e.origin} to be ${t}${e.minimum.toString()}`}case"invalid_format":if("starts_with"===e.format)return`Invalid string: must start with "${e.prefix}"`;if("ends_with"===e.format)return`Invalid string: must end with "${e.suffix}"`;if("includes"===e.format)return`Invalid string: must include "${e.includes}"`;if("regex"===e.format)return`Invalid string: must match pattern ${e.pattern}`;return`Invalid ${s[e.format]??e.format}`;case"not_multiple_of":return`Invalid number: must be a multiple of ${e.divisor}`;case"unrecognized_keys":return`Unrecognized key${e.keys.length>1?"s":""}: ${eE(e.keys,", ")}`;case"invalid_key":return`Invalid key in ${e.origin}`;case"invalid_union":default:return"Invalid input";case"invalid_element":return`Invalid value in ${e.origin}`}})});let nl=new class{name="@contentful/optimization";PREFIX_PARTS=["Ctfl","O10n"];DELIMITER=":";sinks=[];assembleLocationPrefix(e){return`[${[...this.PREFIX_PARTS,e].join(this.DELIMITER)}]`}addSink(e){this.sinks=[...this.sinks.filter(t=>t.name!==e.name),e]}removeSink(e){this.sinks=this.sinks.filter(t=>t.name!==e)}removeSinks(){this.sinks=[]}debug(e,t,...i){this.emit("debug",e,t,...i)}info(e,t,...i){this.emit("info",e,t,...i)}log(e,t,...i){this.emit("log",e,t,...i)}warn(e,t,...i){this.emit("warn",e,t,...i)}error(e,t,...i){this.emit("error",e,t,...i)}fatal(e,t,...i){this.emit("fatal",e,t,...i)}emit(e,t,i,...n){this.onLogEvent({name:this.name,level:e,messages:[`${this.assembleLocationPrefix(t)} ${String(i)}`,...n]})}onLogEvent(e){this.sinks.forEach(t=>{t.ingest(e)})}};function nu(e){return{debug:(t,...i)=>{nl.debug(e,t,...i)},info:(t,...i)=>{nl.info(e,t,...i)},log:(t,...i)=>{nl.log(e,t,...i)},warn:(t,...i)=>{nl.warn(e,t,...i)},error:(t,...i)=>{nl.error(e,t,...i)},fatal:(t,...i)=>{nl.fatal(e,t,...i)}}}let nc={fatal:60,error:50,warn:40,info:30,debug:20,log:10},nd=class{},np={debug:(...e)=>{console.debug(...e)},info:(...e)=>{console.info(...e)},log:(...e)=>{console.log(...e)},warn:(...e)=>{console.warn(...e)},error:(...e)=>{console.error(...e)},fatal:(...e)=>{console.error(...e)}};class nh extends nd{name="ConsoleLogSink";verbosity;constructor(e){super(),this.verbosity=e??"error"}ingest(e){nc[e.level]{i(void 0)},e),await t}let ng=nu("ApiClient:Timeout"),nm=nu("ApiClient:Fetch"),nb=function(e){try{let t=function({apiName:e="Optimization",fetchMethod:t=fetch,onRequestTimeout:i,requestTimeout:n=3e3}={}){return async(r,s)=>{let o=new AbortController,a=setTimeout(()=>{"function"==typeof i?i({apiName:e}):ng.error(`Request to "${r.toString()}" timed out`,Error("Request timeout")),o.abort()},n),l=await t(r,{...s,signal:o.signal});return clearTimeout(a),l}}(e);return function({apiName:e="Optimization",fetchMethod:t=fetch,intervalTimeout:i=0,onFailedAttempt:n,retries:r=1}={}){return async(s,o)=>{let a=new AbortController,l=r+1,u=function({apiName:e="Optimization",controller:t,fetchMethod:i=fetch,init:n,url:r}){return async()=>{try{let s=await i(r,n);if(503===s.status)throw new nv(`${e} API request to "${r.toString()}" failed with status: "[${s.status}] ${s.statusText}".`,503);if(!s.ok){let e=Error(`Request to "${r.toString()}" failed with status: [${s.status}] ${s.statusText} - traceparent: ${s.headers.get("traceparent")}`);nf.error("Request failed with non-OK status:",e),t.abort();return}return nf.debug(`Response from "${r.toString()}":`,s),s}catch(e){if(e instanceof nv&&503===e.status)throw e;nf.error(`Request to "${r.toString()}" failed:`,e),t.abort()}}}({apiName:e,controller:a,fetchMethod:t,init:o,url:s});for(let t=1;t<=l;t++)try{let e=await u();if(e)return e;break}catch(s){if(!(s instanceof nv)||503!==s.status)throw s;let r=l-t;if(n?.({apiName:e,error:s,attemptNumber:t,retriesLeft:r}),0===r)throw s;await ny(i)}throw Error(`${e} API request to "${s.toString()}" may not be retried.`)}}({...e,fetchMethod:t})}catch(e){throw e instanceof Error&&("AbortError"===e.name?nm.warn("Request aborted due to network issues. This request may not be retried."):nm.error("Request failed:",e)),e}},nw=nu("ApiClient"),n_=class{name;clientId;environment;fetch;constructor(e,{fetchOptions:t,clientId:i,environment:n}){this.clientId=i,this.environment=n??"main",this.name=e,this.fetch=nb({...t??{},apiName:e})}logRequestError(e,{requestName:t}){e instanceof Error&&("AbortError"===e.name?nw.warn(`[${this.name}] "${t}" request aborted due to network issues. This request may not be retried.`):nw.error(`[${this.name}] "${t}" request failed:`,e))}},nz=nu("ApiClient:Experience");class nO extends n_{baseUrl;enabledFeatures;ip;locale;plainText;preflight;constructor(e){super("Experience",e);const{baseUrl:t,enabledFeatures:i,ip:n,locale:r,plainText:s,preflight:o}=e;this.baseUrl=t||"https://experience.ninetailed.co/",this.enabledFeatures=i,this.ip=n,this.locale=r,this.plainText=s,this.preflight=o}async getProfile(e,t={}){if(!e)throw Error("Valid profile ID required.");let i="Get Profile";nz.info(`Sending "${i}" request`);try{let n=await this.fetch(this.constructUrl(`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,t),{method:"GET"}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await n.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async makeProfileMutationRequest({url:e,body:t,options:i}){return await this.fetch(this.constructUrl(e,i),{method:"POST",headers:this.constructHeaders(i),body:JSON.stringify(t),keepalive:!0})}async createProfile({events:e},t={}){let i="Create Profile";nz.info(`Sending "${i}" request`);let n=this.constructExperienceRequestBody(e,t);nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles`,body:n,options:t}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await e.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async updateProfile({profileId:e,events:t},i={}){if(!e)throw Error("Valid profile ID required.");let n="Update Profile";nz.info(`Sending "${n}" request`);let r=this.constructExperienceRequestBody(t,i);nz.debug(`"${n}" request body:`,r);try{let t=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,body:r,options:i}),{data:{changes:s,experiences:o,profile:a}}=na(nn,await t.json());return nz.debug(`"${n}" request successfully completed`),{changes:s,selectedOptimizations:o,profile:a}}catch(e){throw this.logRequestError(e,{requestName:n}),e}}async upsertProfile({profileId:e,events:t},i){return e?await this.updateProfile({profileId:e,events:t},i):await this.createProfile({events:t},i)}async upsertManyProfiles({events:e},t={}){let i="Upsert Many Profiles";nz.info(`Sending "${i}" request`);let n=na(i6,{events:e,options:this.constructBodyOptions(t)});nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/events`,body:n,options:{plainText:!1,...t}}),{data:{profiles:r}}=na(i9,await e.json());return nz.debug(`"${i}" request successfully completed`),r}catch(e){throw this.logRequestError(e,{requestName:i}),e}}constructUrl(e,t){let i=new URL(e,this.baseUrl),n=t.locale??this.locale,r=t.preflight??this.preflight;return n&&i.searchParams.set("locale",n),r&&i.searchParams.set("type","preflight"),i.toString()}constructHeaders({ip:e=this.ip,plainText:t=this.plainText}){let i=new Map;return e&&i.set("X-Force-IP",e),t??this.plainText??!0?i.set("Content-Type","text/plain"):i.set("Content-Type","application/json"),Object.fromEntries(i)}constructBodyOptions=({enabledFeatures:e=this.enabledFeatures})=>{let t={};return e&&Array.isArray(e)&&e.length>0?t.features=e:t.features=["ip-enrichment","location"],t};constructExperienceRequestBody(e,t){return i2.parse({events:na(i0,e),options:this.constructBodyOptions(t)})}}let nS=nu("ApiClient:Insights");class nE extends n_{baseUrl;beaconHandler;constructor(e){super("Insights",e);const{baseUrl:t,beaconHandler:i}=e;this.baseUrl=t||"https://ingest.insights.ninetailed.co/",this.beaconHandler=i}async sendBatchEvents(e,t={}){let{beaconHandler:i=this.beaconHandler}=t,n=new URL(`v1/organizations/${this.clientId}/environments/${this.environment}/events`,this.baseUrl),r=na(no,e);if("function"==typeof i){if(nS.debug("Queueing events via beaconHandler"),i(n,r))return!0;nS.warn("beaconHandler failed to queue events; events will be emitted immediately via fetch")}let s="Event Batches";nS.info(`Sending "${s}" request`),nS.debug(`"${s}" request body:`,r);try{return await this.fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r),keepalive:!0}),nS.debug(`"${s}" request successfully completed`),!0}catch(e){return this.logRequestError(e,{requestName:s}),!1}}}class nx{config;experience;insights;constructor(e){const{experience:t,insights:i,clientId:n,environment:r,fetchOptions:s}=e,o={clientId:n,environment:r,fetchOptions:s};this.config=o,this.experience=new nO({...o,...t}),this.insights=new nE({...o,...i})}}function nk(e){if(!e||"object"!=typeof e)return!1;let t=Object.getPrototypeOf(e);return(null===t||t===Object.prototype||null===Object.getPrototypeOf(t))&&"[object Object]"===Object.prototype.toString.call(e)}function nI(e){return nk(e)||Array.isArray(e)}let n$=tN({campaign:t2(i$),locale:t2(tI()),location:t2(iT),page:t2(iC),screen:t2(iM),userAgent:t2(tI())}),nP=tD(n$,{componentId:tI(),experienceId:t2(tI()),variantIndex:t2(tj())}),nj=tD(nP,{sticky:t2(tT()),viewId:tI(),viewDurationMs:tj()}),nA=tD(nP,{viewId:t2(tI()),viewDurationMs:t2(tj())}),nT=tD(nP,{hoverId:tI(),hoverDurationMs:tj()}),nF=tD(n$,{traits:t2(iB),userId:tI()}),nC=tD(n$,{properties:t2(function(e,t){var i=void 0;let n=e._zod.def.checks;if(n&&n.length>0)throw Error(".partial() cannot be used on object schemas containing refinements");let r=eA(e._zod.def,{get shape(){let t=e._zod.def.shape,n={...t};if(i)for(let e in i){if(!(e in t))throw Error(`Unrecognized key: "${e}"`);i[e]&&(n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e])}else for(let e in t)n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e];return ej(this,"shape",n),n},checks:[]});return eB(e,r)}(iC))}),nR=tD(n$,{name:tI(),properties:iR}),nM=tD(n$,{event:tI(),properties:t2(t8(iR,{}))}),nB={path:"",query:{},referrer:"",search:"",title:"",url:""},nq=class{app;channel;library;getLocale;getPageProperties;getUserAgent;constructor(e){const{app:t,channel:i,library:n,getLocale:r,getPageProperties:s,getUserAgent:o}=e;this.app=t,this.channel=i,this.library=n,this.getLocale=r??(()=>"en-US"),this.getPageProperties=s??(()=>nB),this.getUserAgent=o??(()=>void 0)}buildUniversalEventProperties({campaign:e={},locale:t,location:i,page:n,screen:r,userAgent:s}){let o=new Date().toISOString();return{channel:this.channel,context:{app:this.app,campaign:e,gdpr:{isConsentGiven:!0},library:this.library,locale:t??this.getLocale()??"en-US",location:i,page:n??this.getPageProperties(),screen:r,userAgent:s??this.getUserAgent()},messageId:crypto.randomUUID(),originalTimestamp:o,sentAt:o,timestamp:o}}buildEntryInteractionBase(e,t,i,n){return{...this.buildUniversalEventProperties(e),componentType:"Entry",componentId:t,experienceId:i,variantIndex:n??0}}buildView(e){let{componentId:t,viewId:i,experienceId:n,variantIndex:r,viewDurationMs:s,...o}=na(nj,e);return{...this.buildEntryInteractionBase(o,t,n,r),type:"component",viewId:i,viewDurationMs:s}}buildClick(e){let{componentId:t,experienceId:i,variantIndex:n,...r}=na(nP,e);return{...this.buildEntryInteractionBase(r,t,i,n),type:"component_click"}}buildHover(e){let{hoverId:t,componentId:i,experienceId:n,hoverDurationMs:r,variantIndex:s,...o}=na(nT,e);return{...this.buildEntryInteractionBase(o,i,n,s),type:"component_hover",hoverId:t,hoverDurationMs:r}}buildFlagView(e){let{componentId:t,experienceId:i,variantIndex:n,viewId:r,viewDurationMs:s,...o}=na(nA,e);return{...this.buildEntryInteractionBase(o,t,i,n),...void 0===s?{}:{viewDurationMs:s},...void 0===r?{}:{viewId:r},type:"component",componentType:"Variable"}}buildIdentify(e){let{traits:t={},userId:i,...n}=na(nF,e);return{...this.buildUniversalEventProperties(n),type:"identify",traits:t,userId:i}}buildPageView(e={}){let{properties:t={},...i}=na(nC,e),n=this.getPageProperties(),r=function e(t,i){let n=Object.keys(i);for(let r=0;re?e.reduce((e,{key:t,value:i})=>{let n="object"==typeof i&&null!==i&&"value"in i&&"object"==typeof i.value?i.value:i;return e[t]=n,e},{}):{}},nN=nu("Optimization"),nD="Could not resolve Merge Tag value:",nZ=(e,t)=>{if(!e||"object"!=typeof e)return;if(!t)return e;let i=e;for(let e of t.split(".").filter(Boolean)){if(!i||"object"!=typeof i&&"function"!=typeof i)return;i=Reflect.get(i,e)}return i},nQ={normalizeSelectors:e=>e.split("_").map((e,t,i)=>[i.slice(0,t).join("."),i.slice(t).join("_")].filter(e=>""!==e).join(".")),getValueFromProfile(e,t){let i=nQ.normalizeSelectors(e).find(e=>nZ(t,e));if(!i)return;let n=nZ(t,i);if(n&&("string"==typeof n||"number"==typeof n||"boolean"==typeof n))return`${n}`},resolve(e,t){if(!ih.safeParse(e).success)return void nN.warn(`${nD} supplied entry is not a Merge Tag entry`);let{fields:{nt_fallback:i}}=e;return i4.safeParse(t).success?nQ.getValueFromProfile(e.fields.nt_mergetag_id,t)??i:(nN.warn(`${nD} no valid profile`),i)}},nL=nu("Optimization"),nJ="Could not resolve optimized entry variant:",nH={getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ik(e))return e.fields.nt_experiences.filter(e=>ix(e)).find(e=>t.some(({experienceId:t})=>t===e.fields.nt_experience_id))},getSelectedOptimization({optimizationEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ix(e))return t.find(({experienceId:t})=>t===e.fields.nt_experience_id)},getSelectedVariant({optimizedEntry:e,optimizationEntry:t,selectedVariantIndex:i},n=!1){var r;if(!n&&(!ik(e)||!ix(t)))return;let s=(r=t.fields.nt_config,{distribution:r?.distribution===void 0?[]:[...r.distribution],traffic:r?.traffic??0,components:r?.components===void 0?[]:[...r.components],sticky:r?.sticky??!1}).components.filter(e=>("EntryReplacement"===e.type||void 0===e.type)&&!e.baseline.hidden).find(t=>t.baseline.id===e.sys.id)?.variants;if(s?.length)return s.at(i-1)},getSelectedVariantEntry({optimizationEntry:e,selectedVariant:t},i=!1){if(!i&&(!ix(e)||!iv.safeParse(t).success))return;let n=e.fields.nt_variants?.find(e=>e.sys.id===t.id);return ic.safeParse(n).success?n:void 0},resolve:function(e,t){if(nL.debug(`Resolving optimized entry for baseline entry ${e.sys.id}`),!t?.length)return nL.warn(`${nJ} no selectedOptimizations exist for the current profile`),{entry:e};if(!ik(e))return nL.warn(`${nJ} entry ${e.sys.id} is not optimized`),{entry:e};let i=nH.getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},!0);if(!i)return nL.warn(`${nJ} could not find an optimization entry for ${e.sys.id}`),{entry:e};let n=nH.getSelectedOptimization({optimizationEntry:i,selectedOptimizations:t},!0),r=n?.variantIndex??0;if(0===r)return nL.debug(`Resolved optimization entry for entry ${e.sys.id} is baseline`),{entry:e};let s=nH.getSelectedVariant({optimizedEntry:e,optimizationEntry:i,selectedVariantIndex:r},!0);if(!s)return nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e};let o=nH.getSelectedVariantEntry({optimizationEntry:i,selectedVariant:s},!0);return o?(nL.debug(`Entry ${e.sys.id} has been resolved to variant entry ${o.sys.id}`),{entry:o,selectedOptimization:n}):(nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e})}};class nK{api;eventBuilder;config;flagsResolver=nV;mergeTagValueResolver=nQ;optimizedEntryResolver=nH;interceptors={event:new nU,state:new nU};constructor(e,t={}){this.config=e;const{eventBuilder:i,logLevel:n,environment:r,clientId:s,fetchOptions:o}=e;nl.addSink(new nh(n));const a={clientId:s,environment:r,fetchOptions:o,experience:t.experience,insights:t.insights};this.api=new nx(a),this.eventBuilder=new nq(i??{channel:"server",library:{name:"@contentful/optimization-ios-bridge",version:"0.0.0"}})}getFlag(e,t){return this.flagsResolver.resolve(t)[e]}resolveOptimizedEntry(e,t){return this.optimizedEntryResolver.resolve(e,t)}getMergeTagValue(e,t){return this.mergeTagValueResolver.resolve(e,t)}}let nW=nK;function nG(){}function nX(e,t){return function e(t,i,n,r,s,o,a){let l=a(t,i,n,r,s,o);if(void 0!==l)return l;if(typeof t==typeof i)switch(typeof t){case"bigint":case"string":case"boolean":case"symbol":case"undefined":case"function":return t===i;case"number":return t===i||Object.is(t,i)}return function t(i,n,r,s){if(Object.is(i,n))return!0;let o=F(i),a=F(n);if(o===q&&(o=L),a===q&&(a=L),o!==a)return!1;switch(o){case R:return i.toString()===n.toString();case M:{let e=i.valueOf(),t=n.valueOf();return e===t||Number.isNaN(e)&&Number.isNaN(t)}case B:case V:case U:return Object.is(i.valueOf(),n.valueOf());case C:return i.source===n.source&&i.flags===n.flags;case"[object Function]":return i===n}let l=(r=r??new Map).get(i),u=r.get(n);if(null!=l&&null!=u)return l===n;r.set(i,n),r.set(n,i);try{switch(o){case N:if(i.size!==n.size)return!1;for(let[t,o]of i.entries())if(!n.has(t)||!e(o,n.get(t),t,i,n,r,s))return!1;return!0;case D:{if(i.size!==n.size)return!1;let t=Array.from(i.values()),o=Array.from(n.values());for(let a=0;ae(l,t,void 0,i,n,r,s));if(-1===u)return!1;o.splice(u,1)}return!0}case Z:case H:case K:case W:case G:case"[object BigUint64Array]":case X:case Y:case ee:case"[object BigInt64Array]":case et:case ei:if(er(i)!==er(n)||i.length!==n.length)return!1;for(let t=0;t{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))})}return i}resolveOptimizedEntry(e,t=ey.value){return super.resolveOptimizedEntry(e,t)}getMergeTagValue(e,t=em.value){return super.getMergeTagValue(e,t)}async identify(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("identify",[e],this.eventBuilder.buildIdentify(i),t)}async page(e={}){let{profile:t,...i}=e;return await this.sendExperienceEvent("page",[e],this.eventBuilder.buildPageView(i),t)}async screen(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("screen",[e],this.eventBuilder.buildScreenView(i),t)}async track(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("track",[e],this.eventBuilder.buildTrack(i),t)}async trackView(e){let t,{profile:i,...n}=e;return e.sticky&&(t=await this.sendExperienceEvent("trackView",[e],this.eventBuilder.buildView(n),i)),await this.sendInsightsEvent("trackView",[e],this.eventBuilder.buildView(n),i),t}async trackClick(e){await this.sendInsightsEvent("trackClick",[e],this.eventBuilder.buildClick(e))}async trackHover(e){await this.sendInsightsEvent("trackHover",[e],this.eventBuilder.buildHover(e))}async trackFlagView(e){await this.sendInsightsEvent("trackFlagView",[e],this.eventBuilder.buildFlagView(e))}hasConsent(e){let{[e]:t}=n0,i=void 0!==t?this.allowedEventTypes.includes(t):this.allowedEventTypes.some(t=>t===e);return!!ed.value||i}onBlockedByConsent(e,t){nY.warn(`Event "${e}" was blocked due to lack of consent; payload: ${JSON.stringify(t)}`),this.reportBlockedEvent("consent",e,t)}async sendExperienceEvent(e,t,i,n){return this.hasConsent(e)?await this.experienceQueue.send(i):void this.onBlockedByConsent(e,t)}async sendInsightsEvent(e,t,i,n){this.hasConsent(e)?await this.insightsQueue.send(i):this.onBlockedByConsent(e,t)}buildFlagViewBuilderArgs(e,t=eu.value){let i=t?.find(t=>t.key===e);return{componentId:e,experienceId:i?.meta.experienceId,variantIndex:i?.meta.variantIndex}}getFlagObservable(e){var t;let i,n=this.flagObservables.get(e);if(n)return n;let r=this.trackFlagView.bind(this),s=this.buildFlagViewBuilderArgs.bind(this),o=(t=ew.computed(()=>super.getFlag(e,eu.value)),i=el(t),{get current(){return i.current},subscribe(e){let t=!1,n=ea(i.current);return i.subscribe(i=>{t&&nX(n,i)||(t=!0,n=ea(i),e(i))})},subscribeOnce:e=>i.subscribeOnce(e)}),a={get current(){let{current:t}=o;return r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t},subscribe:t=>o.subscribe(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)}),subscribeOnce:t=>o.subscribeOnce(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)})};return this.flagObservables.set(e,a),a}reportBlockedEvent(e,t,i){let n={reason:e,method:t,args:i};try{this.onEventBlocked?.(n)}catch(e){nY.warn(`onEventBlocked callback failed for method "${t}"`,e)}ec.value=n}}let n2=n1,n6=(e,t)=>!Number.isFinite(e)||void 0===e||e<1?t:Math.floor(e),n3={flushIntervalMs:3e4,baseBackoffMs:500,maxBackoffMs:3e4,jitterRatio:.2,maxConsecutiveFailures:8,circuitOpenMs:12e4},n4="__ctfl_optimization_stateful_runtime_lock__",n8=()=>{let e=globalThis;return e[n4]??={owner:void 0},e[n4]},n5=e=>{let t=n8();t.owner===e&&(t.owner=void 0)};class n9{circuitOpenUntil=0;flushFailureCount=0;flushInFlight=!1;nextFlushAllowedAt=0;onCallbackError;onRetry;policy;retryTimer;constructor(e){const{onCallbackError:t,onRetry:i,policy:n}=e;this.policy=n,this.onRetry=i,this.onCallbackError=t}reset(){this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.flushInFlight=!1,this.nextFlushAllowedAt=0}clearScheduledRetry(){void 0!==this.retryTimer&&(clearTimeout(this.retryTimer),this.retryTimer=void 0)}shouldSkip(e){let{force:t,isOnline:i}=e;if(this.flushInFlight)return!0;if(t)return!1;if(!i)return!0;let n=Date.now();return!!(this.nextFlushAllowedAt>n)||!!(this.circuitOpenUntil>n)}markFlushStarted(){this.flushInFlight=!0}markFlushFinished(){this.flushInFlight=!1}handleFlushSuccess(){let{flushFailureCount:e}=this;this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.nextFlushAllowedAt=0,e<=0||this.safeInvoke("onFlushRecovered",{consecutiveFailures:e})}handleFlushFailure(e){let{queuedBatches:t,queuedEvents:i}=e;this.flushFailureCount+=1;let n=(e=>{let{consecutiveFailures:t,policy:{baseBackoffMs:i,jitterRatio:n,maxBackoffMs:r}}=e,s=Math.min(r,i*2**Math.max(0,t-1)),o=s*n*Math.random();return Math.round(s+o)})({consecutiveFailures:this.flushFailureCount,policy:this.policy}),r=Date.now(),s={consecutiveFailures:this.flushFailureCount,queuedBatches:t,queuedEvents:i,retryDelayMs:n};this.safeInvoke("onFlushFailure",s);let{circuitOpenUntil:o,nextFlushAllowedAt:a,openedCircuit:l,retryDelayMs:u}=(e=>{let{consecutiveFailures:t,failureTimestamp:i,retryDelayMs:n,policy:{maxConsecutiveFailures:r,circuitOpenMs:s}}=e;if(t{this.retryTimer=void 0,this.onRetry()},e)}safeInvoke(...e){let[t,i]=e;try{if("onFlushRecovered"===t)return void this.policy.onFlushRecovered?.(i);if("onCircuitOpen"===t)return void this.policy.onCircuitOpen?.(i);this.policy.onFlushFailure?.(i)}catch(e){this.onCallbackError?.(t,e)}}}let n7=nu("CoreStateful");class re{experienceApi;eventInterceptors;flushRuntime;getAnonymousId;offlineMaxEvents;onOfflineDrop;queuedExperienceEvents=new Set;stateInterceptors;constructor(e){const{experienceApi:t,eventInterceptors:i,flushPolicy:n,getAnonymousId:r,offlineMaxEvents:s,onOfflineDrop:o,stateInterceptors:a}=e;this.experienceApi=t,this.eventInterceptors=i,this.getAnonymousId=r,this.offlineMaxEvents=s,this.onOfflineDrop=o,this.stateInterceptors=a,this.flushRuntime=new n9({policy:n,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{n7.warn(`Experience flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}async send(e){let t=na(iY,await this.eventInterceptors.run(e));if(ep.value=t,eh.value)return await this.upsertProfile([t]);n7.debug(`Queueing ${t.type} event`,t),this.enqueueEvent(t)}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!eh.value}))return;if(0===this.queuedExperienceEvents.size)return void this.flushRuntime.clearScheduledRetry();n7.debug("Flushing offline Experience event queue");let i=Array.from(this.queuedExperienceEvents);this.flushRuntime.markFlushStarted();try{await this.tryUpsertQueuedEvents(i)?(i.forEach(e=>{this.queuedExperienceEvents.delete(e)}),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:+(this.queuedExperienceEvents.size>0),queuedEvents:this.queuedExperienceEvents.size})}finally{this.flushRuntime.markFlushFinished()}}enqueueEvent(e){let t=[];if(this.queuedExperienceEvents.size>=this.offlineMaxEvents){let e=this.queuedExperienceEvents.size-this.offlineMaxEvents+1;(t=this.dropOldestEvents(e)).length>0&&n7.warn(`Dropped ${t.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`)}this.queuedExperienceEvents.add(e),t.length>0&&this.invokeOfflineDropCallback({droppedCount:t.length,droppedEvents:t,maxEvents:this.offlineMaxEvents,queuedEvents:this.queuedExperienceEvents.size})}dropOldestEvents(e){let t=[];for(let i=0;i{nX(eu.value,t)||(eu.value=t),nX(em.value,i)||(em.value=i),nX(ey.value,n)||(ey.value=n)})}}let rt=nu("CoreStateful");class ri{eventInterceptors;flushIntervalMs;flushRuntime;insightsApi;queuedInsightsByProfile=new Map;insightsPeriodicFlushTimer;constructor(e){const{eventInterceptors:t,flushPolicy:i,insightsApi:n}=e,{flushIntervalMs:r}=i;this.eventInterceptors=t,this.flushIntervalMs=r,this.insightsApi=n,this.flushRuntime=new n9({policy:i,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{rt.warn(`Insights flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}clearPeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer&&(clearInterval(this.insightsPeriodicFlushTimer),this.insightsPeriodicFlushTimer=void 0)}async send(e){let{value:t}=em;if(!t)return void rt.warn("Attempting to emit an event without an Optimization profile");let i=na(nr,await this.eventInterceptors.run(e));rt.debug(`Queueing ${i.type} event for profile ${t.id}`,i);let n=this.queuedInsightsByProfile.get(t.id);ep.value=i,n?(n.profile=t,n.events.push(i)):this.queuedInsightsByProfile.set(t.id,{profile:t,events:[i]}),this.ensurePeriodicFlushTimer(),this.getQueuedEventCount()>=25&&await this.flush(),this.reconcilePeriodicFlushTimer()}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!eh.value}))return;rt.debug("Flushing insights event queue");let i=this.createBatches();if(!i.length){this.flushRuntime.clearScheduledRetry(),this.reconcilePeriodicFlushTimer();return}this.flushRuntime.markFlushStarted();try{await this.trySendBatches(i)?(this.queuedInsightsByProfile.clear(),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:i.length,queuedEvents:this.getQueuedEventCount()})}finally{this.flushRuntime.markFlushFinished(),this.reconcilePeriodicFlushTimer()}}createBatches(){let e=[];return this.queuedInsightsByProfile.forEach(({profile:t,events:i})=>{e.push({profile:t,events:i})}),e}async trySendBatches(e){try{return await this.insightsApi.sendBatchEvents(e)}catch(e){return rt.warn("Insights queue flush request threw an error",e),!1}}getQueuedEventCount(){let e=0;return this.queuedInsightsByProfile.forEach(({events:t})=>{e+=t.length}),e}ensurePeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer||0!==this.getQueuedEventCount()&&(this.insightsPeriodicFlushTimer=setInterval(()=>{this.flush()},this.flushIntervalMs))}reconcilePeriodicFlushTimer(){this.getQueuedEventCount()>0?this.ensurePeriodicFlushTimer():this.clearPeriodicFlushTimer()}}let rn=Symbol.for("ctfl.optimization.preview.signals"),rr=Symbol.for("ctfl.optimization.preview.signalFns"),rs=nu("CoreStateful"),ro=["identify","page","screen"],ra=e=>Object.values(e).some(e=>void 0!==e),rl=0;class ru extends n2{singletonOwner;destroyed=!1;allowedEventTypes;experienceQueue;insightsQueue;onEventBlocked;states={blockedEventStream:el(ec),flag:e=>this.getFlagObservable(e),consent:el(ed),eventStream:el(ep),canOptimize:el(eg),selectedOptimizations:el(ey),previewPanelAttached:el(ef),previewPanelOpen:el(ev),profile:el(em)};constructor(e){super(e,{experience:(e=>{if(void 0===e)return;let t={baseUrl:e.experienceBaseUrl,enabledFeatures:e.enabledFeatures,ip:e.ip,locale:e.locale,plainText:e.plainText,preflight:e.preflight};return ra(t)?t:void 0})(e.api),insights:(e=>{if(void 0===e)return;let t={baseUrl:e.insightsBaseUrl,beaconHandler:e.beaconHandler};return ra(t)?t:void 0})(e.api)}),this.singletonOwner=`CoreStateful#${++rl}`,(e=>{let t=n8();if(t.owner)throw Error(`Stateful Optimization SDK already initialized (${t.owner}). Only one stateful instance is supported per runtime.`);t.owner=e})(this.singletonOwner);try{const{allowedEventTypes:t,defaults:i,getAnonymousId:n,onEventBlocked:r,queuePolicy:s}=e,{changes:o,consent:a,selectedOptimizations:l,profile:u}=i??{},c=(e=>({flush:((e,t=n3)=>{var i,n;let r=e??{},s=n6(r.baseBackoffMs,t.baseBackoffMs),o=Math.max(s,n6(r.maxBackoffMs,t.maxBackoffMs));return{flushIntervalMs:n6(r.flushIntervalMs,t.flushIntervalMs),baseBackoffMs:s,maxBackoffMs:o,jitterRatio:(i=r.jitterRatio,n=t.jitterRatio,Number.isFinite(i)&&void 0!==i?Math.min(1,Math.max(0,i)):n),maxConsecutiveFailures:n6(r.maxConsecutiveFailures,t.maxConsecutiveFailures),circuitOpenMs:n6(r.circuitOpenMs,t.circuitOpenMs),onCircuitOpen:r.onCircuitOpen,onFlushFailure:r.onFlushFailure,onFlushRecovered:r.onFlushRecovered}})(e?.flush),offlineMaxEvents:n6(e?.offlineMaxEvents,100),onOfflineDrop:e?.onOfflineDrop}))(s);this.allowedEventTypes=t??ro,this.onEventBlocked=r,this.insightsQueue=new ri({eventInterceptors:this.interceptors.event,flushPolicy:c.flush,insightsApi:this.api.insights}),this.experienceQueue=new re({experienceApi:this.api.experience,eventInterceptors:this.interceptors.event,flushPolicy:c.flush,getAnonymousId:n??(()=>void 0),offlineMaxEvents:c.offlineMaxEvents,onOfflineDrop:c.onOfflineDrop,stateInterceptors:this.interceptors.state}),void 0!==a&&(ed.value=a),p(()=>{void 0!==o&&(eu.value=o),void 0!==l&&(ey.value=l),void 0!==u&&(em.value=u)}),this.initializeEffects()}catch(e){throw n5(this.singletonOwner),e}}initializeEffects(){A(()=>{rs.debug(`Profile ${em.value&&`with ID ${em.value.id}`} has been ${em.value?"set":"cleared"}`)}),A(()=>{rs.debug(`Variants have been ${ey.value?.length?"populated":"cleared"}`)}),A(()=>{rs.info(`Core ${ed.value?"will":"will not"} emit gated events due to consent (${ed.value})`)}),A(()=>{eh.value&&(this.insightsQueue.clearScheduledRetry(),this.experienceQueue.clearScheduledRetry(),this.flushQueues({force:!0}))})}async flushQueues(e={}){await this.insightsQueue.flush(e),await this.experienceQueue.flush(e)}destroy(){this.destroyed||(this.destroyed=!0,this.insightsQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush insights queue during destroy()",String(e))}),this.experienceQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush Experience queue during destroy()",String(e))}),this.insightsQueue.clearPeriodicFlushTimer(),n5(this.singletonOwner))}reset(){p(()=>{ec.value=void 0,ep.value=void 0,eu.value=void 0,em.value=void 0,ey.value=void 0})}async flush(){await this.flushQueues()}consent(e){ed.value=e}get online(){return eh.value??!1}set online(e){eh.value=e}registerPreviewPanel(e){Reflect.set(e,rn,eb),Reflect.set(e,rr,ew)}}let rc="ALL_VISITORS";function rd(e,t){let{audiences:{[t]:i}}=e;return i?i.isActive?"on":"off":"default"}function rp(e,t){return"on"===e||"off"!==e&&t}function rh(e,t,i,n){let r=t[e.id]??0,s=void 0!==i.selectedOptimizations[e.id],o={...e,currentVariantIndex:r,isOverridden:s};if(s&&void 0!==n){let{[e.id]:t}=n;void 0!==t&&(o.naturalVariantIndex=t)}return o}function rf(e,t){let i=Object.values(t);if(0===i.length)return e;let n=e.map(e=>{let{[e.experienceId]:i}=t;return i?{...e,variantIndex:i.variantIndex}:e});for(let e of i)n.some(t=>t.experienceId===e.experienceId)||n.push({experienceId:e.experienceId,variantIndex:e.variantIndex,variants:{}});return n}nu("Preview");let rv=nu("PreviewOverrides"),ry={audiences:{},selectedOptimizations:{}};class rg{baselineSelectedOptimizations=null;baselineAudienceQualifications={};overrides={...ry,audiences:{},selectedOptimizations:{}};interceptorId=null;selectedOptimizations;profile;stateInterceptors;onOverridesChanged;constructor(e){const{selectedOptimizations:t,profile:i,stateInterceptors:n,onOverridesChanged:r}=e;this.selectedOptimizations=t,this.profile=i,this.stateInterceptors=n,this.onOverridesChanged=r;const{value:s}=t;s&&(this.baselineSelectedOptimizations=s,rv.debug("Captured initial signal state as baseline")),this.interceptorId=e.stateInterceptors.add(e=>{let{selectedOptimizations:t}=e;this.baselineSelectedOptimizations=t;let i=Object.keys(this.overrides.selectedOptimizations).length>0,n=i?{...e,selectedOptimizations:rf(e.selectedOptimizations,this.overrides.selectedOptimizations)}:{...e};return i&&rv.debug("Intercepting state update to preserve overrides"),this.notifyChanged(),n}),rv.info("State interceptor registered")}activateAudience(e,t){rv.info("Activating audience override:",e),this.setAudienceOverride(e,!0,1,t)}deactivateAudience(e,t){rv.info("Deactivating audience override:",e),this.setAudienceOverride(e,!1,0,t)}resetAudienceOverride(e){rv.info("Resetting audience override:",e);let{overrides:t}=this,{audiences:i,selectedOptimizations:n}=t,r=i[e]?.experienceIds??[],s=new Set(r),o=Object.fromEntries(Object.entries(n).filter(([e])=>!s.has(e))),a=Object.fromEntries(Object.entries(i).filter(([t])=>t!==e));this.overrides={audiences:a,selectedOptimizations:o},this.baselineAudienceQualifications=Object.fromEntries(Object.entries(this.baselineAudienceQualifications).filter(([t])=>t!==e)),r.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}setVariantOverride(e,t){rv.info("Setting variant override:",{experienceId:e,variantIndex:t}),this.overrides={...this.overrides,selectedOptimizations:{...this.overrides.selectedOptimizations,[e]:{experienceId:e,variantIndex:t}}},this.syncOverridesToSignal(),this.notifyChanged()}resetOptimizationOverride(e){rv.info("Resetting optimization override:",e);let{selectedOptimizations:t}={...this.overrides},i=Object.fromEntries(Object.entries(t).filter(([t])=>t!==e));this.overrides={...this.overrides,selectedOptimizations:i},this.syncOverridesToSignal(),this.notifyChanged()}resetAll(){rv.info("Resetting all overrides to baseline"),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineAudienceQualifications={};let{baselineSelectedOptimizations:e}=this;e&&(this.selectedOptimizations.value=e,rv.debug("Restored signal to baseline")),this.notifyChanged()}getOverrides(){return this.overrides}getBaselineSelectedOptimizations(){return this.baselineSelectedOptimizations}getBaselineAudienceQualifications(){return this.baselineAudienceQualifications}destroy(){null!==this.interceptorId&&(this.stateInterceptors.remove(this.interceptorId),rv.info("State interceptor removed"),this.interceptorId=null),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineSelectedOptimizations=null,this.baselineAudienceQualifications={}}snapshotAudienceQualification(e){if(!this.profile||e in this.baselineAudienceQualifications)return;let t=this.profile.value?.audiences.includes(e)??!1;this.baselineAudienceQualifications[e]=t}syncOverridesToSignal(){this.selectedOptimizations.value=rf(this.baselineSelectedOptimizations??[],this.overrides.selectedOptimizations),rv.debug("Synced overrides to signal")}setAudienceOverride(e,t,i,n){this.snapshotAudienceQualification(e);let r={...this.overrides.selectedOptimizations};for(let e of n)r[e]={experienceId:e,variantIndex:i};this.overrides={audiences:{...this.overrides.audiences,[e]:{audienceId:e,isActive:t,source:"manual",experienceIds:n}},selectedOptimizations:r},n.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}notifyChanged(){this.onOverridesChanged?.(this.overrides)}}let rm=null,rb=null,rw=null,r_=null,rz=null,rO=null,rS={},rE={},rx={initialize(e){rm&&rx.destroy(),rz=null,rO=null,rS={},rE={},rm=new ru({clientId:e.clientId,environment:e.environment,api:{experienceBaseUrl:e.experienceBaseUrl,insightsBaseUrl:e.insightsBaseUrl}}),e.defaults&&(void 0!==e.defaults.consent&&rm.consent(e.defaults.consent),void 0!==e.defaults.profile&&(eb.profile.value=e.defaults.profile),void 0!==e.defaults.changes&&(eb.changes.value=e.defaults.changes),void 0!==e.defaults.optimizations&&(eb.selectedOptimizations.value=e.defaults.optimizations)),rm.consent(!0);let t=globalThis;r_=new rg({selectedOptimizations:eb.selectedOptimizations,profile:eb.profile,stateInterceptors:rm.interceptors.state,onOverridesChanged:()=>{"function"==typeof t.__nativeOnOverridesChanged&&t.__nativeOnOverridesChanged(rx.getPreviewState())}}),rb=A(()=>{let e={profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null};"function"==typeof t.__nativeOnStateChange&&t.__nativeOnStateChange(JSON.stringify(e))}),rw=A(()=>{let e=eb.event.value;e&&"function"==typeof t.__nativeOnEventEmitted&&t.__nativeOnEventEmitted(JSON.stringify(e))})},identify(e,t,i){rm?rm.identify(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},page(e,t,i){rm?rm.page(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},screen(e,t,i){rm?rm.screen({name:e.name,properties:e.properties??{}}).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},flush(e,t){rm?rm.flush().then(()=>{e(JSON.stringify(null))}).catch(e=>{t(e instanceof Error?e.message:String(e))}):t("SDK not initialized. Call initialize() first.")},trackView(e,t,i){rm?rm.trackView(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},trackClick(e,t,i){rm?rm.trackClick(e).then(()=>{t(JSON.stringify(null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},consent(e){rm&&rm.consent(e)},reset(){rm&&(r_?.resetAll(),rm.reset())},setOnline(e){eb.online.value=e},personalizeEntry:(e,t)=>rm?JSON.stringify(rm.resolveOptimizedEntry(e,t)):JSON.stringify({entry:e}),setPreviewPanelOpen(e){rm&&(eb.previewPanelOpen.value=e)},overrideAudience(e,t,i){r_&&(t?r_.activateAudience(e,i):r_.deactivateAudience(e,i))},overrideVariant(e,t){r_?.setVariantOverride(e,t)},resetAudienceOverride(e){r_?.resetAudienceOverride(e)},resetVariantOverride(e){r_?.resetOptimizationOverride(e)},resetAllOverrides(){r_?.resetAll()},loadDefinitions(e,t){try{let i,n;for(let r of(rz=e.map(e=>{let t=e.fields;return"object"==typeof t&&null!==t?{id:t.nt_audience_id??e.sys.id,name:t.nt_name??t.nt_audience_id??e.sys.id,description:t.nt_description}:{id:e.sys.id,name:e.sys.id}}),i=new Map,t.forEach(e=>{(e.includes?.Entry??[]).forEach(e=>{i.set(e.sys.id,e)})}),rO=t.map(e=>{let t=e.fields;if("object"!=typeof t||null===t)return{id:e.sys.id,name:e.sys.id,type:"nt_personalization",distribution:[]};let{nt_config:n}=t,r=[];return n?.distribution&&n.distribution.forEach((e,t)=>{let s,o=(s=n.components?.[0],void 0===s||void 0!==s.type&&"EntryReplacement"!==s.type?"":0===t?s.baseline.id:s.variants[t-1]?.id??""),a=i.get(o);r.push({index:t,variantRef:o,percentage:Math.round(100*e),name:a?function(e){let t=e.fields;if("object"==typeof t&&null!==t)return t.internalTitle??t.title??t.name}(a):void 0})}),{id:t.nt_experience_id??e.sys.id,name:t.nt_name??e.sys.id,type:t.nt_type??"nt_personalization",distribution:r,audience:t.nt_audience?{id:t.nt_audience.sys.id}:void 0}}),n={},t.forEach(e=>{let t=e.fields;if("object"==typeof t&&null!==t){let i=t.nt_personalization_id??t.nt_experience_id??e.sys.id,{nt_name:r}=t;r&&(n[i]=r)}}),rE=n,rS={},rz))rS[r.id]=r.name;return JSON.stringify({audienceCount:rz.length,experienceCount:rO.length})}catch(e){return rz=null,rO=null,rS={},rE={},JSON.stringify({error:e instanceof Error?e.message:String(e)})}},getPreviewState(){let e=r_?.getOverrides()??{audiences:{},selectedOptimizations:{}},t=r_?.getBaselineSelectedOptimizations(),i={};for(let[t,n]of Object.entries(e.audiences))i[t]=n.isActive;let n={};for(let[t,i]of Object.entries(e.selectedOptimizations))n[t]=i.variantIndex;let r={};if(t)for(let e of t)void 0!==n[e.experienceId]&&(r[e.experienceId]=e.variantIndex);let s=rz&&rO?{...function(e){let{audienceDefinitions:t,experienceDefinitions:i,signals:n,overrides:r}=e,{profile:s,selectedOptimizations:o}=n,a=new Set(s?.audiences??[]),l={};if(o)for(let{experienceId:e,variantIndex:t}of o)l[e]=t;let u=null!=e.baselineSelectedOptimizations?Object.fromEntries(e.baselineSelectedOptimizations.map(e=>[e.experienceId,e.variantIndex])):void 0,c=new Set(t.map(e=>e.id)),d=i.filter(e=>!e.audience?.id||!c.has(e.audience.id)).map(e=>rh(e,l,r,u)),p=t.map(e=>{let t=i.filter(t=>t.audience?.id===e.id).map(e=>rh(e,l,r,u)),n=a.has(e.id),s=rd(r,e.id),o=rp(s,n);return{audience:e,experiences:t,isQualified:n,isActive:o,overrideState:s}});if(d.length>0){let e=rd(r,rc);p.push({audience:{id:rc,name:"All Visitors",description:"Experiences that apply to all visitors regardless of audience membership"},experiences:d,isQualified:!0,isActive:rp(e,!0),overrideState:e})}let h=t.length>0||i.length>0;return{audiencesWithExperiences:[...p].sort((e,t)=>e.audience.id===rc?-1:t.audience.id===rc?1:e.isActive!==t.isActive?e.isActive?-1:1:e.audience.name.localeCompare(t.audience.name,void 0,{sensitivity:"base"})),unassociatedExperiences:d,hasData:h,sdkVariantIndices:l}}({audienceDefinitions:rz,experienceDefinitions:rO,signals:{profile:eb.profile.value,selectedOptimizations:eb.selectedOptimizations.value,consent:eb.consent.value,isLoading:!1},overrides:e,baselineSelectedOptimizations:t}),audienceNameMap:rS,experienceNameMap:rE}:null;return JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null,previewPanelOpen:eb.previewPanelOpen.value,audienceOverrides:i,variantOverrides:n,defaultAudienceQualifications:r_?.getBaselineAudienceQualifications()??{},defaultVariantIndices:r,previewModel:s})},getProfile(){let e=eb.profile.value;return e?JSON.stringify(e):null},getState:()=>JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null}),destroy(){r_?.destroy(),r_=null,rz=null,rO=null,rS={},rE={},rw&&(rw(),rw=null),rb&&(rb(),rb=null),rm&&(rm.destroy(),rm=null)}};globalThis.__bridge=rx;let rk=rx;return u.default})()); +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.OptimizationBridge=t():e.OptimizationBridge=t()}(globalThis,()=>(()=>{"use strict";let e,t,i,n,r,s,o;var a,l={};l.d=(e,t)=>{for(var i in t)l.o(t,i)&&!l.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},l.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var u={};l.d(u,{default:()=>rI});var c=Symbol.for("preact-signals");function d(){if(g>1)g--;else{for(var e,t=!1;void 0!==y;){var i=y;for(y=void 0,m++;void 0!==i;){var n=i.o;if(i.o=void 0,i.f&=-3,!(8&i.f)&&O(i))try{i.c()}catch(i){t||(e=i,t=!0)}i=n}}if(m=0,g--,t)throw e}}function p(e){if(g>0)return e();g++;try{return e()}finally{d()}}var f=void 0;function h(e){var t=f;f=void 0;try{return e()}finally{f=t}}var v,y=void 0,g=0,m=0,b=0;function w(e){if(void 0!==f){var t=e.n;if(void 0===t||t.t!==f)return t={i:0,S:e,p:f.s,n:void 0,t:f,e:void 0,x:void 0,r:t},void 0!==f.s&&(f.s.n=t),f.s=t,e.n=t,32&f.f&&e.S(t),t;if(-1===t.i)return t.i=0,void 0!==t.n&&(t.n.p=t.p,void 0!==t.p&&(t.p.n=t.n),t.p=f.s,t.n=void 0,f.s.n=t,f.s=t),t}}function _(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function z(e,t){return new _(e,t)}function O(e){for(var t=e.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function S(e){for(var t=e.s;void 0!==t;t=t.n){var i=t.S.n;if(void 0!==i&&(t.r=i),t.S.n=t,t.i=-1,void 0===t.n){e.s=t;break}}}function E(e){for(var t=e.s,i=void 0;void 0!==t;){var n=t.p;-1===t.i?(t.S.U(t),void 0!==n&&(n.n=t.n),void 0!==t.n&&(t.n.p=n)):i=t,t.S.n=t.r,void 0!==t.r&&(t.r=void 0),t=n}e.s=i}function x(e,t){_.call(this,void 0),this.x=e,this.s=void 0,this.g=b-1,this.f=4,this.W=null==t?void 0:t.watched,this.Z=null==t?void 0:t.unwatched,this.name=null==t?void 0:t.name}function k(e,t){return new x(e,t)}function I(e){var t=e.u;if(e.u=void 0,"function"==typeof t){g++;var i=f;f=void 0;try{t()}catch(t){throw e.f&=-2,e.f|=8,$(e),t}finally{f=i,d()}}}function $(e){for(var t=e.s;void 0!==t;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,I(e)}function P(e){if(f!==this)throw Error("Out-of-order effect");E(this),f=e,this.f&=-2,8&this.f&&$(this),d()}function j(e,t){this.x=e,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=null==t?void 0:t.name,v&&v.push(this)}function A(e,t){var i=new j(e,t);try{i.c()}catch(e){throw i.d(),e}var n=i.d.bind(i);return n[Symbol.dispose]=n,n}function T(e){return Object.getOwnPropertySymbols(e).filter(t=>Object.prototype.propertyIsEnumerable.call(e,t))}function F(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":Object.prototype.toString.call(e)}_.prototype.brand=c,_.prototype.h=function(){return!0},_.prototype.S=function(e){var t=this,i=this.t;i!==e&&void 0===e.e&&(e.x=i,this.t=e,void 0!==i?i.e=e:h(function(){var e;null==(e=t.W)||e.call(t)}))},_.prototype.U=function(e){var t=this;if(void 0!==this.t){var i=e.e,n=e.x;void 0!==i&&(i.x=n,e.e=void 0),void 0!==n&&(n.e=i,e.x=void 0),e===this.t&&(this.t=n,void 0===n&&h(function(){var e;null==(e=t.Z)||e.call(t)}))}},_.prototype.subscribe=function(e){var t=this;return A(function(){var i=t.value,n=f;f=void 0;try{e(i)}finally{f=n}},{name:"sub"})},_.prototype.valueOf=function(){return this.value},_.prototype.toString=function(){return this.value+""},_.prototype.toJSON=function(){return this.value},_.prototype.peek=function(){var e=f;f=void 0;try{return this.value}finally{f=e}},Object.defineProperty(_.prototype,"value",{get:function(){var e=w(this);return void 0!==e&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(m>100)throw Error("Cycle detected");this.v=e,this.i++,b++,g++;try{for(var t=this.t;void 0!==t;t=t.x)t.t.N()}finally{d()}}}}),x.prototype=new _,x.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if(32==(36&this.f)||(this.f&=-5,this.g===b))return!0;if(this.g=b,this.f|=1,this.i>0&&!O(this))return this.f&=-2,!0;var e=f;try{S(this),f=this;var t=this.x();(16&this.f||this.v!==t||0===this.i)&&(this.v=t,this.f&=-17,this.i++)}catch(e){this.v=e,this.f|=16,this.i++}return f=e,E(this),this.f&=-2,!0},x.prototype.S=function(e){if(void 0===this.t){this.f|=36;for(var t=this.s;void 0!==t;t=t.n)t.S.S(t)}_.prototype.S.call(this,e)},x.prototype.U=function(e){if(void 0!==this.t&&(_.prototype.U.call(this,e),void 0===this.t)){this.f&=-33;for(var t=this.s;void 0!==t;t=t.n)t.S.U(t)}},x.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;void 0!==e;e=e.x)e.t.N()}},Object.defineProperty(x.prototype,"value",{get:function(){if(1&this.f)throw Error("Cycle detected");var e=w(this);if(this.h(),void 0!==e&&(e.i=this.i),16&this.f)throw this.v;return this.v}}),j.prototype.c=function(){var e=this.S();try{if(8&this.f||void 0===this.x)return;var t=this.x();"function"==typeof t&&(this.u=t)}finally{e()}},j.prototype.S=function(){if(1&this.f)throw Error("Cycle detected");this.f|=1,this.f&=-9,I(this),S(this),g++;var e=f;return f=this,P.bind(this,e)},j.prototype.N=function(){2&this.f||(this.f|=2,this.o=y,y=this)},j.prototype.d=function(){this.f|=8,1&this.f||$(this)},j.prototype.dispose=function(){this.d()};let C="[object RegExp]",R="[object String]",M="[object Number]",B="[object Boolean]",q="[object Arguments]",U="[object Symbol]",V="[object Date]",N="[object Map]",D="[object Set]",Z="[object Array]",Q="[object ArrayBuffer]",L="[object Object]",J="[object DataView]",H="[object Uint8Array]",K="[object Uint8ClampedArray]",W="[object Uint16Array]",G="[object Uint32Array]",X="[object Int8Array]",Y="[object Int16Array]",ee="[object Int32Array]",et="[object Float32Array]",ei="[object Float64Array]",en="object"==typeof globalThis&&globalThis||"object"==typeof window&&window||"object"==typeof self&&self||"object"==typeof global&&global||function(){return this}()||Function("return this")();function er(e){return void 0!==en.Buffer&&en.Buffer.isBuffer(e)}function es(e,t,i,n=new Map,r){let s=r?.(e,t,i,n);if(void 0!==s)return s;if(null==e||"object"!=typeof e&&"function"!=typeof e)return e;if(n.has(e))return n.get(e);if(Array.isArray(e)){let t=Array(e.length);n.set(e,t);for(let s=0;stypeof SharedArrayBuffer&&e instanceof SharedArrayBuffer)return e.slice(0);if(e instanceof DataView){let t=new DataView(e.buffer.slice(0),e.byteOffset,e.byteLength);return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof File&&e instanceof File){let t=new File([e],e.name,{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if("u">typeof Blob&&e instanceof Blob){let t=new Blob([e],{type:e.type});return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Error){let t=structuredClone(e);return n.set(e,t),t.message=e.message,t.name=e.name,t.stack=e.stack,t.cause=e.cause,t.constructor=e.constructor,eo(t,e,i,n,r),t}if(e instanceof Boolean){let t=new Boolean(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof Number){let t=new Number(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if(e instanceof String){let t=new String(e.valueOf());return n.set(e,t),eo(t,e,i,n,r),t}if("object"==typeof e&&function(e){switch(F(e)){case q:case Z:case Q:case J:case B:case V:case et:case ei:case X:case Y:case ee:case N:case M:case L:case C:case D:case R:case U:case H:case K:case W:case G:return!0;default:return!1}}(e)){let t=Object.create(Object.getPrototypeOf(e));return n.set(e,t),eo(t,e,i,n,r),t}return e}function eo(e,t,i=e,n,r){let s=[...Object.keys(t),...T(t)];for(let o=0;o({unsubscribe:A(()=>{t(ea(e.value))})}),subscribeOnce(t){let i=!1,n=!1,r=()=>void 0;return r=A(()=>{if(i)return;let{value:s}=e;if(null==s)return;i=!0;let o=null;try{t(ea(s))}catch(e){o=e instanceof Error?e:Error(`Subscriber threw non-Error value: ${String(e)}`)}if(n?r():queueMicrotask(r),o)throw o}),n=!0,{unsubscribe:()=>{!i&&(i=!0,n&&r())}}}}}let eu=z(),ec=z(),ed=z(),ep=z(),ef=z(!0),eh=z(!1),ev=z(!1),ey=z(),eg=k(()=>void 0!==ey.value),em=z(),eb={blockedEvent:ec,changes:eu,consent:ed,event:ep,online:ef,previewPanelAttached:eh,previewPanelOpen:ev,selectedOptimizations:ey,canOptimize:eg,profile:em},ew={batch:p,computed:k,effect:A,untracked:h};function e_(e,t,i){function n(i,n){if(i._zod||Object.defineProperty(i,"_zod",{value:{def:n,constr:o,traits:new Set},enumerable:!1}),i._zod.traits.has(e))return;i._zod.traits.add(e),t(i,n);let r=o.prototype,s=Object.keys(r);for(let e=0;e!!i?.Parent&&t instanceof i.Parent||t?._zod?.traits?.has(e)}),Object.defineProperty(o,"name",{value:e}),o}Object.freeze({status:"aborted"}),Symbol("zod_brand");class ez extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}let eO={};function eS(e){return e&&Object.assign(eO,e),eO}function eE(e,t="|"){return e.map(e=>eU(e)).join(t)}function ex(e,t){return"bigint"==typeof t?t.toString():t}function ek(e){return{get value(){{let t=e();return Object.defineProperty(this,"value",{value:t}),t}}}}function eI(e){let t=+!!e.startsWith("^"),i=e.endsWith("$")?e.length-1:e.length;return e.slice(t,i)}let e$=Symbol("evaluating");function eP(e,t,i){let n;Object.defineProperty(e,t,{get(){if(n!==e$)return void 0===n&&(n=e$,n=i()),n},set(i){Object.defineProperty(e,t,{value:i})},configurable:!0})}function ej(e,t,i){Object.defineProperty(e,t,{value:i,writable:!0,enumerable:!0,configurable:!0})}function eA(...e){let t={};for(let i of e)Object.assign(t,Object.getOwnPropertyDescriptors(i));return Object.defineProperties({},t)}let eT="captureStackTrace"in Error?Error.captureStackTrace:(...e)=>{};function eF(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function eC(e){if(!1===eF(e))return!1;let t=e.constructor;if(void 0===t||"function"!=typeof t)return!0;let i=t.prototype;return!1!==eF(i)&&!1!==Object.prototype.hasOwnProperty.call(i,"isPrototypeOf")}ek(()=>{if("u">typeof navigator&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{return Function(""),!0}catch(e){return!1}});let eR=new Set(["string","number","symbol"]);function eM(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function eB(e,t,i){let n=new e._zod.constr(t??e._zod.def);return(!t||i?.parent)&&(n._zod.parent=e),n}function eq(e){if(!e)return{};if("string"==typeof e)return{error:()=>e};if(e?.message!==void 0){if(e?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");e.error=e.message}return(delete e.message,"string"==typeof e.error)?{...e,error:()=>e.error}:e}function eU(e){return"bigint"==typeof e?e.toString()+"n":"string"==typeof e?`"${e}"`:`${e}`}function eV(e,t=0){if(!0===e.aborted)return!0;for(let i=t;i(t.path??(t.path=[]),t.path.unshift(e),t))}function eD(e){return"string"==typeof e?e:e?.message}function eZ(e,t,i){let n={...e,path:e.path??[]};return e.message||(n.message=eD(e.inst?._zod.def?.error?.(e))??eD(t?.error?.(e))??eD(i.customError?.(e))??eD(i.localeError?.(e))??"Invalid input"),delete n.inst,delete n.continue,t?.reportInput||delete n.input,n}function eQ(e){return Array.isArray(e)?"array":"string"==typeof e?"string":"unknown"}let eL=(e,t)=>{e.name="$ZodError",Object.defineProperty(e,"_zod",{value:e._zod,enumerable:!1}),Object.defineProperty(e,"issues",{value:t,enumerable:!1}),e.message=JSON.stringify(t,ex,2),Object.defineProperty(e,"toString",{value:()=>e.message,enumerable:!1})},eJ=e_("$ZodError",eL),eH=e_("$ZodError",eL,{Parent:Error}),eK=(e=eH,(t,i,n,r)=>{let s=n?Object.assign(n,{async:!1}):{async:!1},o=t._zod.run({value:i,issues:[]},s);if(o instanceof Promise)throw new ez;if(o.issues.length){let t=new(r?.Err??e)(o.issues.map(e=>eZ(e,s,eS())));throw eT(t,r?.callee),t}return o.value}),eW=(t=eH,async(e,i,n,r)=>{let s=n?Object.assign(n,{async:!0}):{async:!0},o=e._zod.run({value:i,issues:[]},s);if(o instanceof Promise&&(o=await o),o.issues.length){let e=new(r?.Err??t)(o.issues.map(e=>eZ(e,s,eS())));throw eT(e,r?.callee),e}return o.value}),eG=(i=eH,(e,t,n)=>{let r=n?{...n,async:!1}:{async:!1},s=e._zod.run({value:t,issues:[]},r);if(s instanceof Promise)throw new ez;return s.issues.length?{success:!1,error:new(i??eJ)(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eX=(n=eH,async(e,t,i)=>{let r=i?Object.assign(i,{async:!0}):{async:!0},s=e._zod.run({value:t,issues:[]},r);return s instanceof Promise&&(s=await s),s.issues.length?{success:!1,error:new n(s.issues.map(e=>eZ(e,r,eS())))}:{success:!0,data:s.value}}),eY=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,e0="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",e1=RegExp(`^${e0}$`);function e2(e){let t="(?:[01]\\d|2[0-3]):[0-5]\\d";return"number"==typeof e.precision?-1===e.precision?`${t}`:0===e.precision?`${t}:[0-5]\\d`:`${t}:[0-5]\\d\\.\\d{${e.precision}}`:`${t}(?::[0-5]\\d(?:\\.\\d+)?)?`}let e6=/^-?\d+(?:\.\d+)?$/,e3=/^(?:true|false)$/i,e4=/^null$/i,e8=e_("$ZodCheck",(e,t)=>{var i;e._zod??(e._zod={}),e._zod.def=t,(i=e._zod).onattach??(i.onattach=[])}),e5=e_("$ZodCheckMinLength",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag.minimum??-1/0;t.minimum>i&&(e._zod.bag.minimum=t.minimum)}),e._zod.check=i=>{let n=i.value;if(n.length>=t.minimum)return;let r=eQ(n);i.issues.push({origin:r,code:"too_small",minimum:t.minimum,inclusive:!0,input:n,inst:e,continue:!t.abort})}}),e9=e_("$ZodCheckLengthEquals",(e,t)=>{var i;e8.init(e,t),(i=e._zod.def).when??(i.when=e=>{let t=e.value;return null!=t&&void 0!==t.length}),e._zod.onattach.push(e=>{let i=e._zod.bag;i.minimum=t.length,i.maximum=t.length,i.length=t.length}),e._zod.check=i=>{let n=i.value,r=n.length;if(r===t.length)return;let s=eQ(n),o=r>t.length;i.issues.push({origin:s,...o?{code:"too_big",maximum:t.length}:{code:"too_small",minimum:t.length},inclusive:!0,exact:!0,input:i.value,inst:e,continue:!t.abort})}}),e7=e_("$ZodCheckStringFormat",(e,t)=>{var i,n;e8.init(e,t),e._zod.onattach.push(e=>{let i=e._zod.bag;i.format=t.format,t.pattern&&(i.patterns??(i.patterns=new Set),i.patterns.add(t.pattern))}),t.pattern?(i=e._zod).check??(i.check=i=>{t.pattern.lastIndex=0,t.pattern.test(i.value)||i.issues.push({origin:"string",code:"invalid_format",format:t.format,input:i.value,...t.pattern?{pattern:t.pattern.toString()}:{},inst:e,continue:!t.abort})}):(n=e._zod).check??(n.check=()=>{})}),te={major:4,minor:3,patch:6},tt=e_("$ZodType",(e,t)=>{var i;e??(e={}),e._zod.def=t,e._zod.bag=e._zod.bag||{},e._zod.version=te;let n=[...e._zod.def.checks??[]];for(let t of(e._zod.traits.has("$ZodCheck")&&n.unshift(e),n))for(let i of t._zod.onattach)i(e);if(0===n.length)(i=e._zod).deferred??(i.deferred=[]),e._zod.deferred?.push(()=>{e._zod.run=e._zod.parse});else{let t=(e,t,i)=>{let n,r=eV(e);for(let s of t){if(s._zod.def.when){if(!s._zod.def.when(e))continue}else if(r)continue;let t=e.issues.length,o=s._zod.check(e);if(o instanceof Promise&&i?.async===!1)throw new ez;if(n||o instanceof Promise)n=(n??Promise.resolve()).then(async()=>{await o,e.issues.length!==t&&(r||(r=eV(e,t)))});else{if(e.issues.length===t)continue;r||(r=eV(e,t))}}return n?n.then(()=>e):e},i=(i,r,s)=>{if(eV(i))return i.aborted=!0,i;let o=t(r,n,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(t=>e._zod.parse(t,s))}return e._zod.parse(o,s)};e._zod.run=(r,s)=>{if(s.skipChecks)return e._zod.parse(r,s);if("backward"===s.direction){let t=e._zod.parse({value:r.value,issues:[]},{...s,skipChecks:!0});return t instanceof Promise?t.then(e=>i(e,r,s)):i(t,r,s)}let o=e._zod.parse(r,s);if(o instanceof Promise){if(!1===s.async)throw new ez;return o.then(e=>t(e,n,s))}return t(o,n,s)}}eP(e,"~standard",()=>({validate:t=>{try{let i=eG(e,t);return i.success?{value:i.data}:{issues:i.error?.issues}}catch(i){return eX(e,t).then(e=>e.success?{value:e.data}:{issues:e.error?.issues})}},vendor:"zod",version:1}))}),ti=e_("$ZodString",(e,t)=>{var i;let n;tt.init(e,t),e._zod.pattern=[...e?._zod.bag?.patterns??[]].pop()??(n=(i=e._zod.bag)?`[\\s\\S]{${i?.minimum??0},${i?.maximum??""}}`:"[\\s\\S]*",RegExp(`^${n}$`)),e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=String(i.value)}catch(e){}return"string"==typeof i.value||i.issues.push({expected:"string",code:"invalid_type",input:i.value,inst:e}),i}}),tn=e_("$ZodStringFormat",(e,t)=>{e7.init(e,t),ti.init(e,t)}),tr=e_("$ZodISODateTime",(e,t)=>{let i,n,r;t.pattern??(i=e2({precision:t.precision}),n=["Z"],t.local&&n.push(""),t.offset&&n.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)"),r=`${i}(?:${n.join("|")})`,t.pattern=RegExp(`^${e0}T(?:${r})$`)),tn.init(e,t)}),ts=((e,t)=>{t.pattern??(t.pattern=e1),tn.init(e,t)},e_("$ZodNumber",(e,t)=>{tt.init(e,t),e._zod.pattern=e._zod.bag.pattern??e6,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=Number(i.value)}catch(e){}let r=i.value;if("number"==typeof r&&!Number.isNaN(r)&&Number.isFinite(r))return i;let s="number"==typeof r?Number.isNaN(r)?"NaN":Number.isFinite(r)?void 0:"Infinity":void 0;return i.issues.push({expected:"number",code:"invalid_type",input:r,inst:e,...s?{received:s}:{}}),i}})),to=e_("$ZodBoolean",(e,t)=>{tt.init(e,t),e._zod.pattern=e3,e._zod.parse=(i,n)=>{if(t.coerce)try{i.value=!!i.value}catch(e){}let r=i.value;return"boolean"==typeof r||i.issues.push({expected:"boolean",code:"invalid_type",input:r,inst:e}),i}}),ta=e_("$ZodNull",(e,t)=>{tt.init(e,t),e._zod.pattern=e4,e._zod.values=new Set([null]),e._zod.parse=(t,i)=>{let n=t.value;return null===n||t.issues.push({expected:"null",code:"invalid_type",input:n,inst:e}),t}}),tl=e_("$ZodAny",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e}),tu=e_("$ZodUnknown",(e,t)=>{tt.init(e,t),e._zod.parse=e=>e});function tc(e,t,i){e.issues.length&&t.issues.push(...eN(i,e.issues)),t.value[i]=e.value}let td=e_("$ZodArray",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!Array.isArray(r))return i.issues.push({expected:"array",code:"invalid_type",input:r,inst:e}),i;i.value=Array(r.length);let s=[];for(let e=0;etc(t,i,e))):tc(a,i,e)}return s.length?Promise.all(s).then(()=>i):i}});function tp(e,t,i,n,r){if(e.issues.length){if(r&&!(i in n))return;t.issues.push(...eN(i,e.issues))}void 0===e.value?i in n&&(t.value[i]=void 0):t.value[i]=e.value}let tf=e_("$ZodObject",(e,t)=>{let i;tt.init(e,t);let n=Object.getOwnPropertyDescriptor(t,"shape");if(!n?.get){let e=t.shape;Object.defineProperty(t,"shape",{get:()=>{let i={...e};return Object.defineProperty(t,"shape",{value:i}),i}})}let r=ek(()=>(function(e){var t;let i=Object.keys(e.shape);for(let t of i)if(!e.shape?.[t]?._zod?.traits?.has("$ZodType"))throw Error(`Invalid element at key "${t}": expected a Zod schema`);let n=Object.keys(t=e.shape).filter(e=>"optional"===t[e]._zod.optin&&"optional"===t[e]._zod.optout);return{...e,keys:i,keySet:new Set(i),numKeys:i.length,optionalKeys:new Set(n)}})(t));eP(e._zod,"propValues",()=>{let e=t.shape,i={};for(let t in e){let n=e[t]._zod;if(n.values)for(let e of(i[t]??(i[t]=new Set),n.values))i[t].add(e)}return i});let s=t.catchall;e._zod.parse=(t,n)=>{i??(i=r.value);let o=t.value;if(!eF(o))return t.issues.push({expected:"object",code:"invalid_type",input:o,inst:e}),t;t.value={};let a=[],l=i.shape;for(let e of i.keys){let i=l[e],r="optional"===i._zod.optout,s=i._zod.run({value:o[e],issues:[]},n);s instanceof Promise?a.push(s.then(i=>tp(i,t,e,o,r))):tp(s,t,e,o,r)}return s?function(e,t,i,n,r,s){let o=[],a=r.keySet,l=r.catchall._zod,u=l.def.type,c="optional"===l.optout;for(let r in t){if(a.has(r))continue;if("never"===u){o.push(r);continue}let s=l.run({value:t[r],issues:[]},n);s instanceof Promise?e.push(s.then(e=>tp(e,i,r,t,c))):tp(s,i,r,t,c)}return(o.length&&i.issues.push({code:"unrecognized_keys",keys:o,input:t,inst:s}),e.length)?Promise.all(e).then(()=>i):i}(a,o,t,n,r.value,e):a.length?Promise.all(a).then(()=>t):t}});function th(e,t,i,n){for(let i of e)if(0===i.issues.length)return t.value=i.value,t;let r=e.filter(e=>!eV(e));return 1===r.length?(t.value=r[0].value,r[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:i,errors:e.map(e=>e.issues.map(e=>eZ(e,n,eS())))}),t)}let tv=e_("$ZodUnion",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.options.some(e=>"optional"===e._zod.optin)?"optional":void 0),eP(e._zod,"optout",()=>t.options.some(e=>"optional"===e._zod.optout)?"optional":void 0),eP(e._zod,"values",()=>{if(t.options.every(e=>e._zod.values))return new Set(t.options.flatMap(e=>Array.from(e._zod.values)))}),eP(e._zod,"pattern",()=>{if(t.options.every(e=>e._zod.pattern)){let e=t.options.map(e=>e._zod.pattern);return RegExp(`^(${e.map(e=>eI(e.source)).join("|")})$`)}});let i=1===t.options.length,n=t.options[0]._zod.run;e._zod.parse=(r,s)=>{if(i)return n(r,s);let o=!1,a=[];for(let e of t.options){let t=e._zod.run({value:r.value,issues:[]},s);if(t instanceof Promise)a.push(t),o=!0;else{if(0===t.issues.length)return t;a.push(t)}}return o?Promise.all(a).then(t=>th(t,r,e,s)):th(a,r,e,s)}}),ty=e_("$ZodDiscriminatedUnion",(e,t)=>{t.inclusive=!1,tv.init(e,t);let i=e._zod.parse;eP(e._zod,"propValues",()=>{let e={};for(let i of t.options){let n=i._zod.propValues;if(!n||0===Object.keys(n).length)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(i)}"`);for(let[t,i]of Object.entries(n))for(let n of(e[t]||(e[t]=new Set),i))e[t].add(n)}return e});let n=ek(()=>{let e=t.options,i=new Map;for(let n of e){let e=n._zod.propValues?.[t.discriminator];if(!e||0===e.size)throw Error(`Invalid discriminated union option at index "${t.options.indexOf(n)}"`);for(let t of e){if(i.has(t))throw Error(`Duplicate discriminator value "${String(t)}"`);i.set(t,n)}}return i});e._zod.parse=(r,s)=>{let o=r.value;if(!eF(o))return r.issues.push({code:"invalid_type",expected:"object",input:o,inst:e}),r;let a=n.value.get(o?.[t.discriminator]);return a?a._zod.run(r,s):t.unionFallback?i(r,s):(r.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:t.discriminator,input:o,path:[t.discriminator],inst:e}),r)}}),tg=e_("$ZodRecord",(e,t)=>{tt.init(e,t),e._zod.parse=(i,n)=>{let r=i.value;if(!eC(r))return i.issues.push({expected:"record",code:"invalid_type",input:r,inst:e}),i;let s=[],o=t.keyType._zod.values;if(o){let a;i.value={};let l=new Set;for(let e of o)if("string"==typeof e||"number"==typeof e||"symbol"==typeof e){l.add("number"==typeof e?e.toString():e);let o=t.valueType._zod.run({value:r[e],issues:[]},n);o instanceof Promise?s.push(o.then(t=>{t.issues.length&&i.issues.push(...eN(e,t.issues)),i.value[e]=t.value})):(o.issues.length&&i.issues.push(...eN(e,o.issues)),i.value[e]=o.value)}for(let e in r)l.has(e)||(a=a??[]).push(e);a&&a.length>0&&i.issues.push({code:"unrecognized_keys",input:r,inst:e,keys:a})}else for(let o of(i.value={},Reflect.ownKeys(r))){if("__proto__"===o)continue;let a=t.keyType._zod.run({value:o,issues:[]},n);if(a instanceof Promise)throw Error("Async schemas not supported in object keys currently");if("string"==typeof o&&e6.test(o)&&a.issues.length){let e=t.keyType._zod.run({value:Number(o),issues:[]},n);if(e instanceof Promise)throw Error("Async schemas not supported in object keys currently");0===e.issues.length&&(a=e)}if(a.issues.length){"loose"===t.mode?i.value[o]=r[o]:i.issues.push({code:"invalid_key",origin:"record",issues:a.issues.map(e=>eZ(e,n,eS())),input:o,path:[o],inst:e});continue}let l=t.valueType._zod.run({value:r[o],issues:[]},n);l instanceof Promise?s.push(l.then(e=>{e.issues.length&&i.issues.push(...eN(o,e.issues)),i.value[a.value]=e.value})):(l.issues.length&&i.issues.push(...eN(o,l.issues)),i.value[a.value]=l.value)}return s.length?Promise.all(s).then(()=>i):i}}),tm=e_("$ZodEnum",(e,t)=>{var i;let n;tt.init(e,t);let r=(n=Object.values(i=t.entries).filter(e=>"number"==typeof e),Object.entries(i).filter(([e,t])=>-1===n.indexOf(+e)).map(([e,t])=>t)),s=new Set(r);e._zod.values=s,e._zod.pattern=RegExp(`^(${r.filter(e=>eR.has(typeof e)).map(e=>"string"==typeof e?eM(e):e.toString()).join("|")})$`),e._zod.parse=(t,i)=>{let n=t.value;return s.has(n)||t.issues.push({code:"invalid_value",values:r,input:n,inst:e}),t}}),tb=e_("$ZodLiteral",(e,t)=>{if(tt.init(e,t),0===t.values.length)throw Error("Cannot create literal schema with no valid values");let i=new Set(t.values);e._zod.values=i,e._zod.pattern=RegExp(`^(${t.values.map(e=>"string"==typeof e?eM(e):e?eM(e.toString()):String(e)).join("|")})$`),e._zod.parse=(n,r)=>{let s=n.value;return i.has(s)||n.issues.push({code:"invalid_value",values:t.values,input:s,inst:e}),n}});function tw(e,t){return e.issues.length&&void 0===t?{issues:[],value:void 0}:e}let t_=e_("$ZodOptional",(e,t)=>{tt.init(e,t),e._zod.optin="optional",e._zod.optout="optional",eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)})?$`):void 0}),e._zod.parse=(e,i)=>{if("optional"===t.innerType._zod.optin){let n=t.innerType._zod.run(e,i);return n instanceof Promise?n.then(t=>tw(t,e.value)):tw(n,e.value)}return void 0===e.value?e:t.innerType._zod.run(e,i)}}),tz=e_("$ZodNullable",(e,t)=>{tt.init(e,t),eP(e._zod,"optin",()=>t.innerType._zod.optin),eP(e._zod,"optout",()=>t.innerType._zod.optout),eP(e._zod,"pattern",()=>{let e=t.innerType._zod.pattern;return e?RegExp(`^(${eI(e.source)}|null)$`):void 0}),eP(e._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),e._zod.parse=(e,i)=>null===e.value?e:t.innerType._zod.run(e,i)}),tO=e_("$ZodPrefault",(e,t)=>{tt.init(e,t),e._zod.optin="optional",eP(e._zod,"values",()=>t.innerType._zod.values),e._zod.parse=(e,i)=>("backward"===i.direction||void 0===e.value&&(e.value=t.defaultValue),t.innerType._zod.run(e,i))}),tS=e_("$ZodLazy",(e,t)=>{tt.init(e,t),eP(e._zod,"innerType",()=>t.getter()),eP(e._zod,"pattern",()=>e._zod.innerType?._zod?.pattern),eP(e._zod,"propValues",()=>e._zod.innerType?._zod?.propValues),eP(e._zod,"optin",()=>e._zod.innerType?._zod?.optin??void 0),eP(e._zod,"optout",()=>e._zod.innerType?._zod?.optout??void 0),e._zod.parse=(t,i)=>e._zod.innerType._zod.run(t,i)});Symbol("ZodOutput"),Symbol("ZodInput");function tE(e,t){return new e5({check:"min_length",...eq(t),minimum:e})}(a=globalThis).__zod_globalRegistry??(a.__zod_globalRegistry=new class e{constructor(){this._map=new WeakMap,this._idmap=new Map}add(e,...t){let i=t[0];return this._map.set(e,i),i&&"object"==typeof i&&"id"in i&&this._idmap.set(i.id,e),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(e){let t=this._map.get(e);return t&&"object"==typeof t&&"id"in t&&this._idmap.delete(t.id),this._map.delete(e),this}get(e){let t=e._zod.parent;if(t){let i={...this.get(t)??{}};delete i.id;let n={...i,...this._map.get(e)};return Object.keys(n).length?n:void 0}return this._map.get(e)}has(e){return this._map.has(e)}});let tx=e_("ZodMiniType",(e,t)=>{if(!e._zod)throw Error("Uninitialized schema in ZodMiniType.");tt.init(e,t),e.def=t,e.type=t.type,e.parse=(t,i)=>eK(e,t,i,{callee:e.parse}),e.safeParse=(t,i)=>eG(e,t,i),e.parseAsync=async(t,i)=>eW(e,t,i,{callee:e.parseAsync}),e.safeParseAsync=async(t,i)=>eX(e,t,i),e.check=(...i)=>e.clone({...t,checks:[...t.checks??[],...i.map(e=>"function"==typeof e?{_zod:{check:e,def:{check:"custom"},onattach:[]}}:e)]},{parent:!0}),e.with=e.check,e.clone=(t,i)=>eB(e,t,i),e.brand=()=>e,e.register=(t,i)=>(t.add(e,i),e),e.apply=t=>t(e)}),tk=e_("ZodMiniString",(e,t)=>{ti.init(e,t),tx.init(e,t)});function tI(e){return new tk({type:"string",...eq(e)})}let t$=e_("ZodMiniStringFormat",(e,t)=>{tn.init(e,t),tk.init(e,t)}),tP=e_("ZodMiniNumber",(e,t)=>{ts.init(e,t),tx.init(e,t)});function tj(e){return new tP({type:"number",checks:[],...eq(e)})}let tA=e_("ZodMiniBoolean",(e,t)=>{to.init(e,t),tx.init(e,t)});function tT(e){return new tA({type:"boolean",...eq(e)})}let tF=e_("ZodMiniNull",(e,t)=>{ta.init(e,t),tx.init(e,t)});function tC(e){return new tF({type:"null",...eq(e)})}let tR=e_("ZodMiniAny",(e,t)=>{tl.init(e,t),tx.init(e,t)});function tM(){return new tR({type:"any"})}let tB=e_("ZodMiniUnknown",(e,t)=>{tu.init(e,t),tx.init(e,t)}),tq=e_("ZodMiniArray",(e,t)=>{td.init(e,t),tx.init(e,t)});function tU(e,t){return new tq({type:"array",element:e,...eq(t)})}let tV=e_("ZodMiniObject",(e,t)=>{tf.init(e,t),tx.init(e,t),eP(e,"shape",()=>t.shape)});function tN(e,t){return new tV({type:"object",shape:e??{},...eq(t)})}function tD(e,t){if(!eC(t))throw Error("Invalid input to extend: expected a plain object");let i=e._zod.def.checks;if(i&&i.length>0){let i=e._zod.def.shape;for(let e in t)if(void 0!==Object.getOwnPropertyDescriptor(i,e))throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}let n=eA(e._zod.def,{get shape(){let i={...e._zod.def.shape,...t};return ej(this,"shape",i),i}});return eB(e,n)}function tZ(e,t){return e.clone({...e._zod.def,catchall:t})}let tQ=e_("ZodMiniUnion",(e,t)=>{tv.init(e,t),tx.init(e,t)});function tL(e,t){return new tQ({type:"union",options:e,...eq(t)})}let tJ=e_("ZodMiniDiscriminatedUnion",(e,t)=>{ty.init(e,t),tx.init(e,t)});function tH(e,t,i){return new tJ({type:"union",options:t,discriminator:e,...eq(i)})}let tK=e_("ZodMiniRecord",(e,t)=>{tg.init(e,t),tx.init(e,t)});function tW(e,t,i){return new tK({type:"record",keyType:e,valueType:t,...eq(i)})}let tG=e_("ZodMiniEnum",(e,t)=>{tm.init(e,t),tx.init(e,t),e.options=Object.values(t.entries)});function tX(e,t){return new tG({type:"enum",entries:Array.isArray(e)?Object.fromEntries(e.map(e=>[e,e])):e,...eq(t)})}let tY=e_("ZodMiniLiteral",(e,t)=>{tb.init(e,t),tx.init(e,t)});function t0(e,t){return new tY({type:"literal",values:Array.isArray(e)?e:[e],...eq(t)})}let t1=e_("ZodMiniOptional",(e,t)=>{t_.init(e,t),tx.init(e,t)});function t2(e){return new t1({type:"optional",innerType:e})}let t6=e_("ZodMiniNullable",(e,t)=>{tz.init(e,t),tx.init(e,t)});function t3(e){return new t6({type:"nullable",innerType:e})}let t4=e_("ZodMiniPrefault",(e,t)=>{tO.init(e,t),tx.init(e,t)});function t8(e,t){return new t4({type:"prefault",innerType:e,get defaultValue(){return"function"==typeof t?t():eC(t)?{...t}:Array.isArray(t)?[...t]:t}})}let t5=e_("ZodMiniLazy",(e,t)=>{tS.init(e,t),tx.init(e,t)});function t9(){let e=new t5({type:"lazy",getter:()=>tL([tI(),tj(),tT(),tC(),tU(e),tW(tI(),e)])});return e}let t7=e_("ZodMiniISODateTime",(e,t)=>{tr.init(e,t),t$.init(e,t)});function ie(e){return new t7({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...eq(e)})}let it=tZ(tN({}),t9()),ii=tN({sys:tN({type:t0("Link"),linkType:tI(),id:tI()})}),ir=tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:tI()})}),is=tN({sys:tN({type:t0("Link"),linkType:t0("Environment"),id:tI()})}),io=tN({sys:tN({type:t0("Link"),linkType:t0("Space"),id:tI()})}),ia=tN({sys:tN({type:t0("Link"),linkType:t0("TaxonomyConcept"),id:tI()})}),il=tN({sys:tN({type:t0("Link"),linkType:t0("Tag"),id:tI()})}),iu=tN({type:t0("Entry"),contentType:ir,publishedVersion:tj(),id:tI(),createdAt:tM(),updatedAt:tM(),locale:t2(tI()),revision:tj(),space:io,environment:is}),ic=tN({fields:it,metadata:tN({tags:tU(il),concepts:t2(tU(ia))}),sys:iu}),id=tD(it,{nt_audience_id:tI(),nt_name:t2(tI()),nt_description:t2(tI())}),ip=tD(ic,{fields:id});tN({contentTypeId:t0("nt_audience"),fields:id});let ih=tD(ic,{fields:tN({nt_name:tI(),nt_fallback:t2(tI()),nt_mergetag_id:tI()}),sys:tD(iu,{contentType:tN({sys:tN({type:t0("Link"),linkType:t0("ContentType"),id:t0("nt_mergetag")})})})}),iv=tN({id:tI(),hidden:t2(tT())}),iy=tN({type:t2(t0("EntryReplacement")),baseline:iv,variants:tU(iv)}),ig=tN({value:tL([tI(),tT(),tC(),tj(),tW(tI(),t9())])}),im=tX(["Boolean","Number","Object","String"]),ib=tH("type",[iy,tN({type:t0("InlineVariable"),key:tI(),valueType:im,baseline:ig,variants:tU(ig)})]),iw=tU(ib),i_=tN({distribution:t2(tU(tj())),traffic:t2(tj()),components:t2(iw),sticky:t2(tT())}),iz=tL([t0("nt_experiment"),t0("nt_personalization")]),iO=tD(it,{nt_name:tI(),nt_description:t2(t3(tI())),nt_type:iz,nt_config:t2(t3(i_)),nt_audience:t2(t3(ip)),nt_variants:t2(tU(tL([ii,ic]))),nt_experience_id:tI()}),iS=tD(ic,{fields:iO});tN({contentTypeId:t0("nt_experience"),fields:iO});let iE=tD(ic,{fields:tD(it,{nt_experiences:tU(tL([ii,iS]))})});function ix(e){return iS.safeParse(e).success}function ik(e){return iE.safeParse(e).success}let iI=t2(tN({name:tI(),version:tI()})),i$=tN({name:t2(tI()),source:t2(tI()),medium:t2(tI()),term:t2(tI()),content:t2(tI())}),iP=tL([t0("mobile"),t0("server"),t0("web")]),ij=tW(tI(),tI()),iA=tN({latitude:tj(),longitude:tj()}),iT=tN({coordinates:t2(iA),city:t2(tI()),postalCode:t2(tI()),region:t2(tI()),regionCode:t2(tI()),country:t2(tI()),countryCode:t2(tI().check(new e9({check:"length_equals",...eq(void 0),length:2}))),continent:t2(tI()),timezone:t2(tI())}),iF=tN({name:tI(),version:tI()}),iC=tZ(tN({path:tI(),query:ij,referrer:tI(),search:tI(),title:t2(tI()),url:tI()}),t9()),iR=tW(tI(),t9()),iM=tZ(tN({name:tI()}),t9()),iB=tW(tI(),t9()),iq=tN({app:iI,campaign:i$,gdpr:tN({isConsentGiven:tT()}),library:iF,locale:tI(),location:t2(iT),userAgent:t2(tI())}),iU=tN({channel:iP,context:tD(iq,{page:t2(iC),screen:t2(iM)}),messageId:tI(),originalTimestamp:ie(),sentAt:ie(),timestamp:ie(),userId:t2(tI())}),iV=tD(iU,{type:t0("alias")}),iN=tD(iU,{type:t0("group")}),iD=tD(iU,{type:t0("identify"),traits:iB}),iZ=tD(iq,{page:iC}),iQ=tD(iU,{type:t0("page"),name:t2(tI()),properties:iC,context:iZ}),iL=tD(iq,{screen:iM}),iJ=tD(iU,{type:t0("screen"),name:tI(),properties:t2(iR),context:iL}),iH=tD(iU,{type:t0("track"),event:tI(),properties:iR}),iK=tD(iU,{componentType:tL([t0("Entry"),t0("Variable")]),componentId:tI(),experienceId:t2(tI()),variantIndex:tj()}),iW=tD(iK,{type:t0("component"),viewDurationMs:t2(tj()),viewId:t2(tI())}),iG={anonymousId:tI()},iX=tU(tH("type",[tD(iV,iG),tD(iW,iG),tD(iN,iG),tD(iD,iG),tD(iQ,iG),tD(iJ,iG),tD(iH,iG)])),iY=tH("type",[iV,iW,iN,iD,iQ,iJ,iH]),i0=tU(iY),i1=tN({features:t2(tU(tI()))}),i2=tN({events:i0.check(tE(1)),options:t2(i1)}),i6=tN({events:iX.check(tE(1)),options:t2(i1)}),i3=tN({id:tI(),isReturningVisitor:tT(),landingPage:iC,count:tj(),activeSessionLength:tj(),averageSessionLength:tj()}),i4=tN({id:tI(),stableId:tI(),random:tj(),audiences:tU(tI()),traits:iB,location:iT,session:i3}),i8=tZ(tN({id:tI()}),t9()),i5=tN({data:tN(),message:tI(),error:t3(tT())}),i9=tD(i5,{data:tN({profiles:t2(tU(i4))})}),i7=tN({key:tI(),type:tL([tX(["Variable"]),tI()]),meta:tN({experienceId:tI(),variantIndex:tj()})}),ne=tL([tI(),tT(),tC(),tj(),tW(tI(),t9())]);tD(i7,{type:tI(),value:new tB({type:"unknown"})});let nt=tU(tH("type",[tD(i7,{type:t0("Variable"),value:ne})])),ni=tU(tN({experienceId:tI(),variantIndex:tj(),variants:tW(tI(),tI()),sticky:t2(t8(tT(),!1))})),nn=tD(i5,{data:tN({profile:i4,experiences:ni,changes:nt})}),nr=tH("type",[iW,tD(iK,{type:t0("component_click")}),tD(iK,{type:t0("component_hover"),hoverDurationMs:tj(),hoverId:tI()})]),ns=tN({profile:i8,events:tU(nr)}),no=tU(ns);function na(e,t){let i=e.safeParse(t);if(i.success)return i.data;throw Error(function(e){let t=[];for(let i of[...e.issues].sort((e,t)=>(e.path??[]).length-(t.path??[]).length))t.push(`✖ ${i.message}`),i.path?.length&&t.push(` → at ${function(e){let t=[];for(let i of e.map(e=>"object"==typeof e?e.key:e))"number"==typeof i?t.push(`[${i}]`):"symbol"==typeof i?t.push(`[${JSON.stringify(String(i))}]`):/[^\w$]/.test(i)?t.push(`[${JSON.stringify(i)}]`):(t.length&&t.push("."),t.push(i));return t.join("")}(i.path)}`);return t.join("\n")}(i.error))}eS({localeError:(r={string:{unit:"characters",verb:"to have"},file:{unit:"bytes",verb:"to have"},array:{unit:"items",verb:"to have"},set:{unit:"items",verb:"to have"},map:{unit:"entries",verb:"to have"}},s={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",mac:"MAC address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},o={nan:"NaN"},e=>{switch(e.code){case"invalid_type":{let t=o[e.expected]??e.expected,i=function(e){let t=typeof e;switch(t){case"number":return Number.isNaN(e)?"nan":"number";case"object":if(null===e)return"null";if(Array.isArray(e))return"array";if(e&&Object.getPrototypeOf(e)!==Object.prototype&&"constructor"in e&&e.constructor)return e.constructor.name}return t}(e.input),n=o[i]??i;return`Invalid input: expected ${t}, received ${n}`}case"invalid_value":if(1===e.values.length)return`Invalid input: expected ${eU(e.values[0])}`;return`Invalid option: expected one of ${eE(e.values,"|")}`;case"too_big":{let t=e.inclusive?"<=":"<",i=r[e.origin]??null;if(i)return`Too big: expected ${e.origin??"value"} to have ${t}${e.maximum.toString()} ${i.unit??"elements"}`;return`Too big: expected ${e.origin??"value"} to be ${t}${e.maximum.toString()}`}case"too_small":{let t=e.inclusive?">=":">",i=r[e.origin]??null;if(i)return`Too small: expected ${e.origin} to have ${t}${e.minimum.toString()} ${i.unit}`;return`Too small: expected ${e.origin} to be ${t}${e.minimum.toString()}`}case"invalid_format":if("starts_with"===e.format)return`Invalid string: must start with "${e.prefix}"`;if("ends_with"===e.format)return`Invalid string: must end with "${e.suffix}"`;if("includes"===e.format)return`Invalid string: must include "${e.includes}"`;if("regex"===e.format)return`Invalid string: must match pattern ${e.pattern}`;return`Invalid ${s[e.format]??e.format}`;case"not_multiple_of":return`Invalid number: must be a multiple of ${e.divisor}`;case"unrecognized_keys":return`Unrecognized key${e.keys.length>1?"s":""}: ${eE(e.keys,", ")}`;case"invalid_key":return`Invalid key in ${e.origin}`;case"invalid_union":default:return"Invalid input";case"invalid_element":return`Invalid value in ${e.origin}`}})});let nl=new class{name="@contentful/optimization";PREFIX_PARTS=["Ctfl","O10n"];DELIMITER=":";sinks=[];assembleLocationPrefix(e){return`[${[...this.PREFIX_PARTS,e].join(this.DELIMITER)}]`}addSink(e){this.sinks=[...this.sinks.filter(t=>t.name!==e.name),e]}removeSink(e){this.sinks=this.sinks.filter(t=>t.name!==e)}removeSinks(){this.sinks=[]}debug(e,t,...i){this.emit("debug",e,t,...i)}info(e,t,...i){this.emit("info",e,t,...i)}log(e,t,...i){this.emit("log",e,t,...i)}warn(e,t,...i){this.emit("warn",e,t,...i)}error(e,t,...i){this.emit("error",e,t,...i)}fatal(e,t,...i){this.emit("fatal",e,t,...i)}emit(e,t,i,...n){this.onLogEvent({name:this.name,level:e,messages:[`${this.assembleLocationPrefix(t)} ${String(i)}`,...n]})}onLogEvent(e){this.sinks.forEach(t=>{t.ingest(e)})}};function nu(e){return{debug:(t,...i)=>{nl.debug(e,t,...i)},info:(t,...i)=>{nl.info(e,t,...i)},log:(t,...i)=>{nl.log(e,t,...i)},warn:(t,...i)=>{nl.warn(e,t,...i)},error:(t,...i)=>{nl.error(e,t,...i)},fatal:(t,...i)=>{nl.fatal(e,t,...i)}}}let nc={fatal:60,error:50,warn:40,info:30,debug:20,log:10},nd=class{},np={debug:(...e)=>{console.debug(...e)},info:(...e)=>{console.info(...e)},log:(...e)=>{console.log(...e)},warn:(...e)=>{console.warn(...e)},error:(...e)=>{console.error(...e)},fatal:(...e)=>{console.error(...e)}};class nf extends nd{name="ConsoleLogSink";verbosity;constructor(e){super(),this.verbosity=e??"error"}ingest(e){nc[e.level]{i(void 0)},e),await t}let ng=nu("ApiClient:Timeout"),nm=nu("ApiClient:Fetch"),nb=function(e){try{let t=function({apiName:e="Optimization",fetchMethod:t=fetch,onRequestTimeout:i,requestTimeout:n=3e3}={}){return async(r,s)=>{let o=new AbortController,a=setTimeout(()=>{"function"==typeof i?i({apiName:e}):ng.error(`Request to "${r.toString()}" timed out`,Error("Request timeout")),o.abort()},n),l=await t(r,{...s,signal:o.signal});return clearTimeout(a),l}}(e);return function({apiName:e="Optimization",fetchMethod:t=fetch,intervalTimeout:i=0,onFailedAttempt:n,retries:r=1}={}){return async(s,o)=>{let a=new AbortController,l=r+1,u=function({apiName:e="Optimization",controller:t,fetchMethod:i=fetch,init:n,url:r}){return async()=>{try{let s=await i(r,n);if(503===s.status)throw new nv(`${e} API request to "${r.toString()}" failed with status: "[${s.status}] ${s.statusText}".`,503);if(!s.ok){let e=Error(`Request to "${r.toString()}" failed with status: [${s.status}] ${s.statusText} - traceparent: ${s.headers.get("traceparent")}`);nh.error("Request failed with non-OK status:",e),t.abort();return}return nh.debug(`Response from "${r.toString()}":`,s),s}catch(e){if(e instanceof nv&&503===e.status)throw e;nh.error(`Request to "${r.toString()}" failed:`,e),t.abort()}}}({apiName:e,controller:a,fetchMethod:t,init:o,url:s});for(let t=1;t<=l;t++)try{let e=await u();if(e)return e;break}catch(s){if(!(s instanceof nv)||503!==s.status)throw s;let r=l-t;if(n?.({apiName:e,error:s,attemptNumber:t,retriesLeft:r}),0===r)throw s;await ny(i)}throw Error(`${e} API request to "${s.toString()}" may not be retried.`)}}({...e,fetchMethod:t})}catch(e){throw e instanceof Error&&("AbortError"===e.name?nm.warn("Request aborted due to network issues. This request may not be retried."):nm.error("Request failed:",e)),e}},nw=nu("ApiClient"),n_=class{name;clientId;environment;fetch;constructor(e,{fetchOptions:t,clientId:i,environment:n}){this.clientId=i,this.environment=n??"main",this.name=e,this.fetch=nb({...t??{},apiName:e})}logRequestError(e,{requestName:t}){e instanceof Error&&("AbortError"===e.name?nw.warn(`[${this.name}] "${t}" request aborted due to network issues. This request may not be retried.`):nw.error(`[${this.name}] "${t}" request failed:`,e))}},nz=nu("ApiClient:Experience");class nO extends n_{baseUrl;enabledFeatures;ip;locale;plainText;preflight;constructor(e){super("Experience",e);const{baseUrl:t,enabledFeatures:i,ip:n,locale:r,plainText:s,preflight:o}=e;this.baseUrl=t||"https://experience.ninetailed.co/",this.enabledFeatures=i,this.ip=n,this.locale=r,this.plainText=s,this.preflight=o}async getProfile(e,t={}){if(!e)throw Error("Valid profile ID required.");let i="Get Profile";nz.info(`Sending "${i}" request`);try{let n=await this.fetch(this.constructUrl(`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,t),{method:"GET"}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await n.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async makeProfileMutationRequest({url:e,body:t,options:i}){return await this.fetch(this.constructUrl(e,i),{method:"POST",headers:this.constructHeaders(i),body:JSON.stringify(t),keepalive:!0})}async createProfile({events:e},t={}){let i="Create Profile";nz.info(`Sending "${i}" request`);let n=this.constructExperienceRequestBody(e,t);nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles`,body:n,options:t}),{data:{changes:r,experiences:s,profile:o}}=na(nn,await e.json());return nz.debug(`"${i}" request successfully completed`),{changes:r,selectedOptimizations:s,profile:o}}catch(e){throw this.logRequestError(e,{requestName:i}),e}}async updateProfile({profileId:e,events:t},i={}){if(!e)throw Error("Valid profile ID required.");let n="Update Profile";nz.info(`Sending "${n}" request`);let r=this.constructExperienceRequestBody(t,i);nz.debug(`"${n}" request body:`,r);try{let t=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/profiles/${e}`,body:r,options:i}),{data:{changes:s,experiences:o,profile:a}}=na(nn,await t.json());return nz.debug(`"${n}" request successfully completed`),{changes:s,selectedOptimizations:o,profile:a}}catch(e){throw this.logRequestError(e,{requestName:n}),e}}async upsertProfile({profileId:e,events:t},i){return e?await this.updateProfile({profileId:e,events:t},i):await this.createProfile({events:t},i)}async upsertManyProfiles({events:e},t={}){let i="Upsert Many Profiles";nz.info(`Sending "${i}" request`);let n=na(i6,{events:e,options:this.constructBodyOptions(t)});nz.debug(`"${i}" request body:`,n);try{let e=await this.makeProfileMutationRequest({url:`v2/organizations/${this.clientId}/environments/${this.environment}/events`,body:n,options:{plainText:!1,...t}}),{data:{profiles:r}}=na(i9,await e.json());return nz.debug(`"${i}" request successfully completed`),r}catch(e){throw this.logRequestError(e,{requestName:i}),e}}constructUrl(e,t){let i=new URL(e,this.baseUrl),n=t.locale??this.locale,r=t.preflight??this.preflight;return n&&i.searchParams.set("locale",n),r&&i.searchParams.set("type","preflight"),i.toString()}constructHeaders({ip:e=this.ip,plainText:t=this.plainText}){let i=new Map;return e&&i.set("X-Force-IP",e),t??this.plainText??!0?i.set("Content-Type","text/plain"):i.set("Content-Type","application/json"),Object.fromEntries(i)}constructBodyOptions=({enabledFeatures:e=this.enabledFeatures})=>{let t={};return e&&Array.isArray(e)&&e.length>0?t.features=e:t.features=["ip-enrichment","location"],t};constructExperienceRequestBody(e,t){return i2.parse({events:na(i0,e),options:this.constructBodyOptions(t)})}}let nS=nu("ApiClient:Insights");class nE extends n_{baseUrl;beaconHandler;constructor(e){super("Insights",e);const{baseUrl:t,beaconHandler:i}=e;this.baseUrl=t||"https://ingest.insights.ninetailed.co/",this.beaconHandler=i}async sendBatchEvents(e,t={}){let{beaconHandler:i=this.beaconHandler}=t,n=new URL(`v1/organizations/${this.clientId}/environments/${this.environment}/events`,this.baseUrl),r=na(no,e);if("function"==typeof i){if(nS.debug("Queueing events via beaconHandler"),i(n,r))return!0;nS.warn("beaconHandler failed to queue events; events will be emitted immediately via fetch")}let s="Event Batches";nS.info(`Sending "${s}" request`),nS.debug(`"${s}" request body:`,r);try{return await this.fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r),keepalive:!0}),nS.debug(`"${s}" request successfully completed`),!0}catch(e){return this.logRequestError(e,{requestName:s}),!1}}}class nx{config;experience;insights;constructor(e){const{experience:t,insights:i,clientId:n,environment:r,fetchOptions:s}=e,o={clientId:n,environment:r,fetchOptions:s};this.config=o,this.experience=new nO({...o,...t}),this.insights=new nE({...o,...i})}}function nk(e){if(!e||"object"!=typeof e)return!1;let t=Object.getPrototypeOf(e);return(null===t||t===Object.prototype||null===Object.getPrototypeOf(t))&&"[object Object]"===Object.prototype.toString.call(e)}function nI(e){return nk(e)||Array.isArray(e)}let n$=tN({campaign:t2(i$),locale:t2(tI()),location:t2(iT),page:t2(iC),screen:t2(iM),userAgent:t2(tI())}),nP=tD(n$,{componentId:tI(),experienceId:t2(tI()),variantIndex:t2(tj())}),nj=tD(nP,{sticky:t2(tT()),viewId:tI(),viewDurationMs:tj()}),nA=tD(nP,{viewId:t2(tI()),viewDurationMs:t2(tj())}),nT=tD(nP,{hoverId:tI(),hoverDurationMs:tj()}),nF=tD(n$,{traits:t2(iB),userId:tI()}),nC=tD(n$,{properties:t2(function(e,t){var i=void 0;let n=e._zod.def.checks;if(n&&n.length>0)throw Error(".partial() cannot be used on object schemas containing refinements");let r=eA(e._zod.def,{get shape(){let t=e._zod.def.shape,n={...t};if(i)for(let e in i){if(!(e in t))throw Error(`Unrecognized key: "${e}"`);i[e]&&(n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e])}else for(let e in t)n[e]=t1?new t1({type:"optional",innerType:t[e]}):t[e];return ej(this,"shape",n),n},checks:[]});return eB(e,r)}(iC))}),nR=tD(n$,{name:tI(),properties:iR}),nM=tD(n$,{event:tI(),properties:t2(t8(iR,{}))}),nB={path:"",query:{},referrer:"",search:"",title:"",url:""},nq=class{app;channel;library;getLocale;getPageProperties;getUserAgent;constructor(e){const{app:t,channel:i,library:n,getLocale:r,getPageProperties:s,getUserAgent:o}=e;this.app=t,this.channel=i,this.library=n,this.getLocale=r??(()=>"en-US"),this.getPageProperties=s??(()=>nB),this.getUserAgent=o??(()=>void 0)}buildUniversalEventProperties({campaign:e={},locale:t,location:i,page:n,screen:r,userAgent:s}){let o=new Date().toISOString();return{channel:this.channel,context:{app:this.app,campaign:e,gdpr:{isConsentGiven:!0},library:this.library,locale:t??this.getLocale()??"en-US",location:i,page:n??this.getPageProperties(),screen:r,userAgent:s??this.getUserAgent()},messageId:crypto.randomUUID(),originalTimestamp:o,sentAt:o,timestamp:o}}buildEntryInteractionBase(e,t,i,n){return{...this.buildUniversalEventProperties(e),componentType:"Entry",componentId:t,experienceId:i,variantIndex:n??0}}buildView(e){let{componentId:t,viewId:i,experienceId:n,variantIndex:r,viewDurationMs:s,...o}=na(nj,e);return{...this.buildEntryInteractionBase(o,t,n,r),type:"component",viewId:i,viewDurationMs:s}}buildClick(e){let{componentId:t,experienceId:i,variantIndex:n,...r}=na(nP,e);return{...this.buildEntryInteractionBase(r,t,i,n),type:"component_click"}}buildHover(e){let{hoverId:t,componentId:i,experienceId:n,hoverDurationMs:r,variantIndex:s,...o}=na(nT,e);return{...this.buildEntryInteractionBase(o,i,n,s),type:"component_hover",hoverId:t,hoverDurationMs:r}}buildFlagView(e){let{componentId:t,experienceId:i,variantIndex:n,viewId:r,viewDurationMs:s,...o}=na(nA,e);return{...this.buildEntryInteractionBase(o,t,i,n),...void 0===s?{}:{viewDurationMs:s},...void 0===r?{}:{viewId:r},type:"component",componentType:"Variable"}}buildIdentify(e){let{traits:t={},userId:i,...n}=na(nF,e);return{...this.buildUniversalEventProperties(n),type:"identify",traits:t,userId:i}}buildPageView(e={}){let{properties:t={},...i}=na(nC,e),n=this.getPageProperties(),r=function e(t,i){let n=Object.keys(i);for(let r=0;re?e.reduce((e,{key:t,value:i})=>{let n="object"==typeof i&&null!==i&&"value"in i&&"object"==typeof i.value?i.value:i;return e[t]=n,e},{}):{}},nN=nu("Optimization"),nD="Could not resolve Merge Tag value:",nZ=(e,t)=>{if(!e||"object"!=typeof e)return;if(!t)return e;let i=e;for(let e of t.split(".").filter(Boolean)){if(!i||"object"!=typeof i&&"function"!=typeof i)return;i=Reflect.get(i,e)}return i},nQ={normalizeSelectors:e=>e.split("_").map((e,t,i)=>[i.slice(0,t).join("."),i.slice(t).join("_")].filter(e=>""!==e).join(".")),getValueFromProfile(e,t){let i=nQ.normalizeSelectors(e).find(e=>nZ(t,e));if(!i)return;let n=nZ(t,i);if(n&&("string"==typeof n||"number"==typeof n||"boolean"==typeof n))return`${n}`},resolve(e,t){if(!ih.safeParse(e).success)return void nN.warn(`${nD} supplied entry is not a Merge Tag entry`);let{fields:{nt_fallback:i}}=e;return i4.safeParse(t).success?nQ.getValueFromProfile(e.fields.nt_mergetag_id,t)??i:(nN.warn(`${nD} no valid profile`),i)}},nL=nu("Optimization"),nJ="Could not resolve optimized entry variant:",nH={getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ik(e))return e.fields.nt_experiences.filter(e=>ix(e)).find(e=>t.some(({experienceId:t})=>t===e.fields.nt_experience_id))},getSelectedOptimization({optimizationEntry:e,selectedOptimizations:t},i=!1){if(i||t.length&&ix(e))return t.find(({experienceId:t})=>t===e.fields.nt_experience_id)},getSelectedVariant({optimizedEntry:e,optimizationEntry:t,selectedVariantIndex:i},n=!1){var r;if(!n&&(!ik(e)||!ix(t)))return;let s=(r=t.fields.nt_config,{distribution:r?.distribution===void 0?[]:[...r.distribution],traffic:r?.traffic??0,components:r?.components===void 0?[]:[...r.components],sticky:r?.sticky??!1}).components.filter(e=>("EntryReplacement"===e.type||void 0===e.type)&&!e.baseline.hidden).find(t=>t.baseline.id===e.sys.id)?.variants;if(s?.length)return s.at(i-1)},getSelectedVariantEntry({optimizationEntry:e,selectedVariant:t},i=!1){if(!i&&(!ix(e)||!iv.safeParse(t).success))return;let n=e.fields.nt_variants?.find(e=>e.sys.id===t.id);return ic.safeParse(n).success?n:void 0},resolve:function(e,t){if(nL.debug(`Resolving optimized entry for baseline entry ${e.sys.id}`),!t?.length)return nL.warn(`${nJ} no selectedOptimizations exist for the current profile`),{entry:e};if(!ik(e))return nL.warn(`${nJ} entry ${e.sys.id} is not optimized`),{entry:e};let i=nH.getOptimizationEntry({optimizedEntry:e,selectedOptimizations:t},!0);if(!i)return nL.warn(`${nJ} could not find an optimization entry for ${e.sys.id}`),{entry:e};let n=nH.getSelectedOptimization({optimizationEntry:i,selectedOptimizations:t},!0),r=n?.variantIndex??0;if(0===r)return nL.debug(`Resolved optimization entry for entry ${e.sys.id} is baseline`),{entry:e};let s=nH.getSelectedVariant({optimizedEntry:e,optimizationEntry:i,selectedVariantIndex:r},!0);if(!s)return nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e};let o=nH.getSelectedVariantEntry({optimizationEntry:i,selectedVariant:s},!0);return o?(nL.debug(`Entry ${e.sys.id} has been resolved to variant entry ${o.sys.id}`),{entry:o,selectedOptimization:n}):(nL.warn(`${nJ} could not find a valid replacement variant entry for ${e.sys.id}`),{entry:e})}};class nK{api;eventBuilder;config;flagsResolver=nV;mergeTagValueResolver=nQ;optimizedEntryResolver=nH;interceptors={event:new nU,state:new nU};constructor(e,t={}){this.config=e;const{eventBuilder:i,logLevel:n,environment:r,clientId:s,fetchOptions:o}=e;nl.addSink(new nf(n));const a={clientId:s,environment:r,fetchOptions:o,experience:t.experience,insights:t.insights};this.api=new nx(a),this.eventBuilder=new nq(i??{channel:"server",library:{name:"@contentful/optimization-ios-bridge",version:"0.0.0"}})}getFlag(e,t){return this.flagsResolver.resolve(t)[e]}resolveOptimizedEntry(e,t){return this.optimizedEntryResolver.resolve(e,t)}getMergeTagValue(e,t){return this.mergeTagValueResolver.resolve(e,t)}}let nW=nK;function nG(){}function nX(e,t){return function e(t,i,n,r,s,o,a){let l=a(t,i,n,r,s,o);if(void 0!==l)return l;if(typeof t==typeof i)switch(typeof t){case"bigint":case"string":case"boolean":case"symbol":case"undefined":case"function":return t===i;case"number":return t===i||Object.is(t,i)}return function t(i,n,r,s){if(Object.is(i,n))return!0;let o=F(i),a=F(n);if(o===q&&(o=L),a===q&&(a=L),o!==a)return!1;switch(o){case R:return i.toString()===n.toString();case M:{let e=i.valueOf(),t=n.valueOf();return e===t||Number.isNaN(e)&&Number.isNaN(t)}case B:case V:case U:return Object.is(i.valueOf(),n.valueOf());case C:return i.source===n.source&&i.flags===n.flags;case"[object Function]":return i===n}let l=(r=r??new Map).get(i),u=r.get(n);if(null!=l&&null!=u)return l===n;r.set(i,n),r.set(n,i);try{switch(o){case N:if(i.size!==n.size)return!1;for(let[t,o]of i.entries())if(!n.has(t)||!e(o,n.get(t),t,i,n,r,s))return!1;return!0;case D:{if(i.size!==n.size)return!1;let t=Array.from(i.values()),o=Array.from(n.values());for(let a=0;ae(l,t,void 0,i,n,r,s));if(-1===u)return!1;o.splice(u,1)}return!0}case Z:case H:case K:case W:case G:case"[object BigUint64Array]":case X:case Y:case ee:case"[object BigInt64Array]":case et:case ei:if(er(i)!==er(n)||i.length!==n.length)return!1;for(let t=0;t{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))})}return i}resolveOptimizedEntry(e,t=ey.value){return super.resolveOptimizedEntry(e,t)}getMergeTagValue(e,t=em.value){return super.getMergeTagValue(e,t)}async identify(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("identify",[e],this.eventBuilder.buildIdentify(i),t)}async page(e={}){let{profile:t,...i}=e;return await this.sendExperienceEvent("page",[e],this.eventBuilder.buildPageView(i),t)}async screen(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("screen",[e],this.eventBuilder.buildScreenView(i),t)}async track(e){let{profile:t,...i}=e;return await this.sendExperienceEvent("track",[e],this.eventBuilder.buildTrack(i),t)}async trackView(e){let t,{profile:i,...n}=e;return e.sticky&&(t=await this.sendExperienceEvent("trackView",[e],this.eventBuilder.buildView(n),i)),await this.sendInsightsEvent("trackView",[e],this.eventBuilder.buildView(n),i),t}async trackClick(e){await this.sendInsightsEvent("trackClick",[e],this.eventBuilder.buildClick(e))}async trackHover(e){await this.sendInsightsEvent("trackHover",[e],this.eventBuilder.buildHover(e))}async trackFlagView(e){await this.sendInsightsEvent("trackFlagView",[e],this.eventBuilder.buildFlagView(e))}hasConsent(e){let{[e]:t}=n0,i=void 0!==t?this.allowedEventTypes.includes(t):this.allowedEventTypes.some(t=>t===e);return!!ed.value||i}onBlockedByConsent(e,t){nY.warn(`Event "${e}" was blocked due to lack of consent; payload: ${JSON.stringify(t)}`),this.reportBlockedEvent("consent",e,t)}async sendExperienceEvent(e,t,i,n){return this.hasConsent(e)?await this.experienceQueue.send(i):void this.onBlockedByConsent(e,t)}async sendInsightsEvent(e,t,i,n){this.hasConsent(e)?await this.insightsQueue.send(i):this.onBlockedByConsent(e,t)}buildFlagViewBuilderArgs(e,t=eu.value){let i=t?.find(t=>t.key===e);return{componentId:e,experienceId:i?.meta.experienceId,variantIndex:i?.meta.variantIndex}}getFlagObservable(e){var t;let i,n=this.flagObservables.get(e);if(n)return n;let r=this.trackFlagView.bind(this),s=this.buildFlagViewBuilderArgs.bind(this),o=(t=ew.computed(()=>super.getFlag(e,eu.value)),i=el(t),{get current(){return i.current},subscribe(e){let t=!1,n=ea(i.current);return i.subscribe(i=>{t&&nX(n,i)||(t=!0,n=ea(i),e(i))})},subscribeOnce:e=>i.subscribeOnce(e)}),a={get current(){let{current:t}=o;return r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t},subscribe:t=>o.subscribe(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)}),subscribeOnce:t=>o.subscribeOnce(i=>{r(s(e,eu.value)).catch(t=>{nl.warn(`Failed to emit "flag view" event for "${e}"`,String(t))}),t(i)})};return this.flagObservables.set(e,a),a}reportBlockedEvent(e,t,i){let n={reason:e,method:t,args:i};try{this.onEventBlocked?.(n)}catch(e){nY.warn(`onEventBlocked callback failed for method "${t}"`,e)}ec.value=n}}let n2=n1,n6=(e,t)=>!Number.isFinite(e)||void 0===e||e<1?t:Math.floor(e),n3={flushIntervalMs:3e4,baseBackoffMs:500,maxBackoffMs:3e4,jitterRatio:.2,maxConsecutiveFailures:8,circuitOpenMs:12e4},n4="__ctfl_optimization_stateful_runtime_lock__",n8=()=>{let e=globalThis;return e[n4]??={owner:void 0},e[n4]},n5=e=>{let t=n8();t.owner===e&&(t.owner=void 0)};class n9{circuitOpenUntil=0;flushFailureCount=0;flushInFlight=!1;nextFlushAllowedAt=0;onCallbackError;onRetry;policy;retryTimer;constructor(e){const{onCallbackError:t,onRetry:i,policy:n}=e;this.policy=n,this.onRetry=i,this.onCallbackError=t}reset(){this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.flushInFlight=!1,this.nextFlushAllowedAt=0}clearScheduledRetry(){void 0!==this.retryTimer&&(clearTimeout(this.retryTimer),this.retryTimer=void 0)}shouldSkip(e){let{force:t,isOnline:i}=e;if(this.flushInFlight)return!0;if(t)return!1;if(!i)return!0;let n=Date.now();return!!(this.nextFlushAllowedAt>n)||!!(this.circuitOpenUntil>n)}markFlushStarted(){this.flushInFlight=!0}markFlushFinished(){this.flushInFlight=!1}handleFlushSuccess(){let{flushFailureCount:e}=this;this.clearScheduledRetry(),this.circuitOpenUntil=0,this.flushFailureCount=0,this.nextFlushAllowedAt=0,e<=0||this.safeInvoke("onFlushRecovered",{consecutiveFailures:e})}handleFlushFailure(e){let{queuedBatches:t,queuedEvents:i}=e;this.flushFailureCount+=1;let n=(e=>{let{consecutiveFailures:t,policy:{baseBackoffMs:i,jitterRatio:n,maxBackoffMs:r}}=e,s=Math.min(r,i*2**Math.max(0,t-1)),o=s*n*Math.random();return Math.round(s+o)})({consecutiveFailures:this.flushFailureCount,policy:this.policy}),r=Date.now(),s={consecutiveFailures:this.flushFailureCount,queuedBatches:t,queuedEvents:i,retryDelayMs:n};this.safeInvoke("onFlushFailure",s);let{circuitOpenUntil:o,nextFlushAllowedAt:a,openedCircuit:l,retryDelayMs:u}=(e=>{let{consecutiveFailures:t,failureTimestamp:i,retryDelayMs:n,policy:{maxConsecutiveFailures:r,circuitOpenMs:s}}=e;if(t{this.retryTimer=void 0,this.onRetry()},e)}safeInvoke(...e){let[t,i]=e;try{if("onFlushRecovered"===t)return void this.policy.onFlushRecovered?.(i);if("onCircuitOpen"===t)return void this.policy.onCircuitOpen?.(i);this.policy.onFlushFailure?.(i)}catch(e){this.onCallbackError?.(t,e)}}}let n7=nu("CoreStateful");class re{experienceApi;eventInterceptors;flushRuntime;getAnonymousId;offlineMaxEvents;onOfflineDrop;queuedExperienceEvents=new Set;stateInterceptors;constructor(e){const{experienceApi:t,eventInterceptors:i,flushPolicy:n,getAnonymousId:r,offlineMaxEvents:s,onOfflineDrop:o,stateInterceptors:a}=e;this.experienceApi=t,this.eventInterceptors=i,this.getAnonymousId=r,this.offlineMaxEvents=s,this.onOfflineDrop=o,this.stateInterceptors=a,this.flushRuntime=new n9({policy:n,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{n7.warn(`Experience flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}async send(e){let t=na(iY,await this.eventInterceptors.run(e));if(ep.value=t,ef.value)return await this.upsertProfile([t]);n7.debug(`Queueing ${t.type} event`,t),this.enqueueEvent(t)}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!ef.value}))return;if(0===this.queuedExperienceEvents.size)return void this.flushRuntime.clearScheduledRetry();n7.debug("Flushing offline Experience event queue");let i=Array.from(this.queuedExperienceEvents);this.flushRuntime.markFlushStarted();try{await this.tryUpsertQueuedEvents(i)?(i.forEach(e=>{this.queuedExperienceEvents.delete(e)}),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:+(this.queuedExperienceEvents.size>0),queuedEvents:this.queuedExperienceEvents.size})}finally{this.flushRuntime.markFlushFinished()}}enqueueEvent(e){let t=[];if(this.queuedExperienceEvents.size>=this.offlineMaxEvents){let e=this.queuedExperienceEvents.size-this.offlineMaxEvents+1;(t=this.dropOldestEvents(e)).length>0&&n7.warn(`Dropped ${t.length} oldest offline event(s) due to queue limit (${this.offlineMaxEvents})`)}this.queuedExperienceEvents.add(e),t.length>0&&this.invokeOfflineDropCallback({droppedCount:t.length,droppedEvents:t,maxEvents:this.offlineMaxEvents,queuedEvents:this.queuedExperienceEvents.size})}dropOldestEvents(e){let t=[];for(let i=0;i{nX(eu.value,t)||(eu.value=t),nX(em.value,i)||(em.value=i),nX(ey.value,n)||(ey.value=n)})}}let rt=nu("CoreStateful");class ri{eventInterceptors;flushIntervalMs;flushRuntime;insightsApi;queuedInsightsByProfile=new Map;insightsPeriodicFlushTimer;constructor(e){const{eventInterceptors:t,flushPolicy:i,insightsApi:n}=e,{flushIntervalMs:r}=i;this.eventInterceptors=t,this.flushIntervalMs=r,this.insightsApi=n,this.flushRuntime=new n9({policy:i,onRetry:()=>{this.flush()},onCallbackError:(e,t)=>{rt.warn(`Insights flush policy callback "${e}" failed`,t)}})}clearScheduledRetry(){this.flushRuntime.clearScheduledRetry()}clearPeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer&&(clearInterval(this.insightsPeriodicFlushTimer),this.insightsPeriodicFlushTimer=void 0)}async send(e){let{value:t}=em;if(!t)return void rt.warn("Attempting to emit an event without an Optimization profile");let i=na(nr,await this.eventInterceptors.run(e));rt.debug(`Queueing ${i.type} event for profile ${t.id}`,i);let n=this.queuedInsightsByProfile.get(t.id);ep.value=i,n?(n.profile=t,n.events.push(i)):this.queuedInsightsByProfile.set(t.id,{profile:t,events:[i]}),this.ensurePeriodicFlushTimer(),this.getQueuedEventCount()>=25&&await this.flush(),this.reconcilePeriodicFlushTimer()}async flush(e={}){let{force:t=!1}=e;if(this.flushRuntime.shouldSkip({force:t,isOnline:!!ef.value}))return;rt.debug("Flushing insights event queue");let i=this.createBatches();if(!i.length){this.flushRuntime.clearScheduledRetry(),this.reconcilePeriodicFlushTimer();return}this.flushRuntime.markFlushStarted();try{await this.trySendBatches(i)?(this.queuedInsightsByProfile.clear(),this.flushRuntime.handleFlushSuccess()):this.flushRuntime.handleFlushFailure({queuedBatches:i.length,queuedEvents:this.getQueuedEventCount()})}finally{this.flushRuntime.markFlushFinished(),this.reconcilePeriodicFlushTimer()}}createBatches(){let e=[];return this.queuedInsightsByProfile.forEach(({profile:t,events:i})=>{e.push({profile:t,events:i})}),e}async trySendBatches(e){try{return await this.insightsApi.sendBatchEvents(e)}catch(e){return rt.warn("Insights queue flush request threw an error",e),!1}}getQueuedEventCount(){let e=0;return this.queuedInsightsByProfile.forEach(({events:t})=>{e+=t.length}),e}ensurePeriodicFlushTimer(){void 0!==this.insightsPeriodicFlushTimer||0!==this.getQueuedEventCount()&&(this.insightsPeriodicFlushTimer=setInterval(()=>{this.flush()},this.flushIntervalMs))}reconcilePeriodicFlushTimer(){this.getQueuedEventCount()>0?this.ensurePeriodicFlushTimer():this.clearPeriodicFlushTimer()}}let rn=Symbol.for("ctfl.optimization.preview.signals"),rr=Symbol.for("ctfl.optimization.preview.signalFns"),rs=nu("CoreStateful"),ro=["identify","page","screen"],ra=e=>Object.values(e).some(e=>void 0!==e),rl=0;class ru extends n2{singletonOwner;destroyed=!1;allowedEventTypes;experienceQueue;insightsQueue;onEventBlocked;states={blockedEventStream:el(ec),flag:e=>this.getFlagObservable(e),consent:el(ed),eventStream:el(ep),canOptimize:el(eg),selectedOptimizations:el(ey),previewPanelAttached:el(eh),previewPanelOpen:el(ev),profile:el(em)};constructor(e){super(e,{experience:(e=>{if(void 0===e)return;let t={baseUrl:e.experienceBaseUrl,enabledFeatures:e.enabledFeatures,ip:e.ip,locale:e.locale,plainText:e.plainText,preflight:e.preflight};return ra(t)?t:void 0})(e.api),insights:(e=>{if(void 0===e)return;let t={baseUrl:e.insightsBaseUrl,beaconHandler:e.beaconHandler};return ra(t)?t:void 0})(e.api)}),this.singletonOwner=`CoreStateful#${++rl}`,(e=>{let t=n8();if(t.owner)throw Error(`Stateful Optimization SDK already initialized (${t.owner}). Only one stateful instance is supported per runtime.`);t.owner=e})(this.singletonOwner);try{const{allowedEventTypes:t,defaults:i,getAnonymousId:n,onEventBlocked:r,queuePolicy:s}=e,{changes:o,consent:a,selectedOptimizations:l,profile:u}=i??{},c=(e=>({flush:((e,t=n3)=>{var i,n;let r=e??{},s=n6(r.baseBackoffMs,t.baseBackoffMs),o=Math.max(s,n6(r.maxBackoffMs,t.maxBackoffMs));return{flushIntervalMs:n6(r.flushIntervalMs,t.flushIntervalMs),baseBackoffMs:s,maxBackoffMs:o,jitterRatio:(i=r.jitterRatio,n=t.jitterRatio,Number.isFinite(i)&&void 0!==i?Math.min(1,Math.max(0,i)):n),maxConsecutiveFailures:n6(r.maxConsecutiveFailures,t.maxConsecutiveFailures),circuitOpenMs:n6(r.circuitOpenMs,t.circuitOpenMs),onCircuitOpen:r.onCircuitOpen,onFlushFailure:r.onFlushFailure,onFlushRecovered:r.onFlushRecovered}})(e?.flush),offlineMaxEvents:n6(e?.offlineMaxEvents,100),onOfflineDrop:e?.onOfflineDrop}))(s);this.allowedEventTypes=t??ro,this.onEventBlocked=r,this.insightsQueue=new ri({eventInterceptors:this.interceptors.event,flushPolicy:c.flush,insightsApi:this.api.insights}),this.experienceQueue=new re({experienceApi:this.api.experience,eventInterceptors:this.interceptors.event,flushPolicy:c.flush,getAnonymousId:n??(()=>void 0),offlineMaxEvents:c.offlineMaxEvents,onOfflineDrop:c.onOfflineDrop,stateInterceptors:this.interceptors.state}),void 0!==a&&(ed.value=a),p(()=>{void 0!==o&&(eu.value=o),void 0!==l&&(ey.value=l),void 0!==u&&(em.value=u)}),this.initializeEffects()}catch(e){throw n5(this.singletonOwner),e}}initializeEffects(){A(()=>{rs.debug(`Profile ${em.value&&`with ID ${em.value.id}`} has been ${em.value?"set":"cleared"}`)}),A(()=>{rs.debug(`Variants have been ${ey.value?.length?"populated":"cleared"}`)}),A(()=>{rs.info(`Core ${ed.value?"will":"will not"} emit gated events due to consent (${ed.value})`)}),A(()=>{ef.value&&(this.insightsQueue.clearScheduledRetry(),this.experienceQueue.clearScheduledRetry(),this.flushQueues({force:!0}))})}async flushQueues(e={}){await this.insightsQueue.flush(e),await this.experienceQueue.flush(e)}destroy(){this.destroyed||(this.destroyed=!0,this.insightsQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush insights queue during destroy()",String(e))}),this.experienceQueue.flush({force:!0}).catch(e=>{nl.warn("Failed to flush Experience queue during destroy()",String(e))}),this.insightsQueue.clearPeriodicFlushTimer(),n5(this.singletonOwner))}reset(){p(()=>{ec.value=void 0,ep.value=void 0,eu.value=void 0,em.value=void 0,ey.value=void 0})}async flush(){await this.flushQueues()}consent(e){ed.value=e}get online(){return ef.value??!1}set online(e){ef.value=e}registerPreviewPanel(e){Reflect.set(e,rn,eb),Reflect.set(e,rr,ew)}}let rc="ALL_VISITORS";function rd(e,t){let{audiences:{[t]:i}}=e;return i?i.isActive?"on":"off":"default"}function rp(e,t){return"on"===e||"off"!==e&&t}function rf(e,t,i,n){let r=t[e.id]??0,s=void 0!==i.selectedOptimizations[e.id],o={...e,currentVariantIndex:r,isOverridden:s};if(s&&void 0!==n){let{[e.id]:t}=n;void 0!==t&&(o.naturalVariantIndex=t)}return o}function rh(e,t){let i=Object.values(t);if(0===i.length)return e;let n=e.map(e=>{let{[e.experienceId]:i}=t;return i?{...e,variantIndex:i.variantIndex}:e});for(let e of i)n.some(t=>t.experienceId===e.experienceId)||n.push({experienceId:e.experienceId,variantIndex:e.variantIndex,variants:{}});return n}nu("Preview");let rv=nu("PreviewOverrides"),ry={audiences:{},selectedOptimizations:{}};class rg{baselineSelectedOptimizations=null;baselineAudienceQualifications={};overrides={...ry,audiences:{},selectedOptimizations:{}};interceptorId=null;selectedOptimizations;profile;stateInterceptors;onOverridesChanged;constructor(e){const{selectedOptimizations:t,profile:i,stateInterceptors:n,onOverridesChanged:r}=e;this.selectedOptimizations=t,this.profile=i,this.stateInterceptors=n,this.onOverridesChanged=r;const{value:s}=t;s&&(this.baselineSelectedOptimizations=s,rv.debug("Captured initial signal state as baseline")),this.interceptorId=e.stateInterceptors.add(e=>{let{selectedOptimizations:t}=e;this.baselineSelectedOptimizations=t;let i=Object.keys(this.overrides.selectedOptimizations).length>0,n=i?{...e,selectedOptimizations:rh(e.selectedOptimizations,this.overrides.selectedOptimizations)}:{...e};return i&&rv.debug("Intercepting state update to preserve overrides"),this.notifyChanged(),n}),rv.info("State interceptor registered")}activateAudience(e,t){rv.info("Activating audience override:",e),this.setAudienceOverride(e,!0,1,t)}deactivateAudience(e,t){rv.info("Deactivating audience override:",e),this.setAudienceOverride(e,!1,0,t)}resetAudienceOverride(e){rv.info("Resetting audience override:",e);let{overrides:t}=this,{audiences:i,selectedOptimizations:n}=t,r=i[e]?.experienceIds??[],s=new Set(r),o=Object.fromEntries(Object.entries(n).filter(([e])=>!s.has(e))),a=Object.fromEntries(Object.entries(i).filter(([t])=>t!==e));this.overrides={audiences:a,selectedOptimizations:o},this.baselineAudienceQualifications=Object.fromEntries(Object.entries(this.baselineAudienceQualifications).filter(([t])=>t!==e)),r.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}setVariantOverride(e,t){rv.info("Setting variant override:",{experienceId:e,variantIndex:t}),this.overrides={...this.overrides,selectedOptimizations:{...this.overrides.selectedOptimizations,[e]:{experienceId:e,variantIndex:t}}},this.syncOverridesToSignal(),this.notifyChanged()}resetOptimizationOverride(e){rv.info("Resetting optimization override:",e);let{selectedOptimizations:t}={...this.overrides},i=Object.fromEntries(Object.entries(t).filter(([t])=>t!==e));this.overrides={...this.overrides,selectedOptimizations:i},this.syncOverridesToSignal(),this.notifyChanged()}resetAll(){rv.info("Resetting all overrides to baseline"),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineAudienceQualifications={};let{baselineSelectedOptimizations:e}=this;e&&(this.selectedOptimizations.value=e,rv.debug("Restored signal to baseline")),this.notifyChanged()}getOverrides(){return this.overrides}getBaselineSelectedOptimizations(){return this.baselineSelectedOptimizations}getBaselineAudienceQualifications(){return this.baselineAudienceQualifications}destroy(){null!==this.interceptorId&&(this.stateInterceptors.remove(this.interceptorId),rv.info("State interceptor removed"),this.interceptorId=null),this.overrides={audiences:{},selectedOptimizations:{}},this.baselineSelectedOptimizations=null,this.baselineAudienceQualifications={}}snapshotAudienceQualification(e){if(!this.profile||e in this.baselineAudienceQualifications)return;let t=this.profile.value?.audiences.includes(e)??!1;this.baselineAudienceQualifications[e]=t}syncOverridesToSignal(){this.selectedOptimizations.value=rh(this.baselineSelectedOptimizations??[],this.overrides.selectedOptimizations),rv.debug("Synced overrides to signal")}setAudienceOverride(e,t,i,n){this.snapshotAudienceQualification(e);let r={...this.overrides.selectedOptimizations};for(let e of n)r[e]={experienceId:e,variantIndex:i};this.overrides={audiences:{...this.overrides.audiences,[e]:{audienceId:e,isActive:t,source:"manual",experienceIds:n}},selectedOptimizations:r},n.length>0&&this.syncOverridesToSignal(),this.notifyChanged()}notifyChanged(){this.onOverridesChanged?.(this.overrides)}}let rm=null,rb=null,rw=null,r_=[],rz=null,rO=null,rS=null,rE={},rx={},rk={initialize(e){rm&&rk.destroy(),rO=null,rS=null,rE={},rx={},rm=new ru({clientId:e.clientId,environment:e.environment,api:{experienceBaseUrl:e.experienceBaseUrl,insightsBaseUrl:e.insightsBaseUrl}}),e.defaults&&(void 0!==e.defaults.consent&&rm.consent(e.defaults.consent),void 0!==e.defaults.profile&&(eb.profile.value=e.defaults.profile),void 0!==e.defaults.changes&&(eb.changes.value=e.defaults.changes),void 0!==e.defaults.optimizations&&(eb.selectedOptimizations.value=e.defaults.optimizations)),rm.consent(!0);let t=globalThis;rz=new rg({selectedOptimizations:eb.selectedOptimizations,profile:eb.profile,stateInterceptors:rm.interceptors.state,onOverridesChanged:()=>{"function"==typeof t.__nativeOnOverridesChanged&&t.__nativeOnOverridesChanged(rk.getPreviewState())}}),rb=A(()=>{let e={profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null};"function"==typeof t.__nativeOnStateChange&&t.__nativeOnStateChange(JSON.stringify(e))}),rw=A(()=>{let e=eb.event.value;e&&"function"==typeof t.__nativeOnEventEmitted&&t.__nativeOnEventEmitted(JSON.stringify(e))})},identify(e,t,i){rm?rm.identify(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},page(e,t,i){rm?rm.page(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},screen(e,t,i){rm?rm.screen({name:e.name,properties:e.properties??{}}).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},flush(e,t){rm?rm.flush().then(()=>{e(JSON.stringify(null))}).catch(e=>{t(e instanceof Error?e.message:String(e))}):t("SDK not initialized. Call initialize() first.")},trackView(e,t,i){rm?rm.trackView(e).then(e=>{t(JSON.stringify(e??null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},trackClick(e,t,i){rm?rm.trackClick(e).then(()=>{t(JSON.stringify(null))}).catch(e=>{i(e instanceof Error?e.message:String(e))}):i("SDK not initialized. Call initialize() first.")},consent(e){rm&&rm.consent(e)},reset(){rm&&(rz?.resetAll(),rm.reset())},setOnline(e){eb.online.value=e},flag(e){rm&&r_.push(rm.states.flag(e).subscribe(()=>void 0))},personalizeEntry:(e,t)=>rm?JSON.stringify(rm.resolveOptimizedEntry(e,t)):JSON.stringify({entry:e}),getMergeTagValue:e=>rm?rm.getMergeTagValue(e)??null:null,setPreviewPanelOpen(e){rm&&(eb.previewPanelOpen.value=e)},overrideAudience(e,t,i){rz&&(t?rz.activateAudience(e,i):rz.deactivateAudience(e,i))},overrideVariant(e,t){rz?.setVariantOverride(e,t)},resetAudienceOverride(e){rz?.resetAudienceOverride(e)},resetVariantOverride(e){rz?.resetOptimizationOverride(e)},resetAllOverrides(){rz?.resetAll()},loadDefinitions(e,t){try{let i,n;for(let r of(rO=e.map(e=>{let t=e.fields;return"object"==typeof t&&null!==t?{id:t.nt_audience_id??e.sys.id,name:t.nt_name??t.nt_audience_id??e.sys.id,description:t.nt_description}:{id:e.sys.id,name:e.sys.id}}),i=new Map,t.forEach(e=>{(e.includes?.Entry??[]).forEach(e=>{i.set(e.sys.id,e)})}),rS=t.map(e=>{let t=e.fields;if("object"!=typeof t||null===t)return{id:e.sys.id,name:e.sys.id,type:"nt_personalization",distribution:[]};let{nt_config:n}=t,r=[];return n?.distribution&&n.distribution.forEach((e,t)=>{let s,o=(s=n.components?.[0],void 0===s||void 0!==s.type&&"EntryReplacement"!==s.type?"":0===t?s.baseline.id:s.variants[t-1]?.id??""),a=i.get(o);r.push({index:t,variantRef:o,percentage:Math.round(100*e),name:a?function(e){let t=e.fields;if("object"==typeof t&&null!==t)return t.internalTitle??t.title??t.name}(a):void 0})}),{id:t.nt_experience_id??e.sys.id,name:t.nt_name??e.sys.id,type:t.nt_type??"nt_personalization",distribution:r,audience:t.nt_audience?{id:t.nt_audience.sys.id}:void 0}}),n={},t.forEach(e=>{let t=e.fields;if("object"==typeof t&&null!==t){let i=t.nt_personalization_id??t.nt_experience_id??e.sys.id,{nt_name:r}=t;r&&(n[i]=r)}}),rx=n,rE={},rO))rE[r.id]=r.name;return JSON.stringify({audienceCount:rO.length,experienceCount:rS.length})}catch(e){return rO=null,rS=null,rE={},rx={},JSON.stringify({error:e instanceof Error?e.message:String(e)})}},getPreviewState(){let e=rz?.getOverrides()??{audiences:{},selectedOptimizations:{}},t=rz?.getBaselineSelectedOptimizations(),i={};for(let[t,n]of Object.entries(e.audiences))i[t]=n.isActive;let n={};for(let[t,i]of Object.entries(e.selectedOptimizations))n[t]=i.variantIndex;let r={};if(t)for(let e of t)void 0!==n[e.experienceId]&&(r[e.experienceId]=e.variantIndex);let s=rO&&rS?{...function(e){let{audienceDefinitions:t,experienceDefinitions:i,signals:n,overrides:r}=e,{profile:s,selectedOptimizations:o}=n,a=new Set(s?.audiences??[]),l={};if(o)for(let{experienceId:e,variantIndex:t}of o)l[e]=t;let u=null!=e.baselineSelectedOptimizations?Object.fromEntries(e.baselineSelectedOptimizations.map(e=>[e.experienceId,e.variantIndex])):void 0,c=new Set(t.map(e=>e.id)),d=i.filter(e=>!e.audience?.id||!c.has(e.audience.id)).map(e=>rf(e,l,r,u)),p=t.map(e=>{let t=i.filter(t=>t.audience?.id===e.id).map(e=>rf(e,l,r,u)),n=a.has(e.id),s=rd(r,e.id),o=rp(s,n);return{audience:e,experiences:t,isQualified:n,isActive:o,overrideState:s}});if(d.length>0){let e=rd(r,rc);p.push({audience:{id:rc,name:"All Visitors",description:"Experiences that apply to all visitors regardless of audience membership"},experiences:d,isQualified:!0,isActive:rp(e,!0),overrideState:e})}let f=t.length>0||i.length>0;return{audiencesWithExperiences:[...p].sort((e,t)=>e.audience.id===rc?-1:t.audience.id===rc?1:e.isActive!==t.isActive?e.isActive?-1:1:e.audience.name.localeCompare(t.audience.name,void 0,{sensitivity:"base"})),unassociatedExperiences:d,hasData:f,sdkVariantIndices:l}}({audienceDefinitions:rO,experienceDefinitions:rS,signals:{profile:eb.profile.value,selectedOptimizations:eb.selectedOptimizations.value,consent:eb.consent.value,isLoading:!1},overrides:e,baselineSelectedOptimizations:t}),audienceNameMap:rE,experienceNameMap:rx}:null;return JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null,previewPanelOpen:eb.previewPanelOpen.value,audienceOverrides:i,variantOverrides:n,defaultAudienceQualifications:rz?.getBaselineAudienceQualifications()??{},defaultVariantIndices:r,previewModel:s})},getProfile(){let e=eb.profile.value;return e?JSON.stringify(e):null},getState:()=>JSON.stringify({profile:eb.profile.value??null,consent:eb.consent.value,canPersonalize:eb.canOptimize.value,changes:eb.changes.value??null,selectedPersonalizations:eb.selectedOptimizations.value??null}),destroy(){for(let e of(rz?.destroy(),rz=null,rO=null,rS=null,rE={},rx={},r_))e.unsubscribe();r_=[],rw&&(rw(),rw=null),rb&&(rb(),rb=null),rm&&(rm.destroy(),rm=null)}};globalThis.__bridge=rk;let rI=rk;return u.default})()); //# sourceMappingURL=optimization-ios-bridge.umd.js.map \ No newline at end of file diff --git a/packages/universal/optimization-js-bridge/src/index.ts b/packages/universal/optimization-js-bridge/src/index.ts index 4a4f20a4..2ea75e07 100644 --- a/packages/universal/optimization-js-bridge/src/index.ts +++ b/packages/universal/optimization-js-bridge/src/index.ts @@ -17,6 +17,7 @@ import { } from '@contentful/optimization-core/preview-support' type ResolveOptimizedEntryParams = Parameters +type GetMergeTagValueParams = Parameters interface BridgeConfig { clientId: string @@ -95,6 +96,8 @@ interface Bridge { baseline: Record, personalizations?: Array>, ): string + getMergeTagValue(mergeTagEntry: Record): string | null + flag(name: string): void setOnline(isOnline: boolean): void // Preview panel @@ -111,6 +114,7 @@ interface Bridge { let instance: CoreStateful | null = null let disposeEffect: (() => void) | null = null let disposeEventEffect: (() => void) | null = null +let flagSubscriptions: Array<{ unsubscribe: () => void }> = [] let overrideManager: PreviewOverrideManager | null = null let audienceDefinitions: AudienceDefinition[] | null = null let experienceDefinitions: ExperienceDefinition[] | null = null @@ -309,6 +313,13 @@ const bridge: Bridge = { signals.online.value = isOnline }, + flag(name: string) { + if (!instance) return + // Subscribing to the flag observable emits a `component` flag-view event + // through the core event stream (and again on each distinct value change). + flagSubscriptions.push(instance.states.flag(name).subscribe(() => undefined)) + }, + personalizeEntry( baseline: Record, personalizations?: Array>, @@ -321,6 +332,12 @@ const bridge: Bridge = { return JSON.stringify(result) }, + getMergeTagValue(mergeTagEntry: Record): string | null { + if (!instance) return null + const value = instance.getMergeTagValue(mergeTagEntry as unknown as GetMergeTagValueParams[0]) + return value ?? null + }, + setPreviewPanelOpen(open: boolean) { if (!instance) return signals.previewPanelOpen.value = open @@ -468,6 +485,10 @@ const bridge: Bridge = { experienceDefinitions = null audienceNameMap = {} experienceNameMap = {} + for (const subscription of flagSubscriptions) { + subscription.unsubscribe() + } + flagSubscriptions = [] if (disposeEventEffect) { disposeEventEffect() disposeEventEffect = null From 6a59652721ca964354f03ae7c17a890a28322421 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Mon, 25 May 2026 08:38:42 +0200 Subject: [PATCH 06/66] prevent CI from silently passing when Android instrumentation runner fails --- .github/workflows/main-pipeline.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index b2ef5cdd..f13bef6f 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -783,6 +783,7 @@ jobs: disable-animations: true emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect script: | + set -o pipefail echo "Disabling animations..." adb shell settings put global window_animation_scale 0 adb shell settings put global transition_animation_scale 0 @@ -798,6 +799,7 @@ jobs: adb shell am instrument -w com.contentful.optimization.uitests/androidx.test.runner.AndroidJUnitRunner 2>&1 | tee /tmp/test-output.log grep -q "FAILURES" /tmp/test-output.log && { echo "::error::Android UI tests failed"; exit 1; } || true grep -q "Process crashed" /tmp/test-output.log && { echo "::error::Test process crashed"; exit 1; } || true + grep -q "OK (" /tmp/test-output.log || { echo "::error::Android UI tests did not complete successfully (missing OK status)"; exit 1; } - name: Upload logs on failure if: failure() From 13a21e13b264672aca82b8c502d5de4a5aebee30 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Mon, 25 May 2026 09:50:39 +0200 Subject: [PATCH 07/66] Add a com.contentful.optimization.views adapter mirroring the Compose adapter on top of the existing core API so XML/Views-based Android apps can integrate the SDK without Compose dependencies --- .../ContentfulOptimization/build.gradle.kts | 1 + .../optimization/views/OptimizationManager.kt | 125 ++++++++ .../optimization/views/OptimizedEntryView.kt | 279 ++++++++++++++++++ .../optimization/views/ScreenTracker.kt | 36 +++ .../views/TrackingRecyclerView.kt | 44 +++ 5 files changed, 485 insertions(+) create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt create mode 100644 packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt diff --git a/packages/android/ContentfulOptimization/build.gradle.kts b/packages/android/ContentfulOptimization/build.gradle.kts index 6cbe2cec..8f8f5e87 100644 --- a/packages/android/ContentfulOptimization/build.gradle.kts +++ b/packages/android/ContentfulOptimization/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation("io.github.dokar3:quickjs-kt:1.0.5") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("androidx.lifecycle:lifecycle-process:2.8.7") + implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.json:json:20240303") diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt new file mode 100644 index 00000000..b9690492 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizationManager.kt @@ -0,0 +1,125 @@ +package com.contentful.optimization.views + +import android.app.Activity +import android.content.Context +import android.widget.ImageButton +import com.contentful.optimization.core.OptimizationClient +import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.preview.PreviewContentfulClient +import com.contentful.optimization.preview.PreviewPanelActivity +import com.contentful.optimization.preview.PreviewPanelConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicReference + +/** + * View-based counterpart of [com.contentful.optimization.compose.OptimizationRoot]. + * + * Initializes a process-wide [OptimizationClient] for XML/Views-based apps and exposes it via + * [client]. Reference implementations and consumer apps should call [initialize] from + * `Application.onCreate`, then read [client] from any [Activity] or `Fragment`. + * + * Mirrors the static-client pattern already documented in `packages/android/AGENTS.md` for + * [com.contentful.optimization.preview.PreviewPanelActivity], extended to cover the whole SDK + * rather than just the preview panel. + */ +object OptimizationManager { + + private val clientRef = AtomicReference(null) + private val previewClientRef = AtomicReference(null) + + /** Default applied to [OptimizedEntryView.trackViews] when the per-view value is null. */ + @Volatile + var trackViews: Boolean = true + private set + + /** Default applied to [OptimizedEntryView.trackTaps] when the per-view value is null. */ + @Volatile + var trackTaps: Boolean = false + private set + + /** Default applied to [OptimizedEntryView.liveUpdates] when the per-view value is null. */ + @Volatile + var liveUpdates: Boolean = false + private set + + private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + /** + * The live [OptimizationClient]. Call [initialize] before reading this, or it throws. + * + * The same error message is used as `LocalOptimizationClient`'s default value on the Compose + * side so cross-implementation diagnostics line up. + */ + val client: OptimizationClient + get() = clientRef.get() + ?: error( + "No OptimizationClient provided. Call OptimizationManager.initialize() in your " + + "Application.onCreate before reading OptimizationManager.client.", + ) + + /** + * Construct and initialize the [OptimizationClient]. Idempotent — subsequent calls update + * the global tracking defaults and preview-panel client but do not recreate the underlying + * client. + */ + fun initialize( + context: Context, + config: OptimizationConfig, + trackViews: Boolean = true, + trackTaps: Boolean = false, + liveUpdates: Boolean = false, + previewPanel: PreviewPanelConfig? = null, + ) { + this.trackViews = trackViews + this.trackTaps = trackTaps + this.liveUpdates = liveUpdates + previewClientRef.set(previewPanel?.contentfulClient) + + if (clientRef.get() != null) return + + val newClient = OptimizationClient(context.applicationContext) + clientRef.set(newClient) + initScope.launch { + try { + newClient.initialize(config) + } catch (_: Exception) { + // Initialization failures surface through `client.isInitialized` staying false; + // callers that observe state handle this the same way as the Compose path. + } + } + } + + /** + * Attach the preview-panel floating action button to [activity]. Returns null when the + * preview panel is disabled in the active [PreviewPanelConfig] or when [initialize] has not + * been called. Mirrors [com.contentful.optimization.compose.OptimizationRoot]'s + * `previewPanel` parameter, which inserts the same overlay on the Compose side. + */ + fun attachPreviewPanel(activity: Activity): ImageButton? { + val c = clientRef.get() ?: return null + return PreviewPanelActivity.addFloatingButton( + activity = activity, + client = c, + contentfulClient = previewClientRef.get(), + ) + } + + /** + * Tear down for testing or hot-reloads. Production apps don't normally call this. + */ + fun resetForTesting() { + clientRef.getAndSet(null)?.let { c -> + runBlocking { + try { + c.destroy() + } catch (_: Exception) { + } + } + } + previewClientRef.set(null) + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt new file mode 100644 index 00000000..436a6991 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/OptimizedEntryView.kt @@ -0,0 +1,279 @@ +package com.contentful.optimization.views + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import com.contentful.optimization.core.PersonalizedResult +import com.contentful.optimization.core.TrackClickPayload +import com.contentful.optimization.tracking.TrackingMetadata +import com.contentful.optimization.tracking.ViewTrackingController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * View-based counterpart of [com.contentful.optimization.compose.OptimizedEntry]. + * + * Wraps a single Contentful entry and renders the resolved (personalized) entry through a + * caller-supplied [contentRenderer]. Owns a [ViewTrackingController] for visibility-based view + * tracking and forwards click events to [com.contentful.optimization.core.OptimizationClient.trackClick]. + * + * Live-updates and locking semantics mirror `OptimizedEntry` so the same Contentful entry + * behaves identically whether rendered through Compose or XML Views. + * + * Typical use: + * ```kotlin + * val view = OptimizedEntryView(context) + * view.setContentRenderer { resolvedEntry -> + * TextView(context).apply { + * text = (resolvedEntry["fields"] as? Map<*, *>)?.get("text") as? String + * } + * } + * view.accessibilityIdentifier = "content-entry-$id" + * view.setEntry(rawEntry) + * ``` + */ +class OptimizedEntryView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + // Configuration — public so consumers can tune per-instance without subclassing. + var viewTimeMs: Int = 2000 + var threshold: Double = 0.8 + var viewDurationUpdateIntervalMs: Int = 5000 + var liveUpdates: Boolean? = null + var trackViews: Boolean? = null + var trackTaps: Boolean? = null + var onTap: ((Map) -> Unit)? = null + + /** + * Scopes the entry's accessibility identifier. Mirrors `OptimizedEntry`'s + * `accessibilityIdentifier` parameter, which is the SDK's `contentDescription` contract. + */ + var accessibilityIdentifier: String? = null + set(value) { + field = value + contentDescription = value + } + + private var entry: Map? = null + private var personalizationsOverride: List>? = null + private var contentRenderer: ((Map) -> View)? = null + + private var trackingScope: CoroutineScope? = null + private var personalizationJob: Job? = null + private var previewJob: Job? = null + + private var controller: ViewTrackingController? = null + private var lockedPersonalizations: List>? = null + private var isLocked: Boolean = false + private var lastResult: PersonalizedResult? = null + + private val preDrawListener = ViewTreeObserver.OnPreDrawListener { + updateVisibility() + true + } + + /** + * Set the renderer that turns a resolved entry map into a child View. Called on every + * personalization update; the returned View replaces the previous content. + */ + fun setContentRenderer(renderer: (Map) -> View) { + this.contentRenderer = renderer + lastResult?.let { renderContent(it.entry) } + } + + /** + * Set the entry to personalize. Optional [personalizations] forces a specific set instead + * of observing the live personalizations stream (used by tests or callers driving their own + * personalization state). + */ + fun setEntry( + entry: Map, + personalizations: List>? = null, + ) { + this.entry = entry + this.personalizationsOverride = personalizations + this.lockedPersonalizations = null + this.isLocked = false + restartObservation() + } + + /** Force a visibility re-check from outside (e.g., from [TrackingRecyclerView] on scroll). */ + fun requestVisibilityCheck() { + updateVisibility() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + trackingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + viewTreeObserver.addOnPreDrawListener(preDrawListener) + if (resolveTrackTaps()) { + setOnClickListener { fireTrackClick() } + } + restartObservation() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + viewTreeObserver.removeOnPreDrawListener(preDrawListener) + controller?.let { + it.onDisappear() + it.destroy() + } + controller = null + trackingScope?.cancel() + trackingScope = null + personalizationJob = null + previewJob = null + } + + private fun restartObservation() { + val scope = trackingScope ?: return + val entry = entry ?: return + val client = OptimizationManager.client + + controller?.let { + it.onDisappear() + it.destroy() + } + controller = null + personalizationJob?.cancel() + previewJob?.cancel() + + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val isPersonalized = fields?.containsKey("nt_experiences") == true + val explicitOverride = personalizationsOverride + + personalizationJob = scope.launch { + if (!isPersonalized || explicitOverride != null) { + // Plain entry — or caller-supplied personalizations. No live observation. + publishResult( + client.personalizeEntry(baseline = entry, personalizations = explicitOverride), + ) + return@launch + } + // Mirrors the OptimizedEntry composable: collect each personalization emission + // rather than snapshotting, so the rapid sequence triggered by identify() is not + // coalesced. + client.selectedPersonalizations.collect { newValue -> + val previewOpen = client.isPreviewPanelOpen.value + val shouldLive = + if (previewOpen) true else liveUpdates ?: OptimizationManager.liveUpdates + if (!shouldLive && !isLocked && newValue != null) { + lockedPersonalizations = newValue + isLocked = true + } + val effective = if (shouldLive) newValue else lockedPersonalizations + publishResult( + client.personalizeEntry(baseline = entry, personalizations = effective), + ) + } + } + + previewJob = scope.launch { + client.isPreviewPanelOpen.collect { open -> + if (!open && isPersonalized && isLocked) { + lockedPersonalizations = client.selectedPersonalizations.value + publishResult( + client.personalizeEntry( + baseline = entry, + personalizations = lockedPersonalizations, + ), + ) + } + } + } + } + + private fun publishResult(result: PersonalizedResult) { + lastResult = result + renderContent(result.entry) + attachController(result) + } + + private fun renderContent(entry: Map) { + val renderer = contentRenderer ?: return + removeAllViews() + addView(renderer(entry)) + } + + private fun attachController(result: PersonalizedResult) { + if (!resolveTrackViews()) return + val entry = entry ?: return + controller?.let { + it.onDisappear() + it.destroy() + } + controller = ViewTrackingController( + client = OptimizationManager.client, + entry = entry, + personalization = result.personalization, + threshold = threshold, + viewTimeMs = viewTimeMs, + viewDurationUpdateIntervalMs = viewDurationUpdateIntervalMs, + ) + updateVisibility() + } + + private fun updateVisibility() { + val controller = controller ?: return + if (!isAttachedToWindow || height == 0) return + + // getGlobalVisibleRect intersects with every ancestor's clip rect plus the window's + // visible area, so rect.height() is the truly-visible portion of this view. + val rect = Rect() + val visible = getGlobalVisibleRect(rect) + val elementHeight = height.toFloat() + val visibleHeight = if (visible) rect.height().toFloat() else 0f + + // Encode the visibility geometry as (elementY=0, scrollY=0, viewportHeight=visibleHeight) + // so ViewTrackingController computes ratio = visibleHeight / elementHeight, matching the + // ratio the Compose `Modifier.trackViews` derives from its scroll-aware geometry. + controller.updateVisibility( + elementY = 0f, + elementHeight = elementHeight, + scrollY = 0f, + viewportHeight = visibleHeight, + ) + } + + private fun fireTrackClick() { + val entry = entry ?: return + val scope = trackingScope ?: return + val metadata = TrackingMetadata(entry, lastResult?.personalization) + scope.launch { + try { + OptimizationManager.client.trackClick( + TrackClickPayload( + componentId = metadata.componentId, + experienceId = metadata.experienceId, + variantIndex = metadata.variantIndex, + ), + ) + } catch (_: Exception) { + } + } + onTap?.invoke(entry) + } + + private fun resolveTrackViews(): Boolean = trackViews ?: OptimizationManager.trackViews + + private fun resolveTrackTaps(): Boolean { + val explicit = trackTaps + return when { + explicit == false -> false + explicit != null || onTap != null -> true + else -> OptimizationManager.trackTaps + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt new file mode 100644 index 00000000..2b8a5713 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/ScreenTracker.kt @@ -0,0 +1,36 @@ +package com.contentful.optimization.views + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * View-based counterpart of [com.contentful.optimization.compose.ScreenTrackingEffect]. + * + * Call from `Activity.onResume` or `Fragment.onResume` to emit a `screen` event with the given + * name through the active [com.contentful.optimization.core.OptimizationClient]: + * + * ```kotlin + * override fun onResume() { + * super.onResume() + * ScreenTracker.trackScreen("MainScreen") + * } + * ``` + * + * Failures (including the client not yet being initialized) are swallowed — same contract as + * the Compose `ScreenTrackingEffect`. + */ +object ScreenTracker { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun trackScreen(name: String) { + scope.launch { + try { + OptimizationManager.client.screen(name = name) + } catch (_: Exception) { + } + } + } +} diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt new file mode 100644 index 00000000..db0562a2 --- /dev/null +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/views/TrackingRecyclerView.kt @@ -0,0 +1,44 @@ +package com.contentful.optimization.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +/** + * View-based counterpart of [com.contentful.optimization.compose.OptimizationLazyColumn]. + * + * A drop-in [RecyclerView] that nudges descendant [OptimizedEntryView] instances to re-evaluate + * their visibility on every scroll frame. Optional: [OptimizedEntryView] inside any scroll + * container will also re-check via its own + * [android.view.ViewTreeObserver.OnPreDrawListener], so this class is a redundant signal path + * for scroll containers whose layout passes do not naturally redraw children. + */ +class TrackingRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : RecyclerView(context, attrs, defStyleAttr) { + + init { + addOnScrollListener( + object : OnScrollListener() { + override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { + forEachOptimizedEntry(this@TrackingRecyclerView) { + it.requestVisibilityCheck() + } + } + }, + ) + } + + private fun forEachOptimizedEntry(root: View, action: (OptimizedEntryView) -> Unit) { + if (root is OptimizedEntryView) action(root) + if (root is ViewGroup) { + for (i in 0 until root.childCount) { + forEachOptimizedEntry(root.getChildAt(i), action) + } + } + } +} From c852ee21bcc9a5f54cf5b734491d06dd37dfe241 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Mon, 25 May 2026 09:52:39 +0200 Subject: [PATCH 08/66] Extract the platform-agnostic app helpers into a new :shared Kotlin library module under com.contentful.optimization.shared so both the Compose reference impl and the upcoming XML Views impl can consume the same AppConfig, ContentfulFetcher, EventStore, MockPreviewContentfulClient, and RichText --- .../android-sdk/app/build.gradle.kts | 1 + .../optimization/app/MainActivity.kt | 2 ++ .../app/components/AnalyticsEventDisplay.kt | 2 +- .../app/components/ContentEntryView.kt | 1 + .../app/components/NestedContentEntryView.kt | 1 + .../app/screens/LiveUpdatesTestScreen.kt | 2 +- .../optimization/app/screens/MainScreen.kt | 6 ++-- .../android-sdk/settings.gradle.kts | 1 + .../android-sdk/shared/build.gradle.kts | 34 +++++++++++++++++++ .../shared/src/main/AndroidManifest.xml | 2 ++ .../optimization/shared}/AppConfig.kt | 2 +- .../optimization/shared}/ContentfulFetcher.kt | 2 +- .../optimization/shared}/EventStore.kt | 2 +- .../shared}/MockPreviewContentfulClient.kt | 2 +- .../optimization/shared}/RichText.kt | 2 +- 15 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 implementations/android-sdk/shared/build.gradle.kts create mode 100644 implementations/android-sdk/shared/src/main/AndroidManifest.xml rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/AppConfig.kt (93%) rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/ContentfulFetcher.kt (99%) rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/EventStore.kt (98%) rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app => shared/src/main/kotlin/com/contentful/optimization/shared}/MockPreviewContentfulClient.kt (98%) rename implementations/android-sdk/{app/src/main/kotlin/com/contentful/optimization/app/components => shared/src/main/kotlin/com/contentful/optimization/shared}/RichText.kt (98%) diff --git a/implementations/android-sdk/app/build.gradle.kts b/implementations/android-sdk/app/build.gradle.kts index 83624004..da3b0a51 100644 --- a/implementations/android-sdk/app/build.gradle.kts +++ b/implementations/android-sdk/app/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { dependencies { implementation(project(":ContentfulOptimization")) + implementation(project(":shared")) implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.compose.ui:ui") diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt index c181261c..7e101445 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt @@ -17,6 +17,8 @@ import com.contentful.optimization.app.screens.MainScreen import com.contentful.optimization.compose.OptimizationRoot import com.contentful.optimization.core.OptimizationConfig import com.contentful.optimization.preview.PreviewPanelConfig +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.MockPreviewContentfulClient class MainActivity : ComponentActivity() { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt index 900cc873..d7944a44 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.contentful.optimization.app.EventStore +import com.contentful.optimization.shared.EventStore @Composable fun AnalyticsEventDisplay() { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt index adc3ce42..ea647e93 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.shared.RichText @Composable fun ContentEntryView(entry: Map) { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt index f97451f5..6cf1d657 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.OptimizedEntry +import com.contentful.optimization.shared.RichText @Composable fun NestedContentEntryView(entry: Map) { diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt index 8701780c..8cd27861 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.contentful.optimization.app.ContentfulFetcher +import com.contentful.optimization.shared.ContentfulFetcher import com.contentful.optimization.compose.LocalOptimizationClient import com.contentful.optimization.compose.LocalTrackingConfig import com.contentful.optimization.compose.OptimizationLazyColumn diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt index cf4ce4cd..a9db6177 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt +++ b/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt @@ -22,9 +22,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import com.contentful.optimization.app.AppConfig -import com.contentful.optimization.app.ContentfulFetcher -import com.contentful.optimization.app.EventStore +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.ContentfulFetcher +import com.contentful.optimization.shared.EventStore import com.contentful.optimization.app.components.AnalyticsEventDisplay import com.contentful.optimization.app.components.ContentEntryView import com.contentful.optimization.app.components.NestedContentEntryView diff --git a/implementations/android-sdk/settings.gradle.kts b/implementations/android-sdk/settings.gradle.kts index 825cd9ae..c9e202f2 100644 --- a/implementations/android-sdk/settings.gradle.kts +++ b/implementations/android-sdk/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { rootProject.name = "OptimizationAndroidApp" include(":app") +include(":shared") include(":uitests") include(":ContentfulOptimization") project(":ContentfulOptimization").projectDir = diff --git a/implementations/android-sdk/shared/build.gradle.kts b/implementations/android-sdk/shared/build.gradle.kts new file mode 100644 index 00000000..5052ea94 --- /dev/null +++ b/implementations/android-sdk/shared/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization.shared" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + // Pulls in the OptimizationClient core API used by RichText.resolveText() and the + // preview-contentful interfaces consumed by MockPreviewContentfulClient. + api(project(":ContentfulOptimization")) + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("org.json:json:20240303") +} diff --git a/implementations/android-sdk/shared/src/main/AndroidManifest.xml b/implementations/android-sdk/shared/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/implementations/android-sdk/shared/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt similarity index 93% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt index 9ebc00af..83724f26 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/AppConfig.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/AppConfig.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared object AppConfig { const val clientId = "mock-client-id" diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/ContentfulFetcher.kt similarity index 99% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/ContentfulFetcher.kt index d4af05c9..f32ae852 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/ContentfulFetcher.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/ContentfulFetcher.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/EventStore.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/EventStore.kt index 91de000d..4e2626a4 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/EventStore.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/EventStore.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/MockPreviewContentfulClient.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/MockPreviewContentfulClient.kt index 79b44fa7..5f4a4566 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MockPreviewContentfulClient.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/MockPreviewContentfulClient.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app +package com.contentful.optimization.shared import com.contentful.optimization.preview.ContentfulEntriesResult import com.contentful.optimization.preview.ContentfulIncludes diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt similarity index 98% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt rename to implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt index 76c4fd3f..10f13d4e 100644 --- a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/RichText.kt +++ b/implementations/android-sdk/shared/src/main/kotlin/com/contentful/optimization/shared/RichText.kt @@ -1,4 +1,4 @@ -package com.contentful.optimization.app.components +package com.contentful.optimization.shared import com.contentful.optimization.core.OptimizationClient From f11a64417d6b5b92f49fa7778f32e158eaf6057c Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Mon, 25 May 2026 09:55:01 +0200 Subject: [PATCH 09/66] Rename the Android Compose reference impl module from :app to :compose and reroute every script, gradle build path, IDE run config, and CI workflow line to the new module so it sits as a sibling of the upcoming :views XML reference impl, mirroring the iOS swiftui/uikit pair --- .github/workflows/main-pipeline.yaml | 4 ++-- implementations/android-sdk/AGENTS.md | 15 +++++++++------ implementations/android-sdk/README.md | 4 ++-- .../android-sdk/{app => compose}/build.gradle.kts | 0 .../{app => compose}/src/main/AndroidManifest.xml | 0 .../contentful/optimization/app/MainActivity.kt | 0 .../app/components/AnalyticsEventDisplay.kt | 0 .../app/components/ContentEntryView.kt | 0 .../app/components/NestedContentEntryView.kt | 0 .../app/screens/LiveUpdatesTestScreen.kt | 0 .../optimization/app/screens/MainScreen.kt | 0 .../app/screens/NavigationTestScreen.kt | 0 .../src/main/res/values/themes.xml | 0 implementations/android-sdk/package.json | 4 ++-- implementations/android-sdk/scripts/bootstrap.sh | 4 ++-- implementations/android-sdk/scripts/run-e2e.sh | 4 ++-- implementations/android-sdk/settings.gradle.kts | 2 +- .../android-sdk/uitests/build.gradle.kts | 6 +++++- 18 files changed, 25 insertions(+), 18 deletions(-) rename implementations/android-sdk/{app => compose}/build.gradle.kts (100%) rename implementations/android-sdk/{app => compose}/src/main/AndroidManifest.xml (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt (100%) rename implementations/android-sdk/{app => compose}/src/main/res/values/themes.xml (100%) diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index f13bef6f..3618a930 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -747,7 +747,7 @@ jobs: - name: Build app and test APKs working-directory: implementations/android-sdk - run: ./gradlew :app:assembleDebug :uitests:assembleDebug + run: ./gradlew :compose:assembleDebug :uitests:assembleDebug - name: Start Mock Server run: | @@ -789,7 +789,7 @@ jobs: adb shell settings put global transition_animation_scale 0 adb shell settings put global animator_duration_scale 0 echo "Installing APKs..." - adb install -r implementations/android-sdk/app/build/outputs/apk/debug/app-debug.apk + adb install -r implementations/android-sdk/compose/build/outputs/apk/debug/compose-debug.apk adb install -r implementations/android-sdk/uitests/build/outputs/apk/debug/uitests-debug.apk echo "Setting up adb reverse port forwarding..." adb reverse tcp:8000 tcp:8000 diff --git a/implementations/android-sdk/AGENTS.md b/implementations/android-sdk/AGENTS.md index 0f384342..0d425a76 100644 --- a/implementations/android-sdk/AGENTS.md +++ b/implementations/android-sdk/AGENTS.md @@ -10,9 +10,12 @@ build. ## Key paths -- `app/src/main/kotlin/com/contentful/optimization/app/` — App source -- `app/src/main/kotlin/com/contentful/optimization/app/screens/` — Screen composables -- `app/src/main/kotlin/com/contentful/optimization/app/components/` — Reusable UI components +- `compose/src/main/kotlin/com/contentful/optimization/app/` — Jetpack Compose app source +- `compose/src/main/kotlin/com/contentful/optimization/app/screens/` — Screen composables +- `compose/src/main/kotlin/com/contentful/optimization/app/components/` — Reusable UI components +- `shared/src/main/kotlin/com/contentful/optimization/shared/` — Platform-agnostic Kotlin helpers + (AppConfig, ContentfulFetcher, EventStore, MockPreviewContentfulClient, RichText) shared by every + reference app under this directory - `uitests/` — UI Automator 2 E2E test module (`com.android.test`) - `uitests/src/main/kotlin/.../uitests/tests/` — Test files (1:1 mirror of iOS XCUITest suite) - `uitests/src/main/kotlin/.../uitests/support/` — Shared test helpers, app launcher, device @@ -20,7 +23,7 @@ build. - `scripts/` — Build and run scripts - `build.gradle.kts` — Root build config (plugin versions) - `settings.gradle.kts` — Project structure (includes SDK module + uitests via project.dir) -- `app/build.gradle.kts` — App module build config and dependencies +- `compose/build.gradle.kts` — Compose app module build config and dependencies ## Local rules @@ -30,7 +33,7 @@ build. - The mock server must be running at `http://localhost:8000` before running the app. Use `adb reverse tcp:8000 tcp:8000` to forward the port to the emulator. - The app references the SDK via Gradle `include` + `project.dir` in `settings.gradle.kts`. After - SDK source changes, rebuild via `./gradlew :app:assembleDebug` from this directory. + SDK source changes, rebuild via `./gradlew :compose:assembleDebug` from this directory. - Keep accessibility identifiers (testTags) aligned with the iOS SwiftUI implementation and `implementations/PREVIEW_PANEL_SCENARIOS.md`. - Use `Modifier.testTag()` for app-level test identifiers. The root composable sets @@ -43,7 +46,7 @@ build. ## Commands - `pnpm serve:mocks` (from monorepo root) -- From `implementations/android-sdk/`: `./gradlew :app:assembleDebug` +- From `implementations/android-sdk/`: `./gradlew :compose:assembleDebug` - From `implementations/android-sdk/`: `./scripts/bootstrap.sh` - Build bridge first: `pnpm --filter @contentful/optimization-js-bridge build` - Build UI test APK: `./gradlew :uitests:assembleDebug` diff --git a/implementations/android-sdk/README.md b/implementations/android-sdk/README.md index ac6b29c3..ac782cee 100644 --- a/implementations/android-sdk/README.md +++ b/implementations/android-sdk/README.md @@ -69,8 +69,8 @@ pnpm serve:mocks # Terminal 2: Build and install cd implementations/android-sdk adb reverse tcp:8000 tcp:8000 -./gradlew :app:assembleDebug -adb install -r app/build/outputs/apk/debug/app-debug.apk +./gradlew :compose:assembleDebug +adb install -r compose/build/outputs/apk/debug/compose-debug.apk adb shell am start -n com.contentful.optimization.app/.MainActivity ``` diff --git a/implementations/android-sdk/app/build.gradle.kts b/implementations/android-sdk/compose/build.gradle.kts similarity index 100% rename from implementations/android-sdk/app/build.gradle.kts rename to implementations/android-sdk/compose/build.gradle.kts diff --git a/implementations/android-sdk/app/src/main/AndroidManifest.xml b/implementations/android-sdk/compose/src/main/AndroidManifest.xml similarity index 100% rename from implementations/android-sdk/app/src/main/AndroidManifest.xml rename to implementations/android-sdk/compose/src/main/AndroidManifest.xml diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/MainActivity.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/AnalyticsEventDisplay.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/ContentEntryView.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/components/NestedContentEntryView.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/LiveUpdatesTestScreen.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/MainScreen.kt diff --git a/implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt b/implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt similarity index 100% rename from implementations/android-sdk/app/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt rename to implementations/android-sdk/compose/src/main/kotlin/com/contentful/optimization/app/screens/NavigationTestScreen.kt diff --git a/implementations/android-sdk/app/src/main/res/values/themes.xml b/implementations/android-sdk/compose/src/main/res/values/themes.xml similarity index 100% rename from implementations/android-sdk/app/src/main/res/values/themes.xml rename to implementations/android-sdk/compose/src/main/res/values/themes.xml diff --git a/implementations/android-sdk/package.json b/implementations/android-sdk/package.json index 3871cf4c..551c5a1c 100644 --- a/implementations/android-sdk/package.json +++ b/implementations/android-sdk/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "./gradlew :app:assembleDebug", - "build:release": "./gradlew :app:assembleRelease", + "build": "./gradlew :compose:assembleDebug", + "build:release": "./gradlew :compose:assembleRelease", "bootstrap": "./scripts/bootstrap.sh", "test:ui": "./gradlew :uitests:connectedAndroidTest", "test:e2e": "./scripts/run-e2e.sh" diff --git a/implementations/android-sdk/scripts/bootstrap.sh b/implementations/android-sdk/scripts/bootstrap.sh index 25d2a6e2..fc1c3e94 100755 --- a/implementations/android-sdk/scripts/bootstrap.sh +++ b/implementations/android-sdk/scripts/bootstrap.sh @@ -192,12 +192,12 @@ build_app() { log_info "Building Android app..." cd "$APP_DIR" - ./gradlew :app:assembleDebug + ./gradlew :compose:assembleDebug log_info "Build complete" } install_and_launch() { - local apk="$APP_DIR/app/build/outputs/apk/debug/app-debug.apk" + local apk="$APP_DIR/compose/build/outputs/apk/debug/compose-debug.apk" if [[ ! -f "$apk" ]]; then log_error "APK not found at $apk. Did the build succeed?" diff --git a/implementations/android-sdk/scripts/run-e2e.sh b/implementations/android-sdk/scripts/run-e2e.sh index e6c4774b..c9d31e68 100755 --- a/implementations/android-sdk/scripts/run-e2e.sh +++ b/implementations/android-sdk/scripts/run-e2e.sh @@ -486,12 +486,12 @@ build_apks() { log_info "Building app APK and test APK..." cd "$APP_DIR" - ./gradlew :app:assembleDebug :uitests:assembleDebug + ./gradlew :compose:assembleDebug :uitests:assembleDebug log_info "Build complete" } install_apks() { - local app_apk="$APP_DIR/app/build/outputs/apk/debug/app-debug.apk" + local app_apk="$APP_DIR/compose/build/outputs/apk/debug/compose-debug.apk" local test_apk="$APP_DIR/uitests/build/outputs/apk/debug/uitests-debug.apk" if [[ ! -f "$app_apk" ]]; then diff --git a/implementations/android-sdk/settings.gradle.kts b/implementations/android-sdk/settings.gradle.kts index c9e202f2..034c85ae 100644 --- a/implementations/android-sdk/settings.gradle.kts +++ b/implementations/android-sdk/settings.gradle.kts @@ -16,7 +16,7 @@ dependencyResolutionManagement { rootProject.name = "OptimizationAndroidApp" -include(":app") +include(":compose") include(":shared") include(":uitests") include(":ContentfulOptimization") diff --git a/implementations/android-sdk/uitests/build.gradle.kts b/implementations/android-sdk/uitests/build.gradle.kts index 0dfb5c1b..20b95c1d 100644 --- a/implementations/android-sdk/uitests/build.gradle.kts +++ b/implementations/android-sdk/uitests/build.gradle.kts @@ -23,7 +23,11 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - targetProjectPath = ":app" + // The compose reference impl is the default target. Step 5 reads APP_PACKAGE from the + // instrumentation arguments to switch targets at runtime, but Gradle still needs a single + // compile-time link. Keeping the link pointed at the Compose app preserves the existing + // CI surface; the matrix CI leg installs the views APK separately before running. + targetProjectPath = ":compose" } kotlin { From a1815123ea1592b6734151358903b7f7330d6bf2 Mon Sep 17 00:00:00 2001 From: Alex Freas Date: Mon, 25 May 2026 10:03:05 +0200 Subject: [PATCH 10/66] Add the views/ XML Views reference implementation mirroring the Compose impl screen-for-screen, using OptimizedEntryView, ScreenTracker, and OptimizationManager from the new SDK views adapter and exposing every Compose testTag through an AccessibilityNodeInfoCompat.setViewIdResourceName helper so the shared UI Automator suite resolves the same selectors against both apps --- .../android-sdk/settings.gradle.kts | 1 + .../android-sdk/views/build.gradle.kts | 51 ++++ .../views/src/main/AndroidManifest.xml | 25 ++ .../app/views/LiveUpdatesTestActivity.kt | 229 ++++++++++++++++++ .../optimization/app/views/MainActivity.kt | 181 ++++++++++++++ .../app/views/NavigationTestActivity.kt | 133 ++++++++++ .../app/views/ViewsApplication.kt | 35 +++ .../components/AnalyticsEventDisplayBinder.kt | 137 +++++++++++ .../components/ContentEntryViewBinder.kt | 97 ++++++++ .../NestedContentEntryViewBinder.kt | 54 +++++ .../app/views/support/TestTagging.kt | 32 +++ .../res/layout/activity_live_updates_test.xml | 153 ++++++++++++ .../src/main/res/layout/activity_main.xml | 63 +++++ .../res/layout/activity_navigation_test.xml | 80 ++++++ .../views/src/main/res/values/strings.xml | 4 + .../views/src/main/res/values/themes.xml | 6 + 16 files changed, 1281 insertions(+) create mode 100644 implementations/android-sdk/views/build.gradle.kts create mode 100644 implementations/android-sdk/views/src/main/AndroidManifest.xml create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt create mode 100644 implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt create mode 100644 implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml create mode 100644 implementations/android-sdk/views/src/main/res/layout/activity_main.xml create mode 100644 implementations/android-sdk/views/src/main/res/layout/activity_navigation_test.xml create mode 100644 implementations/android-sdk/views/src/main/res/values/strings.xml create mode 100644 implementations/android-sdk/views/src/main/res/values/themes.xml diff --git a/implementations/android-sdk/settings.gradle.kts b/implementations/android-sdk/settings.gradle.kts index 034c85ae..fd04f872 100644 --- a/implementations/android-sdk/settings.gradle.kts +++ b/implementations/android-sdk/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { rootProject.name = "OptimizationAndroidApp" include(":compose") +include(":views") include(":shared") include(":uitests") include(":ContentfulOptimization") diff --git a/implementations/android-sdk/views/build.gradle.kts b/implementations/android-sdk/views/build.gradle.kts new file mode 100644 index 00000000..0cf4d0a3 --- /dev/null +++ b/implementations/android-sdk/views/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.contentful.optimization.app.views" + compileSdk = 36 + + defaultConfig { + // Distinct applicationId from the Compose impl so UI Automator can target each independently + // by package name (per the APP_PACKAGE instrumentation argument set up in Step 5). + applicationId = "com.contentful.optimization.app.views" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +dependencies { + implementation(project(":ContentfulOptimization")) + implementation(project(":shared")) + + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("com.google.android.material:material:1.12.0") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/implementations/android-sdk/views/src/main/AndroidManifest.xml b/implementations/android-sdk/views/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e5c19bbf --- /dev/null +++ b/implementations/android-sdk/views/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt new file mode 100644 index 00000000..09c226cc --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/LiveUpdatesTestActivity.kt @@ -0,0 +1,229 @@ +package com.contentful.optimization.app.views + +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.ContentfulFetcher +import com.contentful.optimization.views.OptimizationManager +import com.contentful.optimization.views.OptimizedEntryView +import kotlinx.coroutines.launch + +/** + * View-based counterpart of `LiveUpdatesTestScreen`. + * + * Holds three [OptimizedEntryView] slots that exercise the three live-update modes + * (default / live / locked), plus the toggle controls and status labels the existing + * `LiveUpdatesTests` UI Automator suite asserts against. + */ +class LiveUpdatesTestActivity : AppCompatActivity() { + + private val client get() = OptimizationManager.client + + private lateinit var closeButton: Button + private lateinit var identifyButton: Button + private lateinit var resetButton: Button + private lateinit var toggleGlobalButton: Button + private lateinit var simulatePreviewButton: Button + + private lateinit var identifiedStatus: TextView + private lateinit var globalStatus: TextView + private lateinit var previewPanelStatus: TextView + + private lateinit var defaultSlot: OptimizedEntryView + private lateinit var liveSlot: OptimizedEntryView + private lateinit var lockedSlot: OptimizedEntryView + + private var globalLiveUpdates = false + private var isPreviewPanelSimulated = false + private var isIdentified = false + private var loadedEntry: Map? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_live_updates_test) + + bindViews() + applyTestTags() + wireButtons() + + loadEntry() + } + + private fun bindViews() { + closeButton = findViewById(R.id.close_live_updates_test_button) + identifyButton = findViewById(R.id.live_updates_identify_button) + resetButton = findViewById(R.id.live_updates_reset_button) + toggleGlobalButton = findViewById(R.id.toggle_global_live_updates_button) + simulatePreviewButton = findViewById(R.id.simulate_preview_panel_button) + + identifiedStatus = findViewById(R.id.identified_status) + globalStatus = findViewById(R.id.global_live_updates_status) + previewPanelStatus = findViewById(R.id.preview_panel_status) + + defaultSlot = findViewById(R.id.default_slot) + liveSlot = findViewById(R.id.live_slot) + lockedSlot = findViewById(R.id.locked_slot) + } + + private fun applyTestTags() { + findViewById(R.id.live_updates_scroll_view).setTestTag("live-updates-scroll-view") + closeButton.setTestTag("close-live-updates-test-button") + identifyButton.setTestTag("live-updates-identify-button") + resetButton.setTestTag("live-updates-reset-button") + toggleGlobalButton.setTestTag("toggle-global-live-updates-button") + simulatePreviewButton.setTestTag("simulate-preview-panel-button") + + identifiedStatus.setTestTag("identified-status") + globalStatus.setTestTag("global-live-updates-status") + previewPanelStatus.setTestTag("preview-panel-status") + + defaultSlot.accessibilityIdentifier = "default-personalization" + liveSlot.accessibilityIdentifier = "live-personalization" + lockedSlot.accessibilityIdentifier = "locked-personalization" + } + + private fun wireButtons() { + closeButton.setOnClickListener { finish() } + identifyButton.setOnClickListener { handleIdentify() } + resetButton.setOnClickListener { handleReset() } + toggleGlobalButton.setOnClickListener { toggleGlobalLiveUpdates() } + simulatePreviewButton.setOnClickListener { togglePreviewPanelSimulation() } + } + + private fun loadEntry() { + lifecycleScope.launch { + val entries = ContentfulFetcher.fetchEntries(listOf("2Z2WLOx07InSewC3LUB3eX")) + loadedEntry = entries.firstOrNull() ?: return@launch + attachSlotRenderers() + renderSlots() + } + } + + private fun attachSlotRenderers() { + defaultSlot.setContentRenderer { resolvedEntry -> + renderEntryDisplay(resolvedEntry, prefix = "default") + } + liveSlot.setContentRenderer { resolvedEntry -> + renderEntryDisplay(resolvedEntry, prefix = "live") + } + lockedSlot.setContentRenderer { resolvedEntry -> + renderEntryDisplay(resolvedEntry, prefix = "locked") + } + } + + private fun renderSlots() { + val entry = loadedEntry ?: return + + // Match the Compose semantics: default slot inherits the global setting, the live and + // locked slots pin explicitly. Passing `liveUpdates = null` to the default slot leaves + // it free to fall back to OptimizationManager.liveUpdates — but the global toggle here + // is a per-screen value, not a global SDK default. So we feed the screen's + // globalLiveUpdates into the default slot explicitly. + defaultSlot.liveUpdates = globalLiveUpdates + defaultSlot.setEntry(entry) + + liveSlot.liveUpdates = true + liveSlot.setEntry(entry) + + lockedSlot.liveUpdates = false + lockedSlot.setEntry(entry) + } + + private fun renderEntryDisplay(entry: Map, prefix: String): View { + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val text = fields?.get("text") as? String ?: "No content" + + @Suppress("UNCHECKED_CAST") + val sys = entry["sys"] as? Map + val entryId = sys?.get("id") as? String ?: "" + + val column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setTestTag("$prefix-container") + } + + column.addView( + TextView(this).apply { + this.text = text + contentDescription = text + setTestTag("$prefix-text") + }, + ) + val entryLabel = "Entry: $entryId" + column.addView( + TextView(this).apply { + this.text = entryLabel + contentDescription = entryLabel + setTestTag("$prefix-entry-id") + }, + ) + return column + } + + private fun handleIdentify() { + lifecycleScope.launch { + try { + client.identify( + userId = "charles", + traits = mapOf("identified" to true), + ) + } catch (_: Exception) { + } + isIdentified = true + applyIdentifiedUI() + } + } + + private fun handleReset() { + client.reset() + lifecycleScope.launch { + try { + client.page(mapOf("url" to "live-updates-test")) + } catch (_: Exception) { + } + isIdentified = false + applyIdentifiedUI() + } + } + + private fun applyIdentifiedUI() { + identifyButton.visibility = if (isIdentified) View.GONE else View.VISIBLE + resetButton.visibility = if (isIdentified) View.VISIBLE else View.GONE + val label = if (isIdentified) "Yes" else "No" + identifiedStatus.text = label + identifiedStatus.contentDescription = label + } + + private fun toggleGlobalLiveUpdates() { + globalLiveUpdates = !globalLiveUpdates + toggleGlobalButton.text = if (globalLiveUpdates) "Global: ON" else "Global: OFF" + val label = if (globalLiveUpdates) "ON" else "OFF" + globalStatus.text = label + globalStatus.contentDescription = label + // Restart the default slot so the new global setting takes effect mid-screen, mirroring + // the Compose `key(globalLiveUpdates, ...)` recomposition. + loadedEntry?.let { + defaultSlot.liveUpdates = globalLiveUpdates + defaultSlot.setEntry(it) + } + } + + private fun togglePreviewPanelSimulation() { + isPreviewPanelSimulated = !isPreviewPanelSimulated + simulatePreviewButton.text = + if (isPreviewPanelSimulated) "Close Preview Panel" else "Simulate Preview Panel" + val label = if (isPreviewPanelSimulated) "Open" else "Closed" + previewPanelStatus.text = label + previewPanelStatus.contentDescription = label + // Drive the SDK preview-panel flag so the OptimizedEntryView observation loop switches + // every slot — including the locked one — into live-update mode while open. Matches + // the Compose path's `client.setPreviewPanelOpen(...)` call. + client.setPreviewPanelOpen(isPreviewPanelSimulated) + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt new file mode 100644 index 00000000..49b7c44e --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/MainActivity.kt @@ -0,0 +1,181 @@ +package com.contentful.optimization.app.views + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.components.AnalyticsEventDisplayBinder +import com.contentful.optimization.app.views.components.ContentEntryViewBinder +import com.contentful.optimization.app.views.components.NestedContentEntryViewBinder +import com.contentful.optimization.app.views.components.isNestedContent +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.ContentfulFetcher +import com.contentful.optimization.shared.EventStore +import com.contentful.optimization.views.OptimizationManager +import kotlinx.coroutines.launch + +/** + * View-based counterpart of the Compose `MainScreen` + `MainActivity` pairing. + * + * Hosts the entry list, the action row (Identify/Reset, Navigation Test, Live Updates Test), and + * the analytics-events display. Mirrors the Compose path so the existing UI Automator tests + * (which look up `By.res("identify-button")`, `By.res("main-scroll-view")`, etc.) work unchanged. + */ +class MainActivity : AppCompatActivity() { + + private val client get() = OptimizationManager.client + + private lateinit var identifyButton: Button + private lateinit var resetButton: Button + private lateinit var navigationTestButton: Button + private lateinit var liveUpdatesTestButton: Button + private lateinit var scrollView: View + private lateinit var entriesContainer: LinearLayout + private lateinit var loadingIndicator: TextView + + private var analyticsDisplayBinder: AnalyticsEventDisplayBinder? = null + private var isIdentified: Boolean = false + private var entriesLoaded: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (intent.getBooleanExtra("reset", false)) { + getSharedPreferences("com.contentful.optimization", Context.MODE_PRIVATE) + .edit() + .clear() + .apply() + } + val simulateOffline = intent.getBooleanExtra("simulate_offline", false) + + setContentView(R.layout.activity_main) + + identifyButton = findViewById(R.id.identify_button) + resetButton = findViewById(R.id.reset_button) + navigationTestButton = findViewById(R.id.navigation_test_button) + liveUpdatesTestButton = findViewById(R.id.live_updates_test_button) + scrollView = findViewById(R.id.main_scroll_view) + entriesContainer = findViewById(R.id.entries_container) + loadingIndicator = findViewById(R.id.loading_indicator) + + // testTags must match the Compose `Modifier.testTag(...)` strings byte-for-byte so the + // shared UI Automator suite resolves them identically across both apps. + identifyButton.setTestTag("identify-button") + resetButton.setTestTag("reset-button") + navigationTestButton.setTestTag("navigation-test-button") + liveUpdatesTestButton.setTestTag("live-updates-test-button") + scrollView.setTestTag("main-scroll-view") + + identifyButton.setOnClickListener { handleIdentify() } + resetButton.setOnClickListener { handleReset() } + navigationTestButton.setOnClickListener { + startActivity(Intent(this, NavigationTestActivity::class.java)) + } + liveUpdatesTestButton.setOnClickListener { + startActivity(Intent(this, LiveUpdatesTestActivity::class.java)) + } + + // Mirrors MainScreen.LaunchedEffect(Unit): subscribe events, consent, page, optional offline. + EventStore.subscribe(client.events, lifecycleScope) + lifecycleScope.launch { + client.consent(true) + try { + client.page(mapOf("url" to "app")) + } catch (_: Exception) { + } + if (simulateOffline) { + client.setOnline(false) + } + } + + observeProfileForEntries() + } + + private fun observeProfileForEntries() { + var lastProfileFingerprint: String? = null + lifecycleScope.launch { + client.state.collect { state -> + val profile = state.profile + val identified = + @Suppress("UNCHECKED_CAST") + (profile?.get("traits") as? Map)?.get("identified") == true + updateIdentifiedUI(identified) + + if (profile == null) return@collect + val fingerprint = profile.toString() + if (fingerprint == lastProfileFingerprint) return@collect + lastProfileFingerprint = fingerprint + + val entries = ContentfulFetcher.fetchEntries(AppConfig.entryIds) + if (!entriesLoaded) { + client.subscribeToFlag("boolean") + entriesLoaded = true + } + renderEntries(entries) + } + } + } + + private fun updateIdentifiedUI(identified: Boolean) { + if (identified == isIdentified) return + isIdentified = identified + identifyButton.visibility = if (identified) View.GONE else View.VISIBLE + resetButton.visibility = if (identified) View.VISIBLE else View.GONE + } + + private fun handleIdentify() { + lifecycleScope.launch { + try { + client.identify( + userId = "charles", + traits = mapOf("identified" to true), + ) + } catch (_: Exception) { + } + } + } + + private fun handleReset() { + client.reset() + lifecycleScope.launch { + try { + client.page(mapOf("url" to "app")) + } catch (_: Exception) { + } + } + } + + private fun renderEntries(entries: List>) { + if (entries.isEmpty()) { + loadingIndicator.visibility = View.VISIBLE + return + } + loadingIndicator.visibility = View.GONE + entriesContainer.removeAllViews() + + entries.forEach { entry -> + val child = if (isNestedContent(entry)) { + NestedContentEntryViewBinder.create(this, entry) + } else { + ContentEntryViewBinder.create(this, entry) + } + entriesContainer.addView(child) + } + + // Analytics events display lives at the end of the scrollable content, matching the + // Compose Column layout that places AnalyticsEventDisplay after the entries. + val analyticsContainer = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + entriesContainer.addView(analyticsContainer) + val binder = AnalyticsEventDisplayBinder(this, analyticsContainer) + binder.attach(lifecycleScope) + analyticsDisplayBinder = binder + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt new file mode 100644 index 00000000..0d6eb1b2 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/NavigationTestActivity.kt @@ -0,0 +1,133 @@ +package com.contentful.optimization.app.views + +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.views.OptimizationManager +import com.contentful.optimization.views.ScreenTracker +import kotlinx.coroutines.launch + +/** + * View-based counterpart of `NavigationTestScreen`. Owns three view states (Home, ViewOne, + * ViewTwo) and emits the same `screen` events the Compose `ScreenTrackingEffect` calls do, so + * the existing screen-tracking UI Automator tests resolve identically against both impls. + */ +class NavigationTestActivity : AppCompatActivity() { + + private lateinit var closeButton: Button + private lateinit var homePane: View + private lateinit var viewOnePane: View + private lateinit var viewTwoPane: View + private lateinit var goToViewOneButton: Button + private lateinit var goToViewTwoButton: Button + + private lateinit var homeScreenEventLog: TextView + private lateinit var viewOneLastScreenEvent: TextView + private lateinit var viewOneScreenEventLog: TextView + private lateinit var viewTwoLastScreenEvent: TextView + private lateinit var viewTwoScreenEventLog: TextView + + private val screenLog = mutableListOf() + + private enum class State { HOME, VIEW_ONE, VIEW_TWO } + private var state: State = State.HOME + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_navigation_test) + + closeButton = findViewById(R.id.close_navigation_test_button) + homePane = findViewById(R.id.navigation_home) + viewOnePane = findViewById(R.id.navigation_view_one) + viewTwoPane = findViewById(R.id.navigation_view_two) + goToViewOneButton = findViewById(R.id.go_to_view_one_button) + goToViewTwoButton = findViewById(R.id.go_to_view_two_button) + homeScreenEventLog = findViewById(R.id.home_screen_event_log) + viewOneLastScreenEvent = findViewById(R.id.view_one_last_screen_event) + viewOneScreenEventLog = findViewById(R.id.view_one_screen_event_log) + viewTwoLastScreenEvent = findViewById(R.id.view_two_last_screen_event) + viewTwoScreenEventLog = findViewById(R.id.view_two_screen_event_log) + + closeButton.setTestTag("close-navigation-test-button") + goToViewOneButton.setTestTag("go-to-view-one-button") + goToViewTwoButton.setTestTag("go-to-view-two-button") + homePane.setTestTag("navigation-home") + viewOnePane.setTestTag("navigation-view-test-one") + viewTwoPane.setTestTag("navigation-view-test-two") + homeScreenEventLog.setTestTag("screen-event-log") + viewOneLastScreenEvent.setTestTag("last-screen-event") + viewOneScreenEventLog.setTestTag("screen-event-log") + viewTwoLastScreenEvent.setTestTag("last-screen-event") + viewTwoScreenEventLog.setTestTag("screen-event-log") + + closeButton.setOnClickListener { finish() } + goToViewOneButton.setOnClickListener { transitionTo(State.VIEW_ONE) } + goToViewTwoButton.setOnClickListener { transitionTo(State.VIEW_TWO) } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when (state) { + State.VIEW_TWO -> transitionTo(State.VIEW_ONE) + State.VIEW_ONE -> transitionTo(State.HOME) + State.HOME -> finish() + } + } + }, + ) + + observeScreenEvents() + renderPanes() + // Initial screen event matches `ScreenTrackingEffect("NavigationHome")` on the home destination. + ScreenTracker.trackScreen("NavigationHome") + } + + private fun observeScreenEvents() { + lifecycleScope.launch { + OptimizationManager.client.events.collect { event -> + val type = event["type"] as? String ?: return@collect + if (type != "screen" && type != "screenViewEvent") return@collect + val name = event["name"] as? String ?: return@collect + screenLog.add(name) + updateLogTextViews() + } + } + } + + private fun transitionTo(target: State) { + state = target + renderPanes() + when (target) { + State.HOME -> ScreenTracker.trackScreen("NavigationHome") + State.VIEW_ONE -> ScreenTracker.trackScreen("NavigationViewOne") + State.VIEW_TWO -> ScreenTracker.trackScreen("NavigationViewTwo") + } + } + + private fun renderPanes() { + homePane.visibility = if (state == State.HOME) View.VISIBLE else View.GONE + viewOnePane.visibility = if (state == State.VIEW_ONE) View.VISIBLE else View.GONE + viewTwoPane.visibility = if (state == State.VIEW_TWO) View.VISIBLE else View.GONE + } + + private fun updateLogTextViews() { + val joined = screenLog.joinToString(",") + homeScreenEventLog.text = joined + homeScreenEventLog.contentDescription = joined + viewOneScreenEventLog.text = joined + viewOneScreenEventLog.contentDescription = joined + viewTwoScreenEventLog.text = joined + viewTwoScreenEventLog.contentDescription = joined + val last = screenLog.lastOrNull() ?: "" + viewOneLastScreenEvent.text = last + viewOneLastScreenEvent.contentDescription = last + viewTwoLastScreenEvent.text = last + viewTwoLastScreenEvent.contentDescription = last + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt new file mode 100644 index 00000000..1a684b91 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/ViewsApplication.kt @@ -0,0 +1,35 @@ +package com.contentful.optimization.app.views + +import android.app.Application +import com.contentful.optimization.core.OptimizationConfig +import com.contentful.optimization.preview.PreviewPanelConfig +import com.contentful.optimization.shared.AppConfig +import com.contentful.optimization.shared.MockPreviewContentfulClient +import com.contentful.optimization.views.OptimizationManager + +/** + * View-based equivalent of `OptimizationRoot` in the Compose reference impl. Boots the SDK once + * per process from [onCreate]; activities read [com.contentful.optimization.views.OptimizationManager.client] + * to get the live [com.contentful.optimization.core.OptimizationClient]. + */ +class ViewsApplication : Application() { + + override fun onCreate() { + super.onCreate() + OptimizationManager.initialize( + context = this, + config = OptimizationConfig( + clientId = AppConfig.clientId, + environment = AppConfig.environment, + experienceBaseUrl = AppConfig.experienceBaseUrl, + insightsBaseUrl = AppConfig.insightsBaseUrl, + debug = true, + ), + trackViews = true, + trackTaps = true, + previewPanel = PreviewPanelConfig( + contentfulClient = MockPreviewContentfulClient(), + ), + ) + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt new file mode 100644 index 00000000..e9032525 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/AnalyticsEventDisplayBinder.kt @@ -0,0 +1,137 @@ +package com.contentful.optimization.app.views.components + +import android.content.Context +import android.graphics.Typeface +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.EventStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * View-based counterpart of `AnalyticsEventDisplay` from the Compose reference impl. + * + * Renders the analytics events list and per-component statistics into a [LinearLayout]. The + * subscriptions to [EventStore.events] and [EventStore.componentStats] survive for the lifetime + * of the supplied [CoroutineScope] passed to [attach]. + */ +class AnalyticsEventDisplayBinder( + private val context: Context, + private val container: LinearLayout, +) { + private val headerLabel = TextView(context).apply { + text = "Analytics Events" + setTypeface(typeface, Typeface.BOLD) + } + private val eventsCount = TextView(context).apply { + setTestTag("events-count") + } + private val emptyMessage = TextView(context).apply { + text = "No events tracked yet" + setTestTag("no-events-message") + contentDescription = "No events tracked yet" + } + private val eventsList = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + private val statsList = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + init { + val padding = context.dp(16) + container.apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + setTestTag("analytics-events-container") + } + container.addView(headerLabel) + container.addView(eventsCount) + container.addView(emptyMessage) + container.addView(eventsList) + container.addView(statsList) + } + + fun attach(scope: CoroutineScope) { + scope.launch { + // Combine the two flows so the UI only updates once per state change tuple, matching + // Compose's `collectAsState` semantics where both `events` and `componentStats` + // recompose the same composable. + EventStore.events.combine(EventStore.componentStats) { events, stats -> events to stats } + .collect { (events, stats) -> render(events, stats) } + } + } + + private fun render( + events: List, + stats: Map, + ) { + val countText = "Events: ${events.size}" + eventsCount.text = countText + eventsCount.contentDescription = countText + + emptyMessage.visibility = if (events.isEmpty()) View.VISIBLE else View.GONE + + eventsList.removeAllViews() + val nonComponent = events.filter { it.type != "component" } + nonComponent.forEachIndexed { index, event -> + val testId = if (event.componentId != null) { + "event-${event.type}-${event.componentId}" + } else { + "event-${event.type}-$index" + } + val desc = buildString { + append(event.type) + event.componentId?.let { append(" - Component: $it") } + event.viewDurationMs?.let { append(" - ${it}ms") } + } + val row = TextView(context).apply { + text = desc + contentDescription = desc + setTestTag(testId) + } + eventsList.addView(row) + } + + statsList.removeAllViews() + stats.keys.sorted().forEach { cid -> + val s = stats[cid] ?: return@forEach + val block = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setTestTag("component-stats-$cid") + } + + val countLine = "Count: ${s.count}" + block.addView( + TextView(context).apply { + text = countLine + contentDescription = countLine + setTestTag("event-count-$cid") + }, + ) + + val durationLine = "Duration: ${s.latestViewDurationMs?.toString() ?: "N/A"}" + block.addView( + TextView(context).apply { + text = durationLine + contentDescription = durationLine + setTestTag("event-duration-$cid") + }, + ) + + val viewIdLine = "ViewId: ${s.latestViewId ?: "N/A"}" + block.addView( + TextView(context).apply { + text = viewIdLine + contentDescription = viewIdLine + setTestTag("event-view-id-$cid") + }, + ) + + statsList.addView(block) + } + } +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt new file mode 100644 index 00000000..f09e0f02 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/ContentEntryViewBinder.kt @@ -0,0 +1,97 @@ +package com.contentful.optimization.app.views.components + +import android.content.Context +import android.util.TypedValue +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.contentful.optimization.app.views.support.setTestTag +import com.contentful.optimization.shared.RichText +import com.contentful.optimization.views.OptimizationManager +import com.contentful.optimization.views.OptimizedEntryView +import kotlinx.coroutines.launch + +/** + * Builds an [OptimizedEntryView] wrapping a single entry, mirroring `ContentEntryView` from the + * Compose reference impl: outer wrapper carries `content-entry-$entryId` as its accessibility + * identifier, inner column carries `entry-text-$entryId` as a test tag and a content description + * combining the resolved text with `[Entry: $entryId]` so the existing UI Automator helpers + * (which match `By.descContains("[Entry: $entryId]")`) work unchanged. + */ +object ContentEntryViewBinder { + + fun create(context: Context, entry: Map): View { + val entryId = entryId(entry) + + val view = OptimizedEntryView(context).apply { + accessibilityIdentifier = "content-entry-$entryId" + trackTaps = true + } + view.setContentRenderer { resolvedEntry -> + renderEntryColumn(context, resolvedEntry, entryId) + } + view.setEntry(entry) + return view + } + + internal fun renderEntryColumn( + context: Context, + resolvedEntry: Map, + entryId: String, + ): View { + @Suppress("UNCHECKED_CAST") + val fields = resolvedEntry["fields"] as? Map + val padding = context.dp(16) + + val textView = TextView(context).apply { text = "No content" } + val idLabel = TextView(context).apply { text = "[Entry: $entryId]" } + + val column = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + addView(textView) + addView(idLabel) + setTestTag("entry-text-$entryId") + contentDescription = "No content [Entry: $entryId]" + } + + // Resolve rich text after the view is attached so we can hook into its lifecycle. The + // suspending RichText.resolveText needs the client to be initialized; if the view is + // detached before the resolution finishes we drop the result silently. + column.addOnAttachStateChangeListener( + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + val owner = v.findViewTreeLifecycleOwner() ?: return + owner.lifecycleScope.launch { + val resolved = RichText.resolveText( + fields?.get("text"), + OptimizationManager.client, + ) + textView.text = resolved + column.contentDescription = "$resolved [Entry: $entryId]" + } + } + + override fun onViewDetachedFromWindow(v: View) { + } + }, + ) + + return column + } +} + +internal fun Context.dp(value: Int): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value.toFloat(), + resources.displayMetrics, + ).toInt() + +@Suppress("UNCHECKED_CAST") +internal fun entryId(entry: Map): String { + val sys = entry["sys"] as? Map + return sys?.get("id") as? String ?: "" +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt new file mode 100644 index 00000000..ef4aa921 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/components/NestedContentEntryViewBinder.kt @@ -0,0 +1,54 @@ +package com.contentful.optimization.app.views.components + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import com.contentful.optimization.views.OptimizedEntryView + +/** + * Renders a `nestedContent` entry tree: an outer wrapper plus a recursive list of nested entries + * underneath it. Mirrors `NestedContentEntryView` from the Compose reference impl. + */ +object NestedContentEntryViewBinder { + + fun create(context: Context, entry: Map): View { + val entryId = entryId(entry) + val column = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + } + + val wrapper = OptimizedEntryView(context).apply { + accessibilityIdentifier = "content-entry-$entryId" + } + wrapper.setContentRenderer { resolvedEntry -> + ContentEntryViewBinder.renderEntryColumn(context, resolvedEntry, entryId) + } + wrapper.setEntry(entry) + column.addView(wrapper) + + @Suppress("UNCHECKED_CAST") + val fields = entry["fields"] as? Map + val nested = (fields?.get("nested") as? List<*>).orEmpty() + @Suppress("UNCHECKED_CAST") + nested + .filterIsInstance>() + .filter { + val sys = it["sys"] as? Map + sys?.get("id") != null + } + .forEach { nestedEntry -> + column.addView(create(context, nestedEntry)) + } + + return column + } +} + +@Suppress("UNCHECKED_CAST") +internal fun isNestedContent(entry: Map): Boolean { + val sys = entry["sys"] as? Map ?: return false + val contentType = sys["contentType"] as? Map ?: return false + val innerSys = contentType["sys"] as? Map ?: return false + val id = innerSys["id"] as? String ?: return false + return id == "nestedContent" +} diff --git a/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt new file mode 100644 index 00000000..539384f2 --- /dev/null +++ b/implementations/android-sdk/views/src/main/kotlin/com/contentful/optimization/app/views/support/TestTagging.kt @@ -0,0 +1,32 @@ +package com.contentful.optimization.app.views.support + +import android.view.View +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat + +/** + * Expose [testTag] as this View's `viewIdResourceName` for UI Automator. Matches the Compose + * reference impl's `Modifier.testTag("foo-bar")` + `testTagsAsResourceId = true` setup, so a + * single test selector (`By.res("foo-bar")`) finds the matching element in both apps. + * + * Android `android:id` resource names cannot contain hyphens, so we cannot reuse the kebab-case + * test-tag strings as XML ids. The standard accessibility plumbing — [AccessibilityNodeInfoCompat.setViewIdResourceName] — + * lets us still report any string as the view-id resource name to accessibility consumers, + * which is what UI Automator queries through `By.res`. + */ +fun View.setTestTag(testTag: String) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + ViewCompat.setAccessibilityDelegate( + this, + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.setViewIdResourceName(testTag) + } + }, + ) +} diff --git a/implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml b/implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml new file mode 100644 index 00000000..06206578 --- /dev/null +++ b/implementations/android-sdk/views/src/main/res/layout/activity_live_updates_test.xml @@ -0,0 +1,153 @@ + + + + + + + + + +