Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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:-}"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ coverage/
# Android upload/release signing keys — never commit
*.jks
example/android/upload-keystore.jks
example/ios/build-e2e/
87 changes: 87 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -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=<link>` → `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/<token>`, 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=<udid> 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).
Loading
Loading