diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..08c433c
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,238 @@
+name: E2E
+
+# Maestro smoke e2e for the React Native wrapper, on the example app built from the local wrapper
+# source (see TESTING.md):
+# native-versions pinned native SDK versions vs the latest published (report-only for now)
+# android ubuntu + KVM emulator (API 35)
+# ios self-hosted Mac simulator, serialised by e2e/sim-lock.sh
+#
+# Secrets: LINKTRAIL_KEY (workspace SDK key) and E2E_TOKEN (X-E2E-Token for the private backend
+# check). Without both, only the offline flows run and the real ones are skipped with a notice.
+# Pull requests from forks get no secrets, and never run on the self-hosted Mac.
+# Variables (optional):
+# NATIVE_VERSION_STRICT 'false' to only warn on native SDK drift (default: fail)
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: e2e-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ native-versions:
+ name: Native SDK versions
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Compare pinned native SDKs with the latest published
+ env:
+ STRICT: ${{ vars.NATIVE_VERSION_STRICT == 'false' && '0' || '1' }}
+ run: bash e2e/check-native-versions.sh
+
+ android:
+ name: Android e2e (Maestro)
+ runs-on: ubuntu-latest
+ timeout-minutes: 75
+ env:
+ API_LEVEL: 35
+ HAVE_SECRETS: ${{ secrets.LINKTRAIL_KEY != '' && secrets.E2E_TOKEN != '' }}
+ steps:
+ - name: Secrets notice
+ if: env.HAVE_SECRETS != 'true'
+ run: echo "::notice title=e2e::LINKTRAIL_KEY / E2E_TOKEN not available — only the offline flows run."
+
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ # The API 35 AVD needs ~7.4 GB; drop toolchains this job never uses (the NDK stays: the
+ # New Architecture app build needs it).
+ - name: Free disk space
+ run: |
+ sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/share/boost \
+ /opt/hostedtoolcache/CodeQL /usr/local/share/powershell /usr/share/swift /opt/az \
+ /usr/local/share/chromium
+ sudo docker image prune --all --force >/dev/null 2>&1 || true
+ df -h /
+
+ - name: Install dependencies
+ run: |
+ npm ci --no-audit --no-fund
+ cd example && npm install --no-audit --no-fund
+
+ - name: Build the example APK
+ env:
+ LINKTRAIL_KEY: ${{ secrets.LINKTRAIL_KEY }}
+ E2E_TOKEN: ${{ secrets.E2E_TOKEN }}
+ BUILD_ONLY: '1'
+ run: bash e2e/android.sh
+
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Install Maestro
+ run: |
+ curl -Ls https://get.maestro.mobile.dev | bash
+ echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
+
+ - name: AVD cache
+ uses: actions/cache@v4
+ id: avd-cache
+ with:
+ path: |
+ ~/.android/avd/*
+ ~/.android/adb*
+ key: avd-${{ env.API_LEVEL }}-google_apis-x86_64-v1
+
+ - name: Create AVD snapshot
+ if: steps.avd-cache.outputs.cache-hit != 'true'
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: ${{ env.API_LEVEL }}
+ target: google_apis
+ arch: x86_64
+ cores: 4
+ ram-size: 4096M
+ disk-size: 6000M
+ force-avd-creation: false
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ disable-animations: false
+ emulator-boot-timeout: 900
+ script: echo "Generated AVD snapshot for caching."
+
+ - name: E2E — Maestro
+ timeout-minutes: 30
+ env:
+ LINKTRAIL_KEY: ${{ secrets.LINKTRAIL_KEY }}
+ E2E_TOKEN: ${{ secrets.E2E_TOKEN }}
+ SKIP_BUILD: '1'
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: ${{ env.API_LEVEL }}
+ target: google_apis
+ arch: x86_64
+ cores: 4
+ ram-size: 4096M
+ disk-size: 6000M
+ force-avd-creation: false
+ emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim -camera-back none
+ disable-animations: true
+ emulator-boot-timeout: 900
+ script: sleep 45 && bash e2e/android.sh
+
+ - name: Upload Maestro debug output
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: maestro-android
+ path: ~/.maestro/tests/
+ retention-days: 7
+ if-no-files-found: ignore
+
+ ios:
+ name: iOS e2e (Maestro)
+ # GitHub-hosted macOS: free and unlimited on this public repo. Same-repo PRs and
+ # manual runs only, because the job uses the repo's e2e secrets.
+ if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: macos-latest
+ timeout-minutes: 120
+ env:
+ LANG: en_US.UTF-8
+ LC_ALL: en_US.UTF-8
+ HAVE_SECRETS: ${{ secrets.LINKTRAIL_KEY != '' && secrets.E2E_TOKEN != '' }}
+ steps:
+ - name: Secrets notice
+ if: env.HAVE_SECRETS != 'true'
+ run: echo "::notice title=e2e::LINKTRAIL_KEY / E2E_TOKEN not available — only the offline flows run."
+
+ - uses: actions/checkout@v4
+
+ - name: Install Maestro
+ run: |
+ curl -Ls https://get.maestro.mobile.dev | bash
+ echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ # Pin Ruby: the image's Homebrew Ruby 3.4 drops kconv, which CocoaPods needs.
+ - name: Set up Ruby + CocoaPods (bundler)
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3'
+ bundler-cache: true
+ working-directory: example
+
+ - name: Install dependencies
+ run: |
+ npm ci --no-audit --no-fund
+ cd example && npm install --no-audit --no-fund && bundle install
+
+ # The build runs outside the lock; only the simulator part is serialised.
+ - name: Build the example app
+ env:
+ LINKTRAIL_KEY: ${{ secrets.LINKTRAIL_KEY }}
+ E2E_TOKEN: ${{ secrets.E2E_TOKEN }}
+ run: |
+ set -euo pipefail
+ BUILD_ONLY=1 bash e2e/ios.sh
+
+ - name: Wait for the simulator lock
+ run: |
+ set -euo pipefail
+ pid="$(bash e2e/sim-lock.sh acquire)"
+ echo "E2E_LOCK_PID=$pid" >> "$GITHUB_ENV"
+
+ - name: Boot a simulator
+ run: |
+ set -euo pipefail
+ UDID="$(xcrun simctl list devices available | grep 'iPhone ' \
+ | grep -oiE '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}' | head -1)"
+ [ -n "$UDID" ] || { echo "::error::no iPhone simulator available"; exit 1; }
+ echo "SIM_UDID=$UDID" >> "$GITHUB_ENV"
+ xcrun simctl boot "$UDID" >/dev/null 2>&1 || true
+ xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
+
+ - name: E2E — Maestro
+ timeout-minutes: 45
+ env:
+ LINKTRAIL_KEY: ${{ secrets.LINKTRAIL_KEY }}
+ E2E_TOKEN: ${{ secrets.E2E_TOKEN }}
+ SKIP_BUILD: '1'
+ run: DEVICE="$SIM_UDID" bash e2e/ios.sh
+
+ - name: Upload Maestro debug output
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: maestro-ios
+ path: ~/.maestro/tests/
+ retention-days: 7
+ if-no-files-found: ignore
+
+ - name: Shut the simulator down
+ if: always()
+ run: '[ -n "${SIM_UDID:-}" ] && xcrun simctl shutdown "$SIM_UDID" || true'
+
+ - name: Release the simulator lock
+ if: always()
+ run: bash e2e/sim-lock.sh release "${E2E_LOCK_PID:-}"
diff --git a/.gitignore b/.gitignore
index 1ead3f0..ab9b8e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ coverage/
# Android upload/release signing keys — never commit
*.jks
example/android/upload-keystore.jks
+example/ios/build-e2e/
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..1b528ad
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,87 @@
+# Testing
+
+## Library checks
+
+`npm run lint`, `npm run typecheck` and `npm run prepare` (the build). CI runs them in
+`.github/workflows/ci.yml`, plus a JS bundle and an Android build of the example app;
+`.github/workflows/ios.yml` builds the example for the iOS simulator.
+
+## Smoke e2e (Maestro)
+
+`.github/workflows/e2e.yml` runs a small Maestro suite on the example app (`example/`), which uses
+the wrapper from source (`"linktrail-react-native": "file:.."`) and the native SDKs it pins
+(`LinkTrailSDK` from CocoaPods, `io.linktrail:sdk` from Maven Central). It runs on pull requests
+and on manual dispatch.
+
+App ids: Android `io.linktrail.demo.store` (the `applicationId`; the Kotlin namespace stays
+`com.kickflip`), iOS `io.linktrail.kickflipdemo`. Both must be in the backend's
+`E2E_APP_IDENTIFIERS` for the verify check.
+
+| Flow | Android | iOS | Needs secrets |
+| --- | --- | --- | --- |
+| `consent_prompt` | consent gate shows; Deny persists across a relaunch | gate shows; Skip stays undecided; a grant persists | no |
+| `deferred_allow` | real click, fake Play referrer, Allow | Safari stages the click token on the clipboard, Allow + Paste | yes |
+| `deferred_deny` | the same with Deny | the same with Deny | yes |
+| `reengagement` | https App Link while installed | `kickflip://e2e/open?url=` → `handleDeepLink` | yes |
+| `track_event` | `trackEvent("e2e_purchase", { value: 9.99, currency: "USD" })` | the same | yes |
+
+The deferred and re-engagement flows assert that the JS `onLink` listener fired **exactly once**,
+with the linked `path` (`/products/aj1`), `source` and `customData` (`voucher=SUMMER25`). The
+values are read off an on-screen diagnostics panel (testIDs `e2e-link-count`, `e2e-link-path`,
+`e2e-link-source`, `e2e-link-custom-data`, `e2e-consent`, `e2e-attribution`, `e2e-event`) that
+only e2e builds show.
+
+After each deferred flow the runner asks the backend what it recorded for that flow's own click
+token (`GET /v1/e2e/clicks/`, header `X-E2E-Token`):
+
+- allow: `installRecorded`, `attributed`, `matchType == "click_token"`, `installCount == 1`;
+- deny: `installRecorded == false`, `installCount == 0`, held for the whole polling window.
+
+### Secrets and variables
+
+| Name | Kind | Purpose |
+| --- | --- | --- |
+| `LINKTRAIL_KEY` | secret | Workspace SDK key, bundled into the e2e build only |
+| `E2E_TOKEN` | secret | `X-E2E-Token` for the backend check |
+| `NATIVE_VERSION_STRICT` | variable | `false` to only warn on native SDK drift (the CI check fails on drift by default) |
+
+Without both secrets (for example on a pull request from a fork) only `consent_prompt` runs and the
+other flows are skipped with a notice. Fork pull requests never run on the self-hosted Mac.
+
+### How the key reaches the build
+
+The runners write the key to `example/e2e.config.local.json` (gitignored, removed when the run
+ends). `example/metro.config.js` resolves the `kickflip-e2e-config` module to that file when it
+exists and to `example/src/e2e/config.default.json` (e2e off, placeholder key) otherwise. The key is
+never passed on a command line or printed; curl reads the secret headers from stdin.
+
+The Android build bundles the JS into the **debug** APK (`-PbundleDebugJs`) so it runs without
+Metro and stays debuggable, which the SDK's fake referrer requires. iOS builds **Release** for the
+simulator, which bundles the JS.
+
+### Run locally
+
+```sh
+npm ci && (cd example && npm install && bundle install)
+
+# Android: a booted emulator (e.g. LinkTrail_API35)
+bash e2e/android.sh # all flows; real ones only with the secrets set
+bash e2e/android.sh consent_prompt # one flow
+
+# iOS: a booted simulator
+bash e2e/ios.sh
+DEVICE= bash e2e/ios.sh consent_prompt
+```
+
+`LINKTRAIL_KEY=… E2E_TOKEN=… bash e2e/android.sh` runs the real flows. They make real clicks on
+production links, so prefer CI for them. `SKIP_BUILD=1` reuses the last build.
+
+On a Mac shared with other simulator jobs, take the lock first:
+`pid="$(bash e2e/sim-lock.sh acquire)"; …; bash e2e/sim-lock.sh release "$pid"`.
+
+### Native SDK versions
+
+`e2e/check-native-versions.sh` compares the versions the wrapper pins
+(`LinktrailReactNative.podspec`, `android/build.gradle`) with the latest on CocoaPods trunk and
+Maven Central. Locally it only warns unless `STRICT=1`; in CI it fails on drift (set the
+`NATIVE_VERSION_STRICT` variable to `false` to only warn).
diff --git a/e2e/android.sh b/e2e/android.sh
new file mode 100755
index 0000000..20f6cc1
--- /dev/null
+++ b/e2e/android.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+#
+# Android smoke e2e for the React Native wrapper: builds the example app (wrapper from source,
+# native io.linktrail:sdk from Maven) with the JS bundled in, then runs the Maestro flows in
+# e2e/flows/android on a booted emulator.
+#
+# consent_prompt offline: the consent gate shows, Deny persists across a relaunch.
+# deferred_allow real click → fake Play referrer → Allow → onLink once (path + customData),
+# then the backend recorded 1 attributed click_token install.
+# deferred_deny the same with Deny → the backend recorded nothing.
+# reengagement https App Link while installed → onLink (source reengagement).
+# track_event trackEvent with a value → resolved by the backend.
+#
+# The deferred flows stage the click through the SDK's debug-only fake referrer
+# (adb shell setprop debug.linktrail.referrer "lt_click="), which needs a debuggable app
+# (io.linktrail:sdk 0.1.0 or later).
+#
+# Usage: bash e2e/android.sh [flow…]
+# Env: LINKTRAIL_KEY, E2E_TOKEN (see e2e/lib.sh), RETRIES (3), CLICK_WAIT (60),
+# SKIP_BUILD=1 to reuse the last APK, BUILD_ONLY=1 to only
+# build it (CI builds before booting the emulator).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+# shellcheck source=e2e/lib.sh
+source "$ROOT/e2e/lib.sh"
+
+APP_ID="io.linktrail.demo.store"
+FLOW_DIR="$ROOT/e2e/flows/android"
+APK="$ROOT/example/android/app/build/outputs/apk/debug/app-debug.apk"
+CLICK_WAIT="${CLICK_WAIT:-60}"
+# Android Chrome UA: the redirect sends Android clicks to the Play Store with &referrer=lt_click=.
+ANDROID_UA="Mozilla/5.0 (Linux; Android 15; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"
+
+reset_props() { adb shell setprop debug.linktrail.referrer "''" >/dev/null 2>&1 || true; }
+cleanup() { remove_config; [ "${BUILD_ONLY:-0}" = 1 ] || reset_props; }
+trap cleanup EXIT
+
+if [ "${SKIP_BUILD:-0}" != 1 ]; then
+ echo "==> Building the example (JS bundled into the debug APK)"
+ write_config
+ (cd "$ROOT/example/android" && ./gradlew :app:assembleDebug -PbundleDebugJs -q)
+ remove_config
+fi
+[ -f "$APK" ] || { echo "ERROR: $APK not found"; exit 1; }
+[ "${BUILD_ONLY:-0}" = 1 ] && { echo "==> Built $APK"; exit 0; }
+
+command -v maestro >/dev/null 2>&1 || { echo "ERROR: maestro not found (curl -Ls https://get.maestro.mobile.dev | bash)"; exit 1; }
+command -v adb >/dev/null 2>&1 || { echo "ERROR: adb not found (Android platform-tools)"; exit 1; }
+adb get-state >/dev/null 2>&1 || { echo "ERROR: no booted emulator/device (adb devices)"; exit 1; }
+
+if [ "$#" -gt 0 ]; then
+ FLOWS=(); for f in "$@"; do FLOWS+=("$(basename "${f%.yaml}")"); done
+else
+ FLOWS=(consent_prompt deferred_allow deferred_deny reengagement track_event)
+fi
+
+reset_maestro() {
+ adb forward --remove-all >/dev/null 2>&1 || true
+ adb uninstall dev.mobile.maestro >/dev/null 2>&1 || true
+ adb uninstall dev.mobile.maestro.test >/dev/null 2>&1 || true
+}
+
+# Slow CI emulators raise "System UI isn't responding" over the app; hide ANR dialogs.
+settle_device() {
+ adb shell settings put global hide_error_dialogs 1 >/dev/null 2>&1 || true
+ adb shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS >/dev/null 2>&1 || true
+}
+
+# real_click: a real click with an Android UA; prints the lt_click token from the Play redirect.
+real_click() {
+ local redirect token
+ redirect="$(curl -sS -o /dev/null -A "$ANDROID_UA" -w '%{redirect_url}' "$LINK_URL")" || return 1
+ token="$(printf '%s' "$redirect" | grep -oE 'lt_click(%3D|=)[0-9a-f]{32}' | head -1 | grep -oE '[0-9a-f]{32}$' || true)"
+ [ -n "$token" ] || { echo " no lt_click token in the redirect for $LINK_URL" >&2; return 1; }
+ printf '%s' "$token"
+}
+
+# wait_click : until GET /v1/events/click/ answers 200 for this workspace's key.
+wait_click() {
+ local code="" waited=0
+ while [ "$waited" -lt "$CLICK_WAIT" ]; do
+ code="$(printf 'header = "X-API-Key: %s"\n' "$LINKTRAIL_KEY" \
+ | curl -sS -o /dev/null -w '%{http_code}' --max-time 10 --config - "$BACKEND_URL/v1/events/click/$1" 2>/dev/null || true)"
+ [ "$code" = 200 ] && return 0
+ sleep 2; waited=$((waited + 2))
+ done
+ echo " click not visible to this key after ${CLICK_WAIT}s (last status: ${code:-none})" >&2
+ return 1
+}
+
+# run_once : fresh install, stage what the flow needs, run it, check the backend.
+run_once() {
+ local name="$1" token=""
+ adb uninstall "$APP_ID" >/dev/null 2>&1 || true
+ reset_props
+ if [[ "$name" == deferred_* ]]; then
+ token="$(real_click)" || return 1
+ echo " click ${token:0:8}… made; waiting for the backend"
+ wait_click "$token" || return 1
+ adb shell setprop debug.linktrail.referrer "lt_click=$token"
+ fi
+ adb install -t "$APK" >/dev/null
+ if [ "$name" = reengagement ]; then
+ # Approve the link host for the app so the https link opens it, not the browser (Android 12+).
+ adb shell pm set-app-links-user-selection --user 0 --package "$APP_ID" true "$LINK_HOST" >/dev/null 2>&1 || true
+ fi
+ settle_device
+ maestro test -e LINK_URL="$LINK_URL" "$FLOW_DIR/$name.yaml" || return 1
+ verify_click "$name" "$token"
+}
+
+fail=0
+for name in "${FLOWS[@]}"; do
+ [ -f "$FLOW_DIR/$name.yaml" ] || { echo "ERROR: no flow $FLOW_DIR/$name.yaml"; fail=1; continue; }
+ if flow_needs_backend "$name" && [ "$HAVE_SECRETS" != 1 ]; then
+ notice "android/$name skipped: LINKTRAIL_KEY and E2E_TOKEN are not set"; continue
+ fi
+ ok=0
+ for attempt in $(seq 1 "$RETRIES"); do
+ echo "==> android/$name (attempt $attempt/$RETRIES)"
+ if run_once "$name"; then ok=1; break; fi
+ echo " attempt failed; resetting the Maestro driver"
+ reset_maestro; sleep 3
+ done
+ if [ "$ok" = 1 ]; then echo "OK: android/$name"; else echo "FAILED: android/$name"; fail=1; fi
+done
+
+[ "$fail" = 0 ] || { echo "==> Some Android e2e flows failed"; exit 1; }
+echo "==> All Android e2e flows passed"
diff --git a/e2e/check-native-versions.sh b/e2e/check-native-versions.sh
new file mode 100755
index 0000000..d83b8d9
--- /dev/null
+++ b/e2e/check-native-versions.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+#
+# Compares the native SDK versions this wrapper pins with the latest published ones:
+# iOS LinktrailReactNative.podspec s.dependency "LinkTrailSDK", "~> X" vs CocoaPods trunk
+# Android android/build.gradle io.linktrail:sdk:X vs Maven Central
+#
+# Report-only by default: a drift prints a warning (a GitHub annotation in CI) and exits 0.
+# STRICT=1 makes a drift (or a lookup failure) exit 1.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+STRICT="${STRICT:-0}"
+
+warn() {
+ if [ "${GITHUB_ACTIONS:-}" = true ]; then echo "::warning title=native SDK version::$*"; else echo "WARN: $*"; fi
+}
+
+ios_pin="$(sed -nE 's/.*s\.dependency[[:space:]]+"LinkTrailSDK",[[:space:]]*"[~>=[:space:]]*([0-9][0-9.]*)".*/\1/p' "$ROOT/LinktrailReactNative.podspec" | head -1)"
+android_pin="$(sed -nE 's/.*"io\.linktrail:sdk:([0-9][0-9.]*)".*/\1/p' "$ROOT/android/build.gradle" | head -1)"
+
+ios_latest="$(curl -fsS --max-time 20 https://trunk.cocoapods.org/api/v1/pods/LinkTrailSDK \
+ | python3 -I -c 'import json, sys; print("\n".join(v["name"] for v in json.load(sys.stdin)["versions"]))' \
+ | grep -E '^[0-9]+(\.[0-9]+)*$' | sort -V | tail -1 || true)"
+android_latest="$(curl -fsS --max-time 20 https://repo1.maven.org/maven2/io/linktrail/sdk/maven-metadata.xml \
+ | sed -nE 's#.*([^<]+).*#\1#p' | head -1 || true)"
+
+drift=0
+check() {
+ local label="$1" pin="$2" latest="$3" source="$4"
+ if [ -z "$pin" ]; then warn "$label: could not read the pinned version"; drift=1; return; fi
+ if [ -z "$latest" ]; then warn "$label: could not read the latest version from $source"; drift=1; return; fi
+ if [ "$pin" = "$latest" ]; then
+ echo "OK: $label pinned $pin = latest on $source"
+ else
+ warn "$label pinned $pin, latest on $source is $latest"
+ drift=1
+ fi
+}
+check "iOS LinkTrailSDK" "$ios_pin" "$ios_latest" "CocoaPods trunk"
+check "Android io.linktrail:sdk" "$android_pin" "$android_latest" "Maven Central"
+
+if [ "$drift" = 1 ] && [ "$STRICT" = 1 ]; then
+ echo "ERROR: native SDK versions differ from the latest published (STRICT=1)"; exit 1
+fi
+[ "$drift" = 1 ] && echo "Report-only: set STRICT=1 to fail on drift."
+exit 0
diff --git a/e2e/flows/android/consent_prompt.yaml b/e2e/flows/android/consent_prompt.yaml
new file mode 100644
index 0000000..4e1199c
--- /dev/null
+++ b/e2e/flows/android/consent_prompt.yaml
@@ -0,0 +1,14 @@
+# Offline (no key, no backend): the consent gate shows on first launch, Deny is applied and
+# persisted, and a relaunch does not ask again. onLink never fires.
+appId: io.linktrail.demo.store
+---
+- launchApp: { clearState: true }
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-deny" }
+- tapOn: { id: "consent-continue" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: denied" }, timeout: 10000 }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 0" }
+- stopApp
+- launchApp
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: denied" }, timeout: 30000 }
+- assertNotVisible: "Allow tracking?"
diff --git a/e2e/flows/android/deferred_allow.yaml b/e2e/flows/android/deferred_allow.yaml
new file mode 100644
index 0000000..bb5e558
--- /dev/null
+++ b/e2e/flows/android/deferred_allow.yaml
@@ -0,0 +1,16 @@
+# Deferred · consent ALLOW (real). Before this flow e2e/android.sh makes a real click on $LINK_URL,
+# stages its lt_click token through the fake Play referrer (setprop debug.linktrail.referrer) and
+# installs the app fresh. Afterwards it checks the backend recorded 1 attributed click_token install.
+appId: io.linktrail.demo.store
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-allow" }
+- tapOn: { id: "consent-continue" }
+- extendedWaitUntil: { visible: { id: "e2e-link-path", text: "path: /products/aj1" }, timeout: 30000 }
+- assertVisible: { id: "e2e-link-source", text: "source: deferred" }
+- assertVisible: { id: "e2e-link-custom-data", text: '.*"voucher":"SUMMER25".*' }
+- assertVisible: { id: "e2e-consent", text: "consent: granted" }
+# Exactly once: give a duplicate delivery time to arrive, then check the count.
+- extendedWaitUntil: { visible: "never-shown-e2e-pause", timeout: 5000, optional: true }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 1" }
diff --git a/e2e/flows/android/deferred_deny.yaml b/e2e/flows/android/deferred_deny.yaml
new file mode 100644
index 0000000..49ccf7a
--- /dev/null
+++ b/e2e/flows/android/deferred_deny.yaml
@@ -0,0 +1,16 @@
+# Deferred · consent DENY (real). Same staging as deferred_allow. Consent gates what is recorded,
+# not routing: the link still routes once, and afterwards e2e/android.sh checks the backend
+# recorded no install for this click.
+appId: io.linktrail.demo.store
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-deny" }
+- tapOn: { id: "consent-continue" }
+- extendedWaitUntil: { visible: { id: "e2e-link-path", text: "path: /products/aj1" }, timeout: 30000 }
+- assertVisible: { id: "e2e-link-source", text: "source: deferred" }
+- assertVisible: { id: "e2e-link-custom-data", text: '.*"voucher":"SUMMER25".*' }
+- assertVisible: { id: "e2e-consent", text: "consent: denied" }
+- assertNotVisible: { id: "e2e-attribution", text: "attribution: attributed=true" }
+- extendedWaitUntil: { visible: "never-shown-e2e-pause", timeout: 5000, optional: true }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 1" }
diff --git a/e2e/flows/android/reengagement.yaml b/e2e/flows/android/reengagement.yaml
new file mode 100644
index 0000000..1ee84a0
--- /dev/null
+++ b/e2e/flows/android/reengagement.yaml
@@ -0,0 +1,16 @@
+# Re-engagement (real): the app is installed and running; an https App Link for $LINK_URL opens it
+# (e2e/android.sh approves the host for the app first) and onLink fires once as a re-engagement.
+appId: io.linktrail.demo.store
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-allow" }
+- tapOn: { id: "consent-continue" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: granted" }, timeout: 10000 }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 0" }
+- openLink: ${LINK_URL}
+- extendedWaitUntil: { visible: { id: "e2e-link-source", text: "source: reengagement" }, timeout: 30000 }
+- assertVisible: { id: "e2e-link-path", text: "path: /products/aj1" }
+- assertVisible: { id: "e2e-link-custom-data", text: '.*"voucher":"SUMMER25".*' }
+- extendedWaitUntil: { visible: "never-shown-e2e-pause", timeout: 5000, optional: true }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 1" }
diff --git a/e2e/flows/android/track_event.yaml b/e2e/flows/android/track_event.yaml
new file mode 100644
index 0000000..f56b246
--- /dev/null
+++ b/e2e/flows/android/track_event.yaml
@@ -0,0 +1,11 @@
+# trackEvent with a value (real): with consent granted, the panel sends
+# trackEvent("e2e_purchase", { value: 9.99, currency: "USD" }) and shows the backend's answer.
+appId: io.linktrail.demo.store
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-allow" }
+- tapOn: { id: "consent-continue" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: granted" }, timeout: 10000 }
+- tapOn: { id: "e2e-track-event" }
+- extendedWaitUntil: { visible: { id: "e2e-event", text: "event: ok id=.*" }, timeout: 30000 }
diff --git a/e2e/flows/ios/_stage_click.yaml b/e2e/flows/ios/_stage_click.yaml
new file mode 100644
index 0000000..bd85a8e
--- /dev/null
+++ b/e2e/flows/ios/_stage_click.yaml
@@ -0,0 +1,16 @@
+# Safari half of the iOS deferred flows (run by e2e/ios.sh before deferred_allow / deferred_deny):
+# open $LINK_URL; the LinkTrail interstitial makes a real click and stages lt_click= on the
+# clipboard. The script then reads the token off the simulator pasteboard for the backend check.
+appId: com.apple.mobilesafari
+---
+- stopApp
+- launchApp
+- tapOn: { id: "TabBarItemTitle" }
+- inputText: ${LINK_URL}
+- pressKey: Enter
+# The subdomain host stages the token on page load and redirects to the App Store, so the CTA and
+# the "address is invalid" alert are optional.
+- extendedWaitUntil: { visible: { text: "Continue to( the)? App Store" }, timeout: 10000, optional: true }
+- tapOn: { text: "Continue to( the)? App Store", optional: true }
+- tapOn: { text: "OK", optional: true }
+- pressKey: Home
diff --git a/e2e/flows/ios/consent_prompt.yaml b/e2e/flows/ios/consent_prompt.yaml
new file mode 100644
index 0000000..a69d34e
--- /dev/null
+++ b/e2e/flows/ios/consent_prompt.yaml
@@ -0,0 +1,17 @@
+# Offline (no key, no backend): the consent gate shows on first launch; Skip leaves consent
+# undecided (deny-by-default); a granted decision persists and a relaunch does not ask again.
+# (The prompt's Continue is Apple's Paste control, which stays disabled without clipboard content,
+# so this flow grants through the e2e panel.)
+appId: io.linktrail.kickflipdemo
+---
+- launchApp: { clearState: true }
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-skip" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: undecided" }, timeout: 10000 }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 0" }
+- tapOn: { id: "e2e-grant-consent" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: granted" }, timeout: 10000 }
+- stopApp
+- launchApp
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: granted" }, timeout: 30000 }
+- assertNotVisible: "Allow tracking?"
diff --git a/e2e/flows/ios/deferred_allow.yaml b/e2e/flows/ios/deferred_allow.yaml
new file mode 100644
index 0000000..4872c5e
--- /dev/null
+++ b/e2e/flows/ios/deferred_allow.yaml
@@ -0,0 +1,16 @@
+# Deferred · consent ALLOW (real). e2e/ios.sh installs the app fresh and runs _stage_click.yaml
+# first (Safari stages the click token on the clipboard). Here the user allows and taps Paste;
+# onLink must fire once with the linked path + customData. Afterwards the script checks the backend
+# recorded 1 attributed click_token install for that token.
+appId: io.linktrail.kickflipdemo
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-allow" }
+- tapOn: { text: "^Paste$" }
+- extendedWaitUntil: { visible: { id: "e2e-link-path", text: "path: /products/aj1" }, timeout: 30000 }
+- assertVisible: { id: "e2e-link-source", text: "source: deferred" }
+- assertVisible: { id: "e2e-link-custom-data", text: '.*"voucher":"SUMMER25".*' }
+- assertVisible: { id: "e2e-consent", text: "consent: granted" }
+- extendedWaitUntil: { visible: "never-shown-e2e-pause", timeout: 5000, optional: true }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 1" }
diff --git a/e2e/flows/ios/deferred_deny.yaml b/e2e/flows/ios/deferred_deny.yaml
new file mode 100644
index 0000000..ff6728e
--- /dev/null
+++ b/e2e/flows/ios/deferred_deny.yaml
@@ -0,0 +1,13 @@
+# Deferred · consent DENY (real). Same staging as deferred_allow; the user denies and taps Paste.
+# Nothing may be attributed and onLink may fire at most once. Afterwards e2e/ios.sh checks the
+# backend recorded no install for the staged token.
+appId: io.linktrail.kickflipdemo
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-deny" }
+- tapOn: { text: "^Paste$" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: denied" }, timeout: 10000 }
+- extendedWaitUntil: { visible: "never-shown-e2e-pause", timeout: 5000, optional: true }
+- assertNotVisible: { id: "e2e-attribution", text: "attribution: attributed=true" }
+- assertNotVisible: { id: "e2e-link-count", text: "onLink: [2-9].*" }
diff --git a/e2e/flows/ios/reengagement.yaml b/e2e/flows/ios/reengagement.yaml
new file mode 100644
index 0000000..ae0b7d3
--- /dev/null
+++ b/e2e/flows/ios/reengagement.yaml
@@ -0,0 +1,18 @@
+# Re-engagement (real): with the app running, kickflip://e2e/open?url= hands $LINK_URL to
+# LinkTrail.handleDeepLink (an unsigned simulator build has no Associated Domains, so an https link
+# would open Safari). The SDK resolves it and onLink fires once as a re-engagement.
+appId: io.linktrail.kickflipdemo
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-skip" }
+- tapOn: { id: "e2e-grant-consent" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: granted" }, timeout: 10000 }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 0" }
+- openLink: ${E2E_OPEN_URL}
+- tapOn: { text: "Open", optional: true }
+- extendedWaitUntil: { visible: { id: "e2e-link-source", text: "source: reengagement" }, timeout: 30000 }
+- assertVisible: { id: "e2e-link-path", text: "path: /products/aj1" }
+- assertVisible: { id: "e2e-link-custom-data", text: '.*"voucher":"SUMMER25".*' }
+- extendedWaitUntil: { visible: "never-shown-e2e-pause", timeout: 5000, optional: true }
+- assertVisible: { id: "e2e-link-count", text: "onLink: 1" }
diff --git a/e2e/flows/ios/track_event.yaml b/e2e/flows/ios/track_event.yaml
new file mode 100644
index 0000000..1a7fa10
--- /dev/null
+++ b/e2e/flows/ios/track_event.yaml
@@ -0,0 +1,11 @@
+# trackEvent with a value (real): with consent granted, the panel sends
+# trackEvent("e2e_purchase", { value: 9.99, currency: "USD" }) and shows the backend's answer.
+appId: io.linktrail.kickflipdemo
+---
+- launchApp
+- extendedWaitUntil: { visible: "Allow tracking?", timeout: 30000 }
+- tapOn: { id: "consent-skip" }
+- tapOn: { id: "e2e-grant-consent" }
+- extendedWaitUntil: { visible: { id: "e2e-consent", text: "consent: granted" }, timeout: 10000 }
+- tapOn: { id: "e2e-track-event" }
+- extendedWaitUntil: { visible: { id: "e2e-event", text: "event: ok id=.*" }, timeout: 30000 }
diff --git a/e2e/ios.sh b/e2e/ios.sh
new file mode 100755
index 0000000..26f5093
--- /dev/null
+++ b/e2e/ios.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+#
+# iOS smoke e2e for the React Native wrapper: builds the example app (wrapper from source,
+# native LinkTrailSDK from CocoaPods trunk) in Release for the simulator, so the JS is bundled and
+# no Metro server is needed, then runs the Maestro flows in e2e/flows/ios on a booted simulator.
+#
+# consent_prompt offline: the consent gate shows, Skip stays undecided, a grant persists.
+# deferred_allow Safari stages a real click token on the clipboard → Allow + Paste → onLink once
+# (path + customData), then the backend recorded 1 attributed click_token install.
+# deferred_deny the same with Deny → the backend recorded nothing.
+# reengagement kickflip://e2e/open?url= → handleDeepLink → onLink (source reengagement).
+# track_event trackEvent with a value → resolved by the backend.
+#
+# Usage: bash e2e/ios.sh [flow…]
+# Env: LINKTRAIL_KEY, E2E_TOKEN (see e2e/lib.sh), RETRIES (3), DEVICE (udid; default: first
+# booted simulator), SKIP_BUILD=1 to reuse the last build, BUILD_ONLY=1 to only build,
+# SKIP_PODS=1 to skip pod install.
+set -euo pipefail
+export PATH="$HOME/.maestro/bin:$PATH:/opt/homebrew/bin:/usr/local/bin" # CI non-login shell; appended so a pinned Ruby (setup-ruby) wins
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+# shellcheck source=e2e/lib.sh
+source "$ROOT/e2e/lib.sh"
+
+APP_ID="io.linktrail.kickflipdemo"
+FLOW_DIR="$ROOT/e2e/flows/ios"
+DERIVED="$ROOT/example/ios/build-e2e"
+APP="$DERIVED/Build/Products/Release-iphonesimulator/KickFlip.app"
+export MAESTRO_DRIVER_STARTUP_TIMEOUT="${MAESTRO_DRIVER_STARTUP_TIMEOUT:-120000}"
+
+trap remove_config EXIT
+
+if [ "${SKIP_BUILD:-0}" != 1 ]; then
+ if [ "${SKIP_PODS:-0}" != 1 ]; then
+ echo "==> pod install"
+ (cd "$ROOT/example/ios" && for i in 1 2 3; do bundle exec pod install && exit 0; echo "pod install attempt $i failed; retrying"; sleep 5; done; exit 1)
+ fi
+ echo "==> Building the example (Release, simulator; JS bundled)"
+ write_config
+ mkdir -p "$DERIVED"
+ # The key is only in the gitignored config Metro reads; never an xcodebuild argument (those are
+ # echoed into the build log).
+ if ! (cd "$ROOT/example/ios" && xcodebuild -workspace KickFlip.xcworkspace -scheme KickFlip \
+ -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build > "$DERIVED/build.log" 2>&1); then
+ echo "ERROR: build failed:"; tail -40 "$DERIVED/build.log"; exit 1
+ fi
+ remove_config
+fi
+[ -d "$APP" ] || { echo "ERROR: $APP not found"; exit 1; }
+[ "${BUILD_ONLY:-0}" = 1 ] && { echo "==> Built $APP"; exit 0; }
+
+command -v maestro >/dev/null 2>&1 || { echo "ERROR: maestro not found (curl -Ls https://get.maestro.mobile.dev | bash)"; exit 1; }
+DEVICE="${DEVICE:-$(xcrun simctl list devices booted | grep -oiE '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}' | head -1 || true)}"
+[ -n "$DEVICE" ] || { echo "ERROR: no booted simulator (boot one, or set DEVICE)"; exit 1; }
+echo "-- device: $DEVICE"
+
+
+if [ "$#" -gt 0 ]; then
+ FLOWS=(); for f in "$@"; do FLOWS+=("$(basename "${f%.yaml}")"); done
+else
+ FLOWS=(consent_prompt deferred_allow deferred_deny reengagement track_event)
+fi
+
+# capture_token: the 32-hex lt_click token the interstitial staged on the clipboard, or nothing.
+capture_token() {
+ { xcrun simctl pbpaste "$DEVICE" 2>/dev/null || true; } | grep -oE 'lt_click=[0-9a-fA-F]{32}' | head -1 | cut -d= -f2 || true
+}
+
+# run_once : fresh install, stage what the flow needs, run, check backend.
+run_once() {
+ local name="$1" reinstall="$2" token="" open_url
+ xcrun simctl uninstall "$DEVICE" "$APP_ID" >/dev/null 2>&1 || true
+ xcrun simctl install "$DEVICE" "$APP" >/dev/null || return 1
+ printf '' | xcrun simctl pbcopy "$DEVICE"
+ if [[ "$name" == deferred_* ]]; then
+ # shellcheck disable=SC2086 # $reinstall is empty or one flag
+ maestro $reinstall --device "$DEVICE" test -e LINK_URL="$LINK_URL" "$FLOW_DIR/_stage_click.yaml" || return 1
+ token="$(capture_token)"
+ [ -n "$token" ] || { echo " no lt_click token on the clipboard after the Safari step" >&2; return 1; }
+ echo " click ${token:0:8}… staged"
+ reinstall=""
+ fi
+ open_url="kickflip://e2e/open?url=$(python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$LINK_URL")"
+ # shellcheck disable=SC2086
+ maestro $reinstall --device "$DEVICE" test -e LINK_URL="$LINK_URL" -e E2E_OPEN_URL="$open_url" "$FLOW_DIR/$name.yaml" || return 1
+ verify_click "$name" "$token"
+}
+
+fail=0
+for name in "${FLOWS[@]}"; do
+ [ -f "$FLOW_DIR/$name.yaml" ] || { echo "ERROR: no flow $FLOW_DIR/$name.yaml"; fail=1; continue; }
+ if flow_needs_backend "$name" && [ "$HAVE_SECRETS" != 1 ]; then
+ notice "ios/$name skipped: LINKTRAIL_KEY and E2E_TOKEN are not set"; continue
+ fi
+ ok=0; reinstall=""
+ for attempt in $(seq 1 "$RETRIES"); do
+ echo "==> ios/$name (attempt $attempt/$RETRIES)"
+ if run_once "$name" "$reinstall"; then ok=1; break; fi
+ # The XCUITest driver on iOS sims can die mid-run and stays dead; restart it before a retry.
+ echo " attempt failed; restarting the Maestro driver"
+ pkill -f 'XCTestRunner|WebDriverAgent' >/dev/null 2>&1 || true
+ reinstall="--reinstall-driver"; sleep 10
+ done
+ if [ "$ok" = 1 ]; then echo "OK: ios/$name"; else echo "FAILED: ios/$name"; fail=1; fi
+done
+
+[ "$fail" = 0 ] || { echo "==> Some iOS e2e flows failed"; exit 1; }
+echo "==> All iOS e2e flows passed"
diff --git a/e2e/lib.sh b/e2e/lib.sh
new file mode 100644
index 0000000..47d8b40
--- /dev/null
+++ b/e2e/lib.sh
@@ -0,0 +1,106 @@
+# shellcheck shell=bash
+# shellcheck disable=SC2034 # the variables here are read by the scripts that source this file
+#
+# Shared helpers for e2e/android.sh and e2e/ios.sh. Sourced, not run.
+# Expects ROOT (the repo root) from the caller.
+#
+# Secrets come from the environment only and are never printed:
+# LINKTRAIL_KEY workspace SDK key. Written into the gitignored example/e2e.config.local.json
+# for the run (removed on exit), so Metro bundles it into the test build.
+# E2E_TOKEN X-E2E-Token for the private backend check GET /v1/e2e/clicks/.
+# Without BOTH, the real flows (anything that talks to the backend) are skipped with a notice and
+# only the offline flows run.
+
+BACKEND_URL="https://backend.linktrail.io"
+# The campaign link the real flows use. Same slug as the native SDK e2e suites (backend seed):
+# it deep links to /products/aj1 with customData voucher=SUMMER25.
+LINK_URL="${LINK_URL:-https://kick.linktrail.io/discounted-product}"
+LINK_HOST="$(printf '%s' "$LINK_URL" | sed -E 's#^https?://([^/]+).*#\1#')"
+RETRIES="${RETRIES:-3}"
+VERIFY_TIMEOUT="${VERIFY_TIMEOUT:-15}"
+E2E_CONFIG="$ROOT/example/e2e.config.local.json"
+
+HAVE_SECRETS=0
+if [ -n "${LINKTRAIL_KEY:-}" ] && [ -n "${E2E_TOKEN:-}" ]; then HAVE_SECRETS=1; fi
+
+# notice : a GitHub Actions notice in CI, a plain line locally.
+notice() {
+ if [ "${GITHUB_ACTIONS:-}" = true ]; then echo "::notice title=e2e::$*"; else echo "NOTICE: $*"; fi
+}
+
+# write_config: the per-run e2e settings Metro bundles (see example/metro.config.js).
+# The key goes through python's argv-free stdin path so it never shows in `ps` or the log.
+write_config() {
+ printf '%s' "${LINKTRAIL_KEY:-}" | python3 -I -c '
+import json, sys
+json.dump({"enabled": True, "apiKey": sys.stdin.read()}, open(sys.argv[1], "w"))
+' "$E2E_CONFIG"
+ chmod 600 "$E2E_CONFIG"
+}
+
+remove_config() { rm -f "$E2E_CONFIG"; }
+
+# flow_needs_backend : every flow except the offline consent one.
+flow_needs_backend() { [ "$1" != "consent_prompt" ]; }
+
+# expect_for : what the backend must have recorded for the flow's click.
+expect_for() {
+ case "$1" in
+ deferred_allow) echo allow ;;
+ deferred_deny) echo deny ;;
+ *) echo none ;;
+ esac
+}
+
+# judge : prints "PASS " or "WAIT ".
+judge() {
+ python3 -I -c '
+import json, sys
+d = json.load(open(sys.argv[1])); mode = sys.argv[2]
+got = "installRecorded=%s attributed=%s matchType=%s installCount=%s" % (
+ d.get("installRecorded"), d.get("attributed"), d.get("matchType"), d.get("installCount"))
+if mode == "deny":
+ ok = d.get("installRecorded") is False and d.get("installCount") == 0
+else:
+ ok = (d.get("installRecorded") is True and d.get("attributed") is True
+ and d.get("matchType") == "click_token" and d.get("installCount") == 1)
+print(("PASS " if ok else "WAIT ") + got)
+' "$1" "$2" 2>/dev/null || echo "WAIT unparseable response"
+}
+
+# verify_click : poll GET /v1/e2e/clicks/ (contracts/e2e-verify-endpoint.md).
+# allow: installRecorded, attributed, matchType=click_token, installCount=1 (first PASS wins)
+# deny: installRecorded=false, installCount=0, held for the whole window so a late install shows
+# Neither the secret nor the full click token is printed.
+verify_click() {
+ local name="$1" token="$2" mode body code verdict="WAIT no response" deadline
+ mode="$(expect_for "$name")"
+ [ "$mode" = none ] && return 0
+ [ -n "$token" ] || { echo "VERIFY FAIL: $name — no lt_click token"; return 1; }
+ body="$(mktemp)"
+ deadline=$(( $(date +%s) + VERIFY_TIMEOUT ))
+ while :; do
+ # The secret reaches curl through a stdin config, never argv.
+ code="$(printf 'header = "X-E2E-Token: %s"\n' "$E2E_TOKEN" \
+ | curl -sS -o "$body" -w '%{http_code}' --max-time 10 --config - "$BACKEND_URL/v1/e2e/clicks/$token" 2>/dev/null || true)"
+ case "$code" in
+ 404)
+ rm -f "$body"
+ echo "VERIFY FAIL: $name — 404: the endpoint is off, the X-E2E-Token is wrong, or the click's app is not in the backend's E2E_APP_IDENTIFIERS"
+ return 1 ;;
+ 200)
+ verdict="$(judge "$body" "$mode")"
+ if [ "$mode" = allow ] && [[ "$verdict" == PASS* ]]; then break; fi
+ if [ "$mode" = deny ] && [[ "$verdict" == WAIT* ]]; then break; fi ;;
+ *) verdict="WAIT HTTP ${code:-000}" ;;
+ esac
+ [ "$(date +%s)" -ge "$deadline" ] && break
+ sleep 2
+ done
+ rm -f "$body"
+ if [[ "$verdict" == PASS* ]]; then
+ echo "VERIFY OK: $name [$mode] click ${token:0:8}… ${verdict#PASS }"; return 0
+ fi
+ echo "VERIFY FAIL: $name [$mode] click ${token:0:8}… — got ${verdict#WAIT }"
+ return 1
+}
diff --git a/e2e/sim-lock.sh b/e2e/sim-lock.sh
new file mode 100755
index 0000000..f4620bd
--- /dev/null
+++ b/e2e/sim-lock.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+#
+# Machine-wide lock around the simulator part of the E2E job (.github/workflows/e2e.yml, job ios).
+#
+# Several self-hosted runners are registered on the same Mac and they share one CoreSimulator device
+# set, so two E2E jobs on different runners would shut each other's simulator down mid-flow. A GitHub
+# concurrency group could serialise them, but a group holds only ONE pending job: a third PR waiting
+# for e2e cancels the second. This lock serialises on the machine instead, so any number of jobs can
+# wait their turn and none is cancelled.
+#
+# acquire wait for the lock, then print the PID of the process holding it (stdout).
+# Progress goes to stderr. Exit 1 if it is not free within E2E_LOCK_TIMEOUT.
+# release let go of the lock held by . A no-op for an empty, dead or foreign PID.
+#
+# The lock is a flock(2) held by a small background perl process (macOS has no flock(1)). The kernel
+# drops it the moment that process exits, so it cannot go stale: the holder exits on `release`, when
+# the job's Runner.Worker goes away (cancelled, crashed or finished without reaching `release`), when
+# the runner kills the job's orphan processes, or after E2E_LOCK_MAX_HOLD seconds as a last resort.
+#
+# Env (all optional):
+# E2E_LOCK_FILE default ~/.linktrail-e2e-simulators.lock. Per user on purpose: simulators
+# belong to a user, so that is exactly the set of jobs that can collide.
+# E2E_LOCK_TIMEOUT seconds to wait before giving up (default 5400 = 90 min).
+# E2E_LOCK_MAX_HOLD seconds after which the holder lets go regardless (default 10800 = 3 h).
+# E2E_LOCK_WATCH_PID release when this PID exits. Default: the Runner.Worker this script runs
+# under, found by walking up the process tree. Set it to 0 to disable.
+# E2E_LOCK_OWNER text written into the lock file so waiters can see who holds it.
+set -uo pipefail
+
+LOCK="${E2E_LOCK_FILE:-$HOME/.linktrail-e2e-simulators.lock}"
+TIMEOUT="${E2E_LOCK_TIMEOUT:-5400}"
+MAX_HOLD="${E2E_LOCK_MAX_HOLD:-10800}"
+HOLDER_NAME="linktrail-e2e-lock-holder"
+
+usage() { echo "usage: $0 acquire | release " >&2; exit 2; }
+log() { echo "e2e-sim-lock: $*" >&2; }
+
+# The Runner.Worker this shell runs under, or nothing outside GitHub Actions.
+runner_worker_pid() {
+ local pid=$$ ppid comm _
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
+ ppid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')"
+ [ -n "$ppid" ] && [ "$ppid" != 0 ] && [ "$ppid" != 1 ] || return 0
+ comm="$(ps -o comm= -p "$ppid" 2>/dev/null)"
+ case "$comm" in *Runner.Worker*) echo "$ppid"; return 0 ;; esac
+ pid="$ppid"
+ done
+}
+
+is_holder() {
+ [[ "${1:-}" =~ ^[0-9]+$ ]] || return 1
+ case "$(ps -o command= -p "$1" 2>/dev/null)" in "$HOLDER_NAME"*) return 0 ;; *) return 1 ;; esac
+}
+
+acquire() {
+ local watch="${E2E_LOCK_WATCH_PID-}"
+ if [ -z "$watch" ]; then
+ watch="$(runner_worker_pid)"
+ if [ -z "$watch" ]; then
+ [ "${GITHUB_ACTIONS:-}" = true ] && log "warning: Runner.Worker not found; the lock is released by 'release', orphan cleanup or after ${MAX_HOLD}s"
+ watch=0
+ fi
+ fi
+ local owner="${E2E_LOCK_OWNER:-${GITHUB_REPOSITORY:-local} run ${GITHUB_RUN_ID:-?} (${GITHUB_REF:-?}) on ${RUNNER_NAME:-$(hostname)}}"
+ local ready
+ ready="$(mktemp "${TMPDIR:-/tmp}/e2e-sim-lock.XXXXXX")" || { log "mktemp failed"; return 1; }
+ rm -f "$ready"
+
+ # stdio goes to /dev/null: the caller reads our stdout through $(...), which would otherwise wait
+ # for the background holder to close it, i.e. forever.
+ perl -e '
+ use strict; use warnings; use Fcntl qw(:flock O_RDWR O_CREAT); use IO::Handle;
+ my ($lock, $ready, $watch, $maxhold, $owner, $name) = @ARGV;
+ $0 = "$name $lock"; # shows in ps which lock this process holds
+ sysopen(my $fh, $lock, O_RDWR | O_CREAT, 0644) or die "open $lock: $!";
+ flock($fh, LOCK_EX) or die "flock $lock: $!";
+ # The job that asked may have been cancelled while we waited: do not sit on the lock for it.
+ exit 0 if $watch && !kill(0, $watch);
+ my $done = sub { truncate($fh, 0); exit 0 };
+ $SIG{TERM} = $SIG{INT} = $SIG{HUP} = $done;
+ truncate($fh, 0); seek($fh, 0, 0); print $fh "$owner\n"; $fh->flush;
+ open(my $r, ">", $ready) or die "open $ready: $!"; print $r "locked\n"; close $r;
+ my $end = time + $maxhold;
+ while (time < $end) { last if $watch && !kill(0, $watch); sleep 1 }
+ $done->();
+ ' "$LOCK" "$ready" "$watch" "$MAX_HOLD" "$owner" "$HOLDER_NAME" /dev/null 2>&1 &
+ local holder=$!
+
+ local start=$SECONDS last_note=$((SECONDS - 55)) holding # first note after ~5s, not before the holder tried
+ while :; do
+ if [ -s "$ready" ]; then
+ rm -f "$ready"
+ log "acquired $LOCK after $((SECONDS - start))s (holder pid $holder)"
+ echo "$holder"
+ return 0
+ fi
+ if ! kill -0 "$holder" 2>/dev/null; then
+ rm -f "$ready"
+ log "the lock holder exited before getting $LOCK"
+ return 1
+ fi
+ if [ $((SECONDS - start)) -ge "$TIMEOUT" ]; then
+ kill "$holder" 2>/dev/null; rm -f "$ready"
+ log "timed out after ${TIMEOUT}s waiting for $LOCK (held by: $(head -1 "$LOCK" 2>/dev/null))"
+ return 1
+ fi
+ if [ $((SECONDS - last_note)) -ge 60 ]; then
+ holding="$(head -1 "$LOCK" 2>/dev/null)"
+ log "waiting for $LOCK — held by: ${holding:-another job}"
+ last_note=$SECONDS
+ fi
+ sleep 1
+ done
+}
+
+release() {
+ local pid="${1:-}"
+ if ! is_holder "$pid"; then
+ [ -n "$pid" ] && log "pid $pid is not a lock holder (already released?); nothing to do"
+ return 0
+ fi
+ kill "$pid" 2>/dev/null
+ local _
+ for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
+ kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
+ log "released (holder pid $pid)"
+ return 0
+}
+
+case "${1:-}" in
+ acquire) [ $# -eq 1 ] || usage; acquire ;;
+ release) [ $# -le 2 ] || usage; release "${2:-}" ;;
+ *) usage ;;
+esac
diff --git a/example/.gitignore b/example/.gitignore
index de99955..d54ef7c 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -73,3 +73,6 @@ yarn-error.log
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+
+# e2e: per-run settings written by e2e/*.sh (holds the SDK key)
+e2e.config.local.json
diff --git a/example/App.tsx b/example/App.tsx
index 688b9d7..d36cb0f 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -9,6 +9,8 @@ import { ConsentPrompt } from './src/ConsentPrompt';
import { HomeScreen } from './src/screens/HomeScreen';
import { ProductScreen } from './src/screens/ProductScreen';
import { SimulatorPanel } from './src/SimulatorPanel';
+import e2eConfig from 'kickflip-e2e-config';
+import { E2EPanel } from './src/e2e/E2EPanel';
/**
* KickFlip — a small storefront showing how LinkTrail's deferred deep linking
@@ -140,6 +142,13 @@ export default function App() {
onClose={() => dispatch({ type: 'set-simulator', open: false })}
/>
+ {e2eConfig.enabled ? (
+
+ ) : null}
+
{/* First-launch consent gate — shown until the user decides or skips. */}
true
)
+ # Newer Xcodes reject pod targets below iOS 15 (AsyncStorage's resource
+ # bundle ships 13.0); lift them to the app's minimum.
+ installer.pods_project.targets.each do |t|
+ t.build_configurations.each do |c|
+ if c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.1
+ c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1'
+ end
+ end
+ end
+
# Xcode 26's clang rejects fmt's consteval format-string checks (an upstream
# React Native / fmt issue, unrelated to LinkTrail). fmt's base.h always
# (re)derives FMT_USE_CONSTEVAL from compiler detection, so a preprocessor
diff --git a/example/metro.config.js b/example/metro.config.js
index 0392349..bbcc100 100644
--- a/example/metro.config.js
+++ b/example/metro.config.js
@@ -1,8 +1,14 @@
+const fs = require('fs');
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const root = path.resolve(__dirname, '..');
+// e2e settings: the gitignored file the e2e scripts write (it holds the SDK key
+// for that run), or the committed defaults (e2e off, placeholder key).
+const E2E_LOCAL_CONFIG = path.join(__dirname, 'e2e.config.local.json');
+const E2E_DEFAULT_CONFIG = path.join(__dirname, 'src', 'e2e', 'config.default.json');
+
/**
* Metro configuration for the example app.
*
@@ -19,6 +25,15 @@ const config = {
new RegExp(`${path.join(root, 'node_modules', 'react-native').replace(/[/\\]/g, '[/\\\\]')}[/\\\\].*`),
new RegExp(`${path.join(root, 'node_modules', 'react').replace(/[/\\]/g, '[/\\\\]')}[/\\\\].*`),
],
+ resolveRequest: (context, moduleName, platform) => {
+ if (moduleName === 'kickflip-e2e-config') {
+ return {
+ type: 'sourceFile',
+ filePath: fs.existsSync(E2E_LOCAL_CONFIG) ? E2E_LOCAL_CONFIG : E2E_DEFAULT_CONFIG,
+ };
+ }
+ return context.resolveRequest(context, moduleName, platform);
+ },
extraNodeModules: {
react: path.join(__dirname, 'node_modules', 'react'),
'react-native': path.join(__dirname, 'node_modules', 'react-native'),
diff --git a/example/src/ConsentPrompt.tsx b/example/src/ConsentPrompt.tsx
index e465318..91270fb 100644
--- a/example/src/ConsentPrompt.tsx
+++ b/example/src/ConsentPrompt.tsx
@@ -40,6 +40,7 @@ export function ConsentPrompt({
{/* Allow / Deny — a selection, applied when you tap Paste / Navigate. */}
setGranted(true)}
>
@@ -48,6 +49,7 @@ export function ConsentPrompt({
setGranted(false)}
>
@@ -64,10 +66,14 @@ export function ConsentPrompt({
Paste / Navigate
{Platform.OS === 'ios' ? (
{
onTokenPasted(token);
onDecide(granted);
@@ -75,6 +81,7 @@ export function ConsentPrompt({
/>
) : (
onDecide(granted)}
>
@@ -82,7 +89,7 @@ export function ConsentPrompt({
)}
-
+
Skip
diff --git a/example/src/attribution.ts b/example/src/attribution.ts
index c4420be..afbd1f5 100644
--- a/example/src/attribution.ts
+++ b/example/src/attribution.ts
@@ -1,7 +1,9 @@
import { Linking, Platform } from 'react-native';
import LinkTrail, { type LinkTrailDeepLink } from 'linktrail-react-native';
+import e2eConfig from 'kickflip-e2e-config';
import type { StoreAction } from './store';
import { ConsentManager, type ConsentState } from './consent';
+import { e2eRecorder } from './e2e/recorder';
/**
* Bridges the demo UI to the LinkTrail SDK: configures it, forwards real
@@ -16,7 +18,7 @@ import { ConsentManager, type ConsentState } from './consent';
* (surfaced via `onError` as a console warning). The deep-link simulator
* works without a key.
*/
-const API_KEY = 'lt_live_USE_YOUR_API_KEY';
+const API_KEY = e2eConfig.apiKey || 'lt_live_USE_YOUR_API_KEY';
/** One of the demo's deferred-deep-link scenarios. */
export interface Scenario {
@@ -113,9 +115,11 @@ export function startAttribution(
// from storage. Routing works even without consent (only tracking is gated).
subscriptions.push(
LinkTrail.onLink((link, source) => {
+ e2eRecorder.link(link, source);
dispatch({ type: 'route-link', link, source });
}),
);
+ subscriptions.push(LinkTrail.onAttribution((a) => e2eRecorder.attribution(a)));
// Surface backend failures — most importantly a rejected API key.
subscriptions.push(
@@ -158,6 +162,19 @@ function routeLocalScheme(
dispatch: (action: StoreAction) => void,
): void {
if (!url.startsWith('kickflip://')) return;
+ // e2e builds only: kickflip://e2e/open?url= hands the
+ // link to the SDK, which resolves it and delivers it through onLink as a
+ // re-engagement. Used on the iOS simulator, where an unsigned build has no
+ // Associated Domains, so https links open Safari instead of the app.
+ if (url.startsWith('kickflip://e2e/open?url=')) {
+ if (e2eConfig.enabled) {
+ const link = decodeURIComponent(url.slice('kickflip://e2e/open?url='.length));
+ LinkTrail.handleDeepLink(link).catch((error) =>
+ console.warn('LinkTrail e2e open failed:', error),
+ );
+ }
+ return;
+ }
const [pathPart = '', queryPart] = url.slice('kickflip://'.length).split('?');
const path = '/' + pathPart.replace(/^\/+|\/+$/g, '');
diff --git a/example/src/e2e/E2EPanel.tsx b/example/src/e2e/E2EPanel.tsx
new file mode 100644
index 0000000..8620463
--- /dev/null
+++ b/example/src/e2e/E2EPanel.tsx
@@ -0,0 +1,80 @@
+import React, { useSyncExternalStore } from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import LinkTrail from 'linktrail-react-native';
+import { e2eRecorder, stableJson } from './recorder';
+
+interface Props {
+ consent: string;
+ onGrantConsent: () => void;
+}
+
+/** Event the trackEvent flow sends: a purchase with a value. */
+export const E2E_EVENT = { name: 'e2e_purchase', value: 9.99, currency: 'USD' };
+
+/**
+ * Diagnostics strip for the Maestro smoke flows (e2e builds only). Every
+ * value has a testID so a flow can assert on it.
+ */
+export function E2EPanel({ consent, onGrantConsent }: Props) {
+ const s = useSyncExternalStore(e2eRecorder.subscribe, e2eRecorder.getState);
+
+ const trackEvent = async () => {
+ e2eRecorder.event('sending');
+ try {
+ const result = await LinkTrail.trackEvent(E2E_EVENT.name, {
+ value: E2E_EVENT.value,
+ currency: E2E_EVENT.currency,
+ });
+ e2eRecorder.event(`ok id=${result.id ?? 'none'}`);
+ } catch (error) {
+ const { code, message } = error as { code?: string; message?: string };
+ e2eRecorder.event(`error ${code ?? message ?? String(error)}`);
+ }
+ };
+
+ return (
+
+ {`consent: ${consent}`}
+ {`onLink: ${s.linkCount}`}
+ {`source: ${s.lastSource ?? 'none'}`}
+ {`path: ${s.lastLink?.path ?? 'none'}`}
+
+ {`customData: ${stableJson(s.lastLink?.customData)}`}
+
+ {`attribution: ${s.attribution}`}
+ {`event: ${s.event}`}
+
+
+ Grant consent
+
+
+ Track event
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ panel: {
+ position: 'absolute',
+ left: 8,
+ right: 8,
+ bottom: 24,
+ backgroundColor: 'rgba(255,255,240,0.97)',
+ borderColor: '#999',
+ borderWidth: 1,
+ borderRadius: 8,
+ padding: 8,
+ },
+ line: { fontSize: 11, color: '#111' },
+ buttons: { flexDirection: 'row', gap: 8, marginTop: 6 },
+ button: {
+ flex: 1,
+ backgroundColor: '#333',
+ borderRadius: 6,
+ paddingVertical: 8,
+ alignItems: 'center',
+ },
+ buttonText: { color: '#FFF', fontSize: 12, fontWeight: '700' },
+});
diff --git a/example/src/e2e/config.d.ts b/example/src/e2e/config.d.ts
new file mode 100644
index 0000000..b3b7946
--- /dev/null
+++ b/example/src/e2e/config.d.ts
@@ -0,0 +1,15 @@
+/**
+ * Build-time e2e settings. Metro resolves this module (see metro.config.js) to
+ * `example/e2e.config.local.json` when the e2e scripts have written one, and to
+ * `config.default.json` otherwise. The local file is gitignored: it holds the
+ * workspace SDK key for a CI run and is deleted when the run ends.
+ */
+declare module 'kickflip-e2e-config' {
+ const config: {
+ /** Show the e2e diagnostics panel. */
+ enabled: boolean;
+ /** Workspace SDK key; empty keeps the placeholder in attribution.ts. */
+ apiKey: string;
+ };
+ export default config;
+}
diff --git a/example/src/e2e/config.default.json b/example/src/e2e/config.default.json
new file mode 100644
index 0000000..90f5d9d
--- /dev/null
+++ b/example/src/e2e/config.default.json
@@ -0,0 +1,4 @@
+{
+ "enabled": false,
+ "apiKey": ""
+}
diff --git a/example/src/e2e/recorder.ts b/example/src/e2e/recorder.ts
new file mode 100644
index 0000000..4a050c5
--- /dev/null
+++ b/example/src/e2e/recorder.ts
@@ -0,0 +1,63 @@
+import type {
+ LinkTrailAttribution,
+ LinkTrailDeepLink,
+ LinkTrailLinkSource,
+} from 'linktrail-react-native';
+
+/**
+ * What the e2e flows read off the screen: every `onLink` delivery (so a flow
+ * can assert it fired exactly once), the last attribution and the last
+ * `trackEvent` result. Only rendered when the e2e config is enabled.
+ */
+export interface E2EState {
+ linkCount: number;
+ lastLink: LinkTrailDeepLink | null;
+ lastSource: LinkTrailLinkSource | null;
+ attribution: string;
+ event: string;
+}
+
+let state: E2EState = {
+ linkCount: 0,
+ lastLink: null,
+ lastSource: null,
+ attribution: 'none',
+ event: 'idle',
+};
+const listeners = new Set<() => void>();
+
+function update(patch: Partial): void {
+ state = { ...state, ...patch };
+ listeners.forEach((l) => l());
+}
+
+export const e2eRecorder = {
+ getState: (): E2EState => state,
+ subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ link(link: LinkTrailDeepLink, source: LinkTrailLinkSource): void {
+ update({ linkCount: state.linkCount + 1, lastLink: link, lastSource: source });
+ },
+ attribution(a: LinkTrailAttribution): void {
+ update({ attribution: `attributed=${a.attributed}` });
+ },
+ event(text: string): void {
+ update({ event: text });
+ },
+};
+
+/** customData as stable JSON (sorted keys), so a flow can match it as text. */
+export function stableJson(data?: Record): string {
+ if (!data) return '{}';
+ const sorted: Record = {};
+ Object.keys(data)
+ .sort()
+ .forEach((k) => {
+ sorted[k] = data[k];
+ });
+ return JSON.stringify(sorted);
+}