Skip to content
Merged
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
18 changes: 5 additions & 13 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,11 @@ runs:
with:
node-version-file: .nvmrc

- name: Cache dependencies
id: yarn-cache
uses: actions/cache@v4
with:
path: |
**/node_modules
.yarn/install-state.gz
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('**/package.json', '!node_modules/**') }}
restore-keys: |
${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
${{ runner.os }}-yarn-

# Installs from the registry on every run rather than restoring a cached
# node_modules: with nmHoistingLimits: workspaces that cache is ~1 GB of
# small files, which took 12-16 minutes to unpack on the macOS runners
# (and 8-13 to pack whenever its key changed), against about a minute to
# fetch every package.
- name: Install dependencies
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --immutable
shell: bash
174 changes: 174 additions & 0 deletions .github/workflows/e2e-ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
name: E2E iOS

on:
pull_request:
push:
branches:
- main

jobs:
e2e-ios:
# GitHub already withholds secrets from fork-originated pull_request
# runs, so this only decides whether the job shows as skipped (clean)
# rather than failed on missing credentials for those runs.
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
# The iOS Simulator is a native macOS process, not a nested VM, so it
# doesn't need hardware virtualization support the way the Android
# emulator does — unlike both ubuntu-latest (no /dev/kvm) and macos-14
# itself (no HVF for a *nested* hypervisor), this just works.
# macos-15, not macos-14: idb-companion (Maestro's iOS driver) requires
# macOS Sequoia+ and a full Xcode 26 install, neither available on 14.
runs-on: macos-15
name: e2e-ios (${{ matrix.scenario }})
strategy:
fail-fast: false
matrix:
# One job per Metro configuration, not per flow: a job is mostly
# setup and build, so every flow that runs against the example's
# default config shares the first one, and only flows that need Metro
# started with other EXPO_PUBLIC_* values get a job of their own.
include:
- scenario: default-config
flows: happy-path cancelled-login deny-all consent-survives-relaunch
onetrust_app_id: ''
gate_timeout_ms: ''
- scenario: onetrust-adapter-failure
flows: onetrust-adapter-failure
onetrust_app_id: 'e2e-invalid-app-id'
gate_timeout_ms: ''
- scenario: cmp-timeout-fail-open
flows: cmp-timeout-fail-open
onetrust_app_id: ''
gate_timeout_ms: '1'
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup
uses: ./.github/actions/setup

- name: Install JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'

# macos-15 defaults xcode-select to 16.4; idb-companion needs a full
# Xcode 26 install (CLT alone isn't enough), which is present on the
# image but not selected by default.
- name: Select Xcode 26
run: sudo xcode-select -s /Applications/Xcode_26.2.app

- name: Install Maestro and idb-companion
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
brew tap facebook/fb
brew install facebook/fb/idb-companion

- name: Prebuild iOS project
working-directory: examples/onetrust
# ios/ is gitignored (generated by expo prebuild), so a clean
# checkout needs this before any CocoaPods/Xcode step is available.
run: npx expo prebuild --platform ios --no-install

- name: Pod install
working-directory: examples/onetrust/ios
run: pod install

# Debug, not Release: a Release build's JS bundle goes through Metro's
# minifier, which — confirmed by inspecting the bundled output — was
# silently stripping the body of a core React Native polyfill module
# (setUpGlobals), crashing the app on launch before it ever reaches
# the UI ("TypeError: undefined is not a function" during module
# init). A Debug build skips bundling/minification at build time
# entirely and fetches its JS from Metro at runtime instead, which
# sidesteps the bug; see the Metro step below for why the per-flow
# env vars move there instead of the build step as a result.
#
# ARCHS=arm64: a generic simulator destination otherwise builds an
# x86_64 slice too, which the Apple Silicon runner's simulator never
# runs. Besides doubling the compile, that slice is where Swift 6.2.3
# (Xcode 26.2) intermittently crashes in its SendNonSendable pass on
# expo-modules-core's SharedObject.emit(event:arguments:).
- name: Build app (Debug)
working-directory: examples/onetrust/ios
run: |
xcodebuild \
-workspace ContentpassOnetrustExample.xcworkspace \
-scheme ContentpassOnetrustExample \
-configuration Debug \
-sdk iphonesimulator \
-derivedDataPath build \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
build

- name: Start Metro (${{ matrix.scenario }})
working-directory: examples/onetrust
env:
EXPO_PUBLIC_ONETRUST_APP_ID: ${{ matrix.onetrust_app_id }}
EXPO_PUBLIC_GATE_TIMEOUT_MS: ${{ matrix.gate_timeout_ms }}
# A Debug build fetches its JS bundle from Metro at app-launch time,
# so the env vars that pick the per-flow scenario have to be set on
# the Metro process, not on the (now scenario-independent) build
# step above.
run: |
npx expo start --port 8081 > /tmp/metro.log 2>&1 &
for i in $(seq 1 30); do
curl -s http://localhost:8081/status | grep -q "packager-status:running" && break
sleep 2
done
if ! curl -s http://localhost:8081/status | grep -q "packager-status:running"; then
echo "Metro did not become ready in time"
cat /tmp/metro.log
exit 1
fi
# Metro's first bundle of this app takes well over a minute to
# transform from a cold cache (confirmed via a CI run that still
# showed "Bundling 88%..." past Maestro's 60s wait) — pre-warm it
# here, where there's no assertion timer running, rather than
# leave the app to request it cold at launch.
echo "Pre-warming Metro's bundle cache..."
time curl -s -o /dev/null "http://localhost:8081/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true"

- name: Run ${{ matrix.scenario }} flows on simulator
working-directory: examples/onetrust
env:
# Maestro's own XCTest-runner driver bootstrap ("iOS driver not
# ready in time") can take longer than its default on a
# GitHub-hosted runner than on a local machine.
MAESTRO_DRIVER_STARTUP_TIMEOUT: '240000'
FLOWS: ${{ matrix.flows }}
run: |
DEVICE_UDID=$(xcrun simctl list devices available -j | jq -r '[.devices[] | .[] | select(.name | test("iPhone"))][0].udid')
xcrun simctl boot "$DEVICE_UDID"
xcrun simctl bootstatus "$DEVICE_UDID" -b
xcrun simctl install "$DEVICE_UDID" ios/build/Build/Products/Debug-iphonesimulator/ContentpassOnetrustExample.app
flow_files=()
for flow in $FLOWS; do
flow_files+=("e2e/flows/$flow.yaml")
done
# One invocation runs every flow even when an earlier one fails,
# and fails the step if any did.
maestro test "${flow_files[@]}" \
-e E2E_STAGING_EMAIL="${{ secrets.E2E_STAGING_EMAIL }}" \
-e E2E_STAGING_PASSWORD="${{ secrets.E2E_STAGING_PASSWORD }}"

# Metro's output is the app's JS console, including the SDK's
# console.debug lines (every message from the layer's WebView, the
# gate's and the CMP adapter's progress), none of which reach the
# simulator log that Maestro collects.
- name: Add Metro log to debug output
if: failure()
run: |
mkdir -p ~/.maestro/tests
cp /tmp/metro.log ~/.maestro/tests/ 2>/dev/null || true

- name: Upload Maestro debug output
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-debug-${{ matrix.scenario }}
path: ~/.maestro/tests/
retention-days: 7
7 changes: 6 additions & 1 deletion examples/onetrust/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"react-native-app-auth"
[
"react-native-app-auth",
{
"redirectUrls": ["de.contentpass.demo://oauth"]
}
]
]
}
}
100 changes: 100 additions & 0 deletions examples/onetrust/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# OneTrust example E2E (iOS, Maestro)

Maestro flows driving the built `examples/onetrust` app against the real
`contentpass-staging` backend and the real OneTrust test tenant — no mock
server. See `flows/` for what each one covers.

iOS only for now: the Android emulator needs hardware virtualization that
neither GitHub-hosted runner type currently provides (`ubuntu-latest` has no
`/dev/kvm`; `macos-14` has no HVF for a _nested_ hypervisor). The iOS
Simulator is a native macOS process, not a VM, so it doesn't hit this at
all — see `.github/workflows/e2e-ios.yml`. Revisit Android once there's a
runner (GitHub larger Linux runner, or self-hosted) that actually exposes
nested virtualization.

## Debug + Metro, not a Release build

The app runs as a **Debug** build with a live Metro server, not a
self-contained Release build. A Release build's JS bundle goes through
Metro's minifier, which — confirmed by inspecting the bundled output byte
for byte — silently strips the body of a core React Native polyfill module
(`setUpGlobals`), crashing the app on launch before it ever reaches the UI
(`TypeError: undefined is not a function` during module init). This is a
toolchain bug worth fixing on its own, but it isn't an E2E-infra concern, so
Debug (which skips bundling/minification at build time and fetches JS from
Metro at runtime) sidesteps it entirely. One consequence: the
`EXPO_PUBLIC_ONETRUST_APP_ID` / `EXPO_PUBLIC_GATE_TIMEOUT_MS` env vars that
pick a scenario have to be set on the **Metro process**, not on the build —
a Debug build itself is identical across every flow.

## Prerequisites

- [Maestro CLI](https://maestro.mobile.dev) installed (`curl -Ls "https://get.maestro.mobile.dev" | bash`).
- [idb-companion](https://github.com/facebook/idb), which Maestro needs to drive the iOS Simulator (`brew tap facebook/fb && brew install facebook/fb/idb-companion`).
- A Metro server running with the right env vars for the scenario you want
(see below), and a booted iOS Simulator with the app installed against it
(`yarn ios` from `examples/onetrust` does both for local dev).
- For `flows/happy-path.yaml` only: a dedicated contentpass-staging test
account's credentials, exported as `E2E_STAGING_EMAIL` /
`E2E_STAGING_PASSWORD`. This is a stable fixture account, and it needs an
active subscription to the example's plan: without one, the login hands
the account on to the subscription checkout instead of redirecting back to
the app, so the flow never sees `AUTHENTICATED`. Never commit these
values; locally, export them in your shell, don't put them in a tracked
`.env`.

## The flows

Against the example's default config (one CI job, `default-config`):

| Flow | Checks |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `happy-path.yaml` | Consent through the funnel; then a subscriber's login, that it survives a relaunch with the layer kept away by the subscription alone, and that logging out brings the layer back |
| `cancelled-login.yaml` | Cancelling iOS's sign-in prompt brings the layer back |
| `deny-all.yaml` | Withdrawing consent in the app brings the layer back |
| `consent-survives-relaunch.yaml` | Stored consent alone satisfies the gate after a relaunch |

Against a Metro started with an override (one CI job each):

| Flow | Override | Checks |
| ------------------------------- | ------------------------------- | ---------------------------------------------------------- |
| `onetrust-adapter-failure.yaml` | `EXPO_PUBLIC_ONETRUST_APP_ID` | An invalid OneTrust app id lands on the CMP-failure screen |
| `cmp-timeout-fail-open.yaml` | `EXPO_PUBLIC_GATE_TIMEOUT_MS=1` | Every gate timeout expiring fails open into the app |

The example's screen prints "Consent layer: shown/hidden" from the gate's
`onVisibilityChange`. It renders underneath the layer too, so this line,
not the presence of the app's own text, is how a flow tells the two apart.

Every flow starts with `clearKeychain` and a `clearState` launch, since a
job runs its flows one after another on the same simulator, and the login
tokens live in the keychain, which `clearState` leaves alone.

## Running a flow

```sh
maestro test flows/happy-path.yaml -e E2E_STAGING_EMAIL="$E2E_STAGING_EMAIL" -e E2E_STAGING_PASSWORD="$E2E_STAGING_PASSWORD"
maestro test flows/cancelled-login.yaml flows/deny-all.yaml flows/consent-survives-relaunch.yaml
maestro test flows/onetrust-adapter-failure.yaml
maestro test flows/cmp-timeout-fail-open.yaml
```

The last two need Metro itself started with the matching env var before
`yarn ios`:

```sh
EXPO_PUBLIC_ONETRUST_APP_ID=garbage-app-id npx expo start # onetrust-adapter-failure.yaml
EXPO_PUBLIC_GATE_TIMEOUT_MS=1 npx expo start # cmp-timeout-fail-open.yaml
```

## iOS steps the login flows have to tap through

- The funnel's real labels are "Einwilligen & weiter" and "Login mit
Contentpass". This app never shows a separate native OneTrust banner; the
hosted Contentpass funnel is the only consent/login UI.
- iOS's native `ASWebAuthenticationSession` permission dialog ("Wants to Use
... to Sign In") appears before the OIDC login page and needs its own
`tapOn: "Continue"`.
- After the login form is submitted, iOS may put a "Save Password?" system
alert over the page. While it is up, it is the only thing in the UI
hierarchy Maestro sees, so the flow dismisses it ("Not Now") before
waiting for the app.
22 changes: 22 additions & 0 deletions examples/onetrust/e2e/flows/cancelled-login.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
appId: com.contentpassOnetrustExample.onetrustExample
---
# Backing out of the login on iOS's own "Wants to Use ... to Sign In" prompt
# has to bring the consent layer back, rather than leave the app on the
# gate's spinner with neither the layer nor the app showing.
- clearKeychain
- launchApp:
clearState: true
- extendedWaitUntil:
visible: 'Login mit Contentpass'
timeout: 90000
- tapOn: 'Login mit Contentpass'
- extendedWaitUntil:
visible: 'Cancel'
timeout: 90000
- tapOn: 'Cancel'

- extendedWaitUntil:
visible: 'Login mit Contentpass'
timeout: 90000
- assertVisible: 'Consent layer: shown'
- assertVisible: 'Is authenticated: No'
21 changes: 21 additions & 0 deletions examples/onetrust/e2e/flows/cmp-timeout-fail-open.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
appId: com.contentpassOnetrustExample.onetrustExample
---
# Built with EXPO_PUBLIC_GATE_TIMEOUT_MS set to an unrealistically small
# value (e.g. "1"), so every one of ContentpassConsentGate's own timeouts
# fires before the real OneTrust SDK or Contentpass backend can respond.
# The gate is designed to fail open in that case: render the app's content
# directly rather than block it behind a CMP/backend that never answered in
# time. Neither the OneTrust banner nor the Contentpass funnel should ever
# appear.
#
# 90s wait: a first CI run (fresh simulator, no cached DNS/TLS state) still
# needs real network round trips for OneTrust adapter creation in App.tsx,
# which has its own timeout (`createOnetrustCmpAdapter` is not covered by
# the forced gate timeouts) — more than Maestro's ~17s implicit default.
- launchApp
- extendedWaitUntil:
visible:
id: 'content-full-consent'
timeout: 90000
- assertVisible:
id: 'content-authenticated'
23 changes: 23 additions & 0 deletions examples/onetrust/e2e/flows/consent-survives-relaunch.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
appId: com.contentpassOnetrustExample.onetrustExample
---
# Consent given once has to be enough: after a relaunch, the consent OneTrust
# stored has to satisfy the gate on its own, without showing the layer again.
- clearKeychain
- launchApp:
clearState: true
- extendedWaitUntil:
visible: 'Login mit Contentpass'
timeout: 90000
- tapOn: 'Einwilligen & weiter'
- extendedWaitUntil:
visible: 'Has full consent: Yes'
timeout: 90000

- launchApp
- extendedWaitUntil:
visible:
id: 'content-full-consent'
timeout: 90000
- assertVisible: 'Has full consent: Yes'
- assertVisible: 'Consent layer: hidden'
- assertNotVisible: 'Login mit Contentpass'
Loading
Loading