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); +}