diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 60df308..f45e97c 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -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 diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 0000000..cb79b65 --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -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 diff --git a/examples/onetrust/app.json b/examples/onetrust/app.json index 4a3b933..ec5f693 100644 --- a/examples/onetrust/app.json +++ b/examples/onetrust/app.json @@ -26,7 +26,12 @@ "favicon": "./assets/favicon.png" }, "plugins": [ - "react-native-app-auth" + [ + "react-native-app-auth", + { + "redirectUrls": ["de.contentpass.demo://oauth"] + } + ] ] } } diff --git a/examples/onetrust/e2e/README.md b/examples/onetrust/e2e/README.md new file mode 100644 index 0000000..d89ee2e --- /dev/null +++ b/examples/onetrust/e2e/README.md @@ -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. diff --git a/examples/onetrust/e2e/flows/cancelled-login.yaml b/examples/onetrust/e2e/flows/cancelled-login.yaml new file mode 100644 index 0000000..6b59ad4 --- /dev/null +++ b/examples/onetrust/e2e/flows/cancelled-login.yaml @@ -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' diff --git a/examples/onetrust/e2e/flows/cmp-timeout-fail-open.yaml b/examples/onetrust/e2e/flows/cmp-timeout-fail-open.yaml new file mode 100644 index 0000000..574488f --- /dev/null +++ b/examples/onetrust/e2e/flows/cmp-timeout-fail-open.yaml @@ -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' diff --git a/examples/onetrust/e2e/flows/consent-survives-relaunch.yaml b/examples/onetrust/e2e/flows/consent-survives-relaunch.yaml new file mode 100644 index 0000000..6d67a09 --- /dev/null +++ b/examples/onetrust/e2e/flows/consent-survives-relaunch.yaml @@ -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' diff --git a/examples/onetrust/e2e/flows/deny-all.yaml b/examples/onetrust/e2e/flows/deny-all.yaml new file mode 100644 index 0000000..dce499c --- /dev/null +++ b/examples/onetrust/e2e/flows/deny-all.yaml @@ -0,0 +1,25 @@ +appId: com.contentpassOnetrustExample.onetrustExample +--- +# Withdrawing consent from inside the app has to bring the consent layer +# back: the gate has to follow consent changes after startup, not only read +# the status once. +- clearKeychain +- launchApp: + clearState: true +- extendedWaitUntil: + visible: 'Login mit Contentpass' + timeout: 90000 +- tapOn: 'Einwilligen & weiter' +- extendedWaitUntil: + visible: + id: 'content-deny-all-button' + timeout: 90000 +- assertVisible: 'Consent layer: hidden' + +- tapOn: + id: 'content-deny-all-button' +- assertVisible: 'Has full consent: No' +- extendedWaitUntil: + visible: 'Login mit Contentpass' + timeout: 90000 +- assertVisible: 'Consent layer: shown' diff --git a/examples/onetrust/e2e/flows/happy-path.yaml b/examples/onetrust/e2e/flows/happy-path.yaml new file mode 100644 index 0000000..d97f3c0 --- /dev/null +++ b/examples/onetrust/e2e/flows/happy-path.yaml @@ -0,0 +1,90 @@ +appId: com.contentpassOnetrustExample.onetrustExample +--- +# Full happy path against the real contentpass-staging backend and the real +# OneTrust test tenant: accept consent, then separately verify login/logout +# with a static staging test account (${E2E_STAGING_EMAIL} / +# ${E2E_STAGING_PASSWORD}, injected at `maestro test` invocation time — see +# e2e/README.md). +# +# The funnel shown here is real, hosted Contentpass content (not native +# OneTrust banner UI — this app never calls into OneTrust's own banner, only +# its consent-status/adapter APIs), so its labels are verified against a +# live run rather than guessed: "Einwilligen & weiter" (not "Accept All") +# and "Login mit Contentpass" (not "Log in"). +# +# Every wait below is generous (90s) because a first CI run — fresh +# simulator, no cached DNS/TLS state, first real network round trip from a +# GitHub-hosted runner to the real OneTrust CDN and Contentpass backend — +# measured meaningfully slower than local iterative testing; Maestro's +# ~17s implicit default was not enough. +# +# Every flow in a job shares one simulator, so each starts from a clean +# slate: clearState wipes stored consent, but not the login tokens, which +# live in the iOS keychain. +- clearKeychain +- launchApp: + clearState: true +- extendedWaitUntil: + visible: 'Login mit Contentpass' + timeout: 90000 +- tapOn: 'Einwilligen & weiter' +- extendedWaitUntil: + visible: + id: 'content-full-consent' + timeout: 90000 +- assertVisible: 'Has full consent: Yes' + +# Relaunch to a clean funnel, then verify the auth path independently of +# consent. iOS shows its own native ASWebAuthenticationSession permission +# dialog ("Wants to Use... to Sign In") before the real OIDC login page +# loads — there's no Android equivalent of this step. +- launchApp: + clearState: true +- extendedWaitUntil: + visible: 'Login mit Contentpass' + timeout: 90000 +- tapOn: 'Login mit Contentpass' +- tapOn: 'Continue' +- extendedWaitUntil: + visible: 'Email' + timeout: 90000 +- tapOn: 'Email' +- inputText: ${E2E_STAGING_EMAIL} +- tapOn: 'Please log in' +- tapOn: 'PASSWORD' +- inputText: ${E2E_STAGING_PASSWORD} +# Anchored exact match: the page's own "Please log in" heading contains +# "Log in" as a substring too, and an unanchored match landed on that +# non-interactive heading instead of the submit button. +- tapOn: '^Log in$' +# iOS may offer to save the just-submitted password in a system alert that +# hides everything else from Maestro until it's dismissed. +- tapOn: + text: 'Not Now' + optional: true + +# Back in the app after the OAuth redirect. +- extendedWaitUntil: + visible: + id: 'content-authenticated' + timeout: 90000 +- assertVisible: 'Is authenticated: Yes' + +# The login has to survive a relaunch, and since this run never consented, +# the subscription alone has to keep the consent layer away. +- launchApp +- extendedWaitUntil: + visible: 'Is authenticated: Yes' + timeout: 90000 +- assertVisible: 'Consent layer: hidden' +- assertNotVisible: 'Login mit Contentpass' + +# With neither a subscription nor consent left, logging out has to bring the +# layer back. +- tapOn: + id: 'content-logout-button' +- assertVisible: 'Is authenticated: No' +- extendedWaitUntil: + visible: 'Login mit Contentpass' + timeout: 90000 +- assertVisible: 'Consent layer: shown' diff --git a/examples/onetrust/e2e/flows/onetrust-adapter-failure.yaml b/examples/onetrust/e2e/flows/onetrust-adapter-failure.yaml new file mode 100644 index 0000000..482053a --- /dev/null +++ b/examples/onetrust/e2e/flows/onetrust-adapter-failure.yaml @@ -0,0 +1,16 @@ +appId: com.contentpassOnetrustExample.onetrustExample +--- +# Built with EXPO_PUBLIC_ONETRUST_APP_ID set to a garbage value, so +# createOnetrustCmpAdapter() rejects during startup. App.tsx catches that and +# shows its own "Failed to load CMP" screen instead of ever reaching +# ContentpassSdkProvider / ContentpassConsentGate. +# +# 90s wait: a first CI run (fresh simulator, no cached DNS/TLS state) needs +# more than Maestro's ~17s implicit default to even get the rejection back +# from OneTrust's real CDN. +- launchApp +- extendedWaitUntil: + visible: + id: 'app-cmp-failed' + timeout: 90000 +- assertVisible: 'Failed to load CMP' diff --git a/examples/onetrust/src/App.tsx b/examples/onetrust/src/App.tsx index 6396db6..fd16cb6 100644 --- a/examples/onetrust/src/App.tsx +++ b/examples/onetrust/src/App.tsx @@ -7,6 +7,7 @@ import type { CmpAdapter } from '@contentpass/react-native-contentpass'; import { createOnetrustCmpAdapter } from '@contentpass/react-native-contentpass-cmp-onetrust'; import { CONTENTPASS_CONFIG, + GATE_TIMEOUTS, ONETRUST_CDN_LOCATION, ONETRUST_APP_ID, ONETRUST_LANGUAGE_CODE, @@ -30,6 +31,9 @@ export default function App() { const [cmpReady, setCmpReady] = useState(false); const [cmpFailed, setCmpFailed] = useState(false); const [cmpAdapter, setCmpAdapter] = useState(null); + // The gate only reports changes, and it starts out hidden, so false is + // also the right value when it settles on hidden without ever calling back. + const [consentLayerVisible, setConsentLayerVisible] = useState(false); useEffect(() => { OTPublishersNativeSDK.startSDK( @@ -56,18 +60,22 @@ export default function App() { }); }, []); - if (!cmpReady) { + // Checked before !cmpReady: neither startSDK() nor + // createOnetrustCmpAdapter() failing ever sets cmpReady, so checking + // !cmpReady first would leave the app stuck on "Loading..." forever + // instead of ever reaching this screen. + if (cmpFailed) { return ( - Loading... + Failed to load CMP ); } - if (cmpFailed || !cmpAdapter) { + if (!cmpReady || !cmpAdapter) { return ( - Failed to load CMP + Loading... ); } @@ -78,9 +86,14 @@ export default function App() { cmpAdapter={cmpAdapter!} contentpassConfig={CONTENTPASS_CONFIG} hideAppWhenVisible={false} + timeouts={GATE_TIMEOUTS} + onVisibilityChange={setConsentLayerVisible} > - + diff --git a/examples/onetrust/src/Config.ts b/examples/onetrust/src/Config.ts index 388535d..d46975c 100644 --- a/examples/onetrust/src/Config.ts +++ b/examples/onetrust/src/Config.ts @@ -1,4 +1,5 @@ import type { ContentpassConfig } from '@contentpass/react-native-contentpass'; +import type { ContentpassGateTimeouts } from '@contentpass/react-native-contentpass-ui'; export const CONTENTPASS_CONFIG: ContentpassConfig = { // Testing app @@ -12,5 +13,28 @@ export const CONTENTPASS_CONFIG: ContentpassConfig = { }; export const ONETRUST_CDN_LOCATION = 'cdn.cookielaw.org'; -export const ONETRUST_APP_ID = '019beb25-2008-72e0-8788-da1eec1f18dc-test'; +export const ONETRUST_APP_ID = + process.env.EXPO_PUBLIC_ONETRUST_APP_ID || + '019beb25-2008-72e0-8788-da1eec1f18dc-test'; export const ONETRUST_LANGUAGE_CODE = 'en'; + +// Lets an E2E build force every ContentpassConsentGate timeout down to the +// same tiny value, to deterministically exercise its fail-open path without +// needing the CMP or backend to actually misbehave. +const forcedTimeoutMs = process.env.EXPO_PUBLIC_GATE_TIMEOUT_MS + ? Number(process.env.EXPO_PUBLIC_GATE_TIMEOUT_MS) + : undefined; + +export const GATE_TIMEOUTS: ContentpassGateTimeouts | undefined = + forcedTimeoutMs + ? { + cmpInitTimeoutMs: forcedTimeoutMs, + cmpMetadataTimeoutMs: forcedTimeoutMs, + cmpConsentStatusTimeoutMs: forcedTimeoutMs, + authenticateTimeoutMs: forcedTimeoutMs, + secondLayerTimeoutMs: forcedTimeoutMs, + contentpassInitTimeoutMs: forcedTimeoutMs, + layerPageLoadTimeoutMs: forcedTimeoutMs, + layerReadyTimeoutMs: forcedTimeoutMs, + } + : undefined; diff --git a/examples/onetrust/src/Content.tsx b/examples/onetrust/src/Content.tsx index fc59ece..aee253c 100644 --- a/examples/onetrust/src/Content.tsx +++ b/examples/onetrust/src/Content.tsx @@ -6,7 +6,13 @@ import { useContentpassSdk, } from '@contentpass/react-native-contentpass'; -export default function Content({ cmpAdapter }: { cmpAdapter: CmpAdapter }) { +export default function Content({ + cmpAdapter, + consentLayerVisible, +}: { + cmpAdapter: CmpAdapter; + consentLayerVisible: boolean; +}) { const sdk = useContentpassSdk(); const [hasFullConsent, setHasFullConsent] = useState(false); @@ -35,19 +41,30 @@ export default function Content({ cmpAdapter }: { cmpAdapter: CmpAdapter }) { return ( Hello World! - Has full consent: {hasFullConsent ? 'Yes' : 'No'} + {/* This screen also renders underneath the layer while it is up, so + this line is how the E2E flows tell "layer shown" from "hidden". */} + + Consent layer: {consentLayerVisible ? 'shown' : 'hidden'} + + + Has full consent: {hasFullConsent ? 'Yes' : 'No'} +