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
9 changes: 0 additions & 9 deletions .github/workflows/e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,19 @@ jobs:
# 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.
#
# layer_page_load_timeout_ms gives the layer's page longer to load
# from live staging than the SDK's 8s default; cmp-timeout-fail-open
# forces every timeout down instead, and the CMP-failure flow never
# gets as far as the layer.
include:
- scenario: default-config
flows: happy-path cancelled-login deny-all consent-survives-relaunch
onetrust_app_id: ''
gate_timeout_ms: ''
layer_page_load_timeout_ms: '30000'
- scenario: onetrust-adapter-failure
flows: onetrust-adapter-failure
onetrust_app_id: 'e2e-invalid-app-id'
gate_timeout_ms: ''
layer_page_load_timeout_ms: ''
- scenario: cmp-timeout-fail-open
flows: cmp-timeout-fail-open
onetrust_app_id: ''
gate_timeout_ms: '1'
layer_page_load_timeout_ms: ''
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -142,7 +134,6 @@ jobs:
env:
EXPO_PUBLIC_ONETRUST_APP_ID: ${{ matrix.onetrust_app_id }}
EXPO_PUBLIC_GATE_TIMEOUT_MS: ${{ matrix.gate_timeout_ms }}
EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS: ${{ matrix.layer_page_load_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
Expand Down
4 changes: 0 additions & 4 deletions examples/onetrust/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,6 @@ to tolerate what fails there without the code being at fault:
retry turns the job green with a warning annotation naming the flows, and
still uploads the debug output, Metro's log included, so the flake can be
looked into.
- The `default-config` job starts Metro with
`EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS=30000`, so a slow page from
staging doesn't fail the gate open halfway through a flow; the SDK's
default is 8s.
- The simulator boots at the start of the job and settles while the app
builds. It is pinned (`SIM_DEVICE`, `SIM_RUNTIME`), as is Maestro
(`MAESTRO_VERSION`), in the workflow's job `env`.
Expand Down
8 changes: 4 additions & 4 deletions examples/onetrust/e2e/flows/happy-path.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ appId: com.contentpassOnetrustExample.onetrustExample
- 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$'
# Submit with the keyboard's return key, not a tap on "Log in": the button
# sits in a footer pinned to the bottom of the page, and with the keyboard
# open its centre can fall on the keyboard's toolbar, which takes the tap.
- pressKey: Enter
# iOS may offer to save the just-submitted password in a system alert that
# hides everything else from Maestro until it's dismissed.
- tapOn:
Expand Down
12 changes: 1 addition & 11 deletions examples/onetrust/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,6 @@ const forcedTimeoutMs = process.env.EXPO_PUBLIC_GATE_TIMEOUT_MS
? Number(process.env.EXPO_PUBLIC_GATE_TIMEOUT_MS)
: undefined;

// Lets an E2E run against live staging give the layer's page longer to load
// than the SDK's default, so a slow response doesn't fail the gate open
// halfway through a flow that is testing something else.
const layerPageLoadTimeoutMs = process.env
.EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS
? Number(process.env.EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS)
: undefined;

export const GATE_TIMEOUTS: ContentpassGateTimeouts | undefined =
forcedTimeoutMs
? {
Expand All @@ -45,6 +37,4 @@ export const GATE_TIMEOUTS: ContentpassGateTimeouts | undefined =
layerPageLoadTimeoutMs: forcedTimeoutMs,
layerReadyTimeoutMs: forcedTimeoutMs,
}
: layerPageLoadTimeoutMs
? { layerPageLoadTimeoutMs }
: undefined;
: undefined;
8 changes: 8 additions & 0 deletions packages/react-native-contentpass-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @contentpass/react-native-contentpass-ui

## 0.11.0

### Minor Changes

- [#82](https://github.com/contentpass/react-native-contentpass/pull/82) [`6d230b2`](https://github.com/contentpass/react-native-contentpass/commit/6d230b2b843875504b276b28ec882195d865dd19) Thanks [@0x7f](https://github.com/0x7f)! - Call `onVisibilityChange(false)` when the gate fails open while the consent layer is shown, so an app that hides its content while the layer is visible shows it again. `onVisibilityChange` is now only called when the visibility it last reported changes. Every fail-open is reported to Contentpass's error tracking, tagged with the UI package version, which needs `@contentpass/react-native-contentpass` 0.10.0 or later. The `timeouts` prop is now documented in the README.

- [#82](https://github.com/contentpass/react-native-contentpass/pull/82) [`613ad69`](https://github.com/contentpass/react-native-contentpass/commit/613ad69fefb6e56c72749db803ef48569bff5b75) Thanks [@0x7f](https://github.com/0x7f)! - Give the consent layer's page 30 seconds to load before the gate fails open, instead of 8, the same default as every other gate timeout. The wait starts when the layer mounts and ends once the page and every resource it loads have arrived, so it includes starting the device's WebView for the first time. On fresh Android installs that often took longer than 8 seconds, and the gate failed open before the layer ever showed. `timeouts.layerPageLoadTimeoutMs` still overrides the default.

## 0.10.1

### Patch Changes
Expand Down
30 changes: 28 additions & 2 deletions packages/react-native-contentpass-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,33 @@ A gate component that wraps your app content. It automatically shows the Content
| `contentpassConfig` | `ContentpassConfig` | Yes | — | The Contentpass SDK configuration object. |
| `hideAppWhenVisible` | `boolean` | No | `true` | When `true`, the app content is completely replaced by the consent layer. When `false`, the consent layer is rendered as an overlay on top of the app content. |
| `locale` | `string` | No | — | Forces the consent layer to be displayed in the given locale (e.g. `"de"`, `"es"`). When omitted, the layer picks the locale from the user's browser/device preferences and falls back to English. Unsupported values are ignored by the layer. |
| `onVisibilityChange` | `(visible: boolean) => void` | No | — | Callback invoked when the consent layer visibility changes. |
| `onVisibilityChange` | `(visible: boolean) => void` | No | — | Callback invoked when the consent layer visibility changes, including with `false` when the gate fails open while the layer is shown. |
| `timeouts` | `ContentpassGateTimeouts` | No | 30s each | Overrides how long the gate waits for each step before it fails open. See [Timeouts and failing open](#timeouts-and-failing-open). |

#### Timeouts and failing open

When a step does not finish in time, or fails, the gate fails open: it renders `children` for the rest of the mounted session instead of blocking the app, and reports the failure to Contentpass. Every timeout defaults to 30 seconds and can be overridden on its own through `timeouts`:

| Key | What the gate waits for |
|-----|-------------------------|
| `cmpInitTimeoutMs` | `cmpAdapter.waitForInit()` |
| `cmpMetadataTimeoutMs` | `cmpAdapter.getRequiredPurposes()` and `getNumberOfVendors()` |
| `cmpConsentStatusTimeoutMs` | The first `cmpAdapter.hasFullConsent()` |
| `contentpassInitTimeoutMs` | The Contentpass SDK leaving its `INITIALISING` state |
| `authenticateTimeoutMs` | The Contentpass login or signup flow |
| `secondLayerTimeoutMs` | `cmpAdapter.showSecondLayer()` |
| `layerPageLoadTimeoutMs` | The consent layer's page and every resource it loads, counted from when the layer mounts. On a fresh install this includes starting the device's WebView for the first time. |
| `layerReadyTimeoutMs` | The loaded page reporting that it is ready |

```tsx
<ContentpassConsentGate
cmpAdapter={cmpAdapter}
contentpassConfig={contentpassConfig}
timeouts={{ layerPageLoadTimeoutMs: 45_000 }}
>
{/* Your app content */}
</ContentpassConsentGate>
```

## Usage

Expand Down Expand Up @@ -78,7 +104,7 @@ For a full working example using OneTrust as the CMP, see the [`examples/onetrus
## Requirements

- React Native >= 0.76.0
- `@contentpass/react-native-contentpass` (peer dependency)
- `@contentpass/react-native-contentpass` >= 0.10.0 (peer dependency)
- `react-native-webview` (peer dependency)

## License
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native-contentpass-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentpass/react-native-contentpass-ui",
"version": "0.10.1",
"version": "0.11.0",
"description": "Contentpass React Native UI Components",
"source": "./src/index.tsx",
"main": "./lib/commonjs/index.js",
Expand Down Expand Up @@ -53,7 +53,7 @@
"access": "public"
},
"peerDependencies": {
"@contentpass/react-native-contentpass": "*",
"@contentpass/react-native-contentpass": ">=0.10.0",
"react": "*",
"react-native": "*",
"react-native-webview": "*"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import { Text } from 'react-native';
import { act, render, screen, waitFor } from '@testing-library/react-native';
import type {
CmpAdapter,
ContentpassConfig,
ContentpassState,
} from '@contentpass/react-native-contentpass';
import ContentpassConsentGate from './ContentpassConsentGate';
import { SDK_VERSION } from './sdkVersion';

let mockLayerFailure: ((error: unknown) => void) | null = null;

jest.mock('./ContentpassLayer', () => ({
__esModule: true,
default: ({ onFailure }: { onFailure: (error: unknown) => void }) => {
mockLayerFailure = onFailure;
return null;
},
}));

const mockSdk = {
instanceId: 'instance',
authState: null as ContentpassState | null,
authenticate: jest.fn(async () => {}),
event: jest.fn(async () => {}),
recoverFromError: jest.fn(async () => {}),
registerObserver: jest.fn((observer: (state: ContentpassState) => void) => {
if (mockSdk.authState) {
observer(mockSdk.authState);
}
}),
reportError: jest.fn(),
unregisterObserver: jest.fn(),
};

// The SDK package resolves to its build output, which tests run without.
jest.mock(
'@contentpass/react-native-contentpass',
() => ({
ContentpassStateType: {
INITIALISING: 'INITIALISING',
UNAUTHENTICATED: 'UNAUTHENTICATED',
AUTHENTICATED: 'AUTHENTICATED',
ERROR: 'ERROR',
},
useContentpassSdk: () => mockSdk,
}),
{ virtual: true }
);

const contentpassConfig = {
propertyId: 'property',
planId: 'plan',
issuer: 'https://issuer.test',
apiUrl: 'https://example.test',
redirectUrl: 'test://oauth',
} as ContentpassConfig;

function createCmpAdapter() {
let consentStatusCallback: ((fullConsent: boolean) => void) | null = null;
const cmpAdapter: CmpAdapter = {
acceptAll: jest.fn(async () => {}),
denyAll: jest.fn(async () => {}),
getNumberOfVendors: jest.fn(async () => 100),
getRequiredPurposes: jest.fn(async () => ['1', '2']),
hasFullConsent: jest.fn(async () => false),
onConsentStatusChange: jest.fn((callback) => {
consentStatusCallback = callback;
return () => {};
}),
showSecondLayer: jest.fn(async () => {}),
waitForInit: jest.fn(async () => {}),
};

return {
cmpAdapter,
changeConsentStatus: (fullConsent: boolean) =>
consentStatusCallback?.(fullConsent),
};
}

function renderGate(
cmpAdapter: CmpAdapter,
onVisibilityChange: (visible: boolean) => void
) {
return render(
<ContentpassConsentGate
cmpAdapter={cmpAdapter}
contentpassConfig={contentpassConfig}
onVisibilityChange={onVisibilityChange}
>
<Text>App content</Text>
</ContentpassConsentGate>
);
}

describe('ContentpassConsentGate failing open', () => {
beforeEach(() => {
mockLayerFailure = null;
mockSdk.authState = {
state: 'UNAUTHENTICATED',
hasValidSubscription: false,
} as ContentpassState;
jest.spyOn(console, 'debug').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});

it('tells the host app the layer is gone and reports why when the layer fails', async () => {
const { cmpAdapter, changeConsentStatus } = createCmpAdapter();
const onVisibilityChange = jest.fn();
const { unmount } = renderGate(cmpAdapter, onVisibilityChange);
await waitFor(() => expect(mockLayerFailure).not.toBeNull());
expect(onVisibilityChange.mock.calls).toEqual([[true]]);

const error = new Error(
'Timed out while loading the Contentpass layer page'
);
act(() => mockLayerFailure!(error));

expect(screen.getByText('App content')).toBeTruthy();
expect(onVisibilityChange.mock.calls).toEqual([[true], [false]]);
expect(mockSdk.reportError).toHaveBeenCalledWith(error, {
msg: 'Failed to initialize Contentpass layer',
tags: { uiVersion: SDK_VERSION },
});

act(() => changeConsentStatus(false));

expect(onVisibilityChange.mock.calls).toEqual([[true], [false]]);

unmount();
});

it('reports a CMP failure that is not an Error under the gate message', async () => {
const { cmpAdapter } = createCmpAdapter();
cmpAdapter.waitForInit = jest.fn(() => Promise.reject('not ready'));
const onVisibilityChange = jest.fn();
const { unmount } = renderGate(cmpAdapter, onVisibilityChange);

await screen.findByText('App content');

expect(mockSdk.reportError).toHaveBeenCalledWith(
new Error('Failed to initialize CMP'),
{ msg: 'Failed to initialize CMP', tags: { uiVersion: SDK_VERSION } }
);
expect(onVisibilityChange).not.toHaveBeenCalled();

unmount();
});

it('does not report the SDK ERROR state, which the SDK reports itself', async () => {
mockSdk.authState = {
state: 'ERROR',
error: new Error('Failed to initialise auth state'),
} as ContentpassState;
const { cmpAdapter } = createCmpAdapter();
const { unmount } = renderGate(cmpAdapter, jest.fn());

await screen.findByText('App content');

expect(mockSdk.reportError).not.toHaveBeenCalled();

unmount();
});
});
Loading
Loading