diff --git a/docs/content/scripts/usercentrics.md b/docs/content/scripts/usercentrics.md new file mode 100644 index 00000000..1c8d0aee --- /dev/null +++ b/docs/content/scripts/usercentrics.md @@ -0,0 +1,152 @@ +--- +title: Usercentrics +description: Load the Usercentrics CMP v3 and drive useScript consent triggers from UC_UI_CMP_EVENT events. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/usercentrics.ts + size: xs +--- + +[Usercentrics](https://usercentrics.com) is a Consent Management Platform (CMP) used to collect, store, and signal end-user consent for third-party scripts under GDPR, CCPA, and the IAB TCF v2 framework. + +Nuxt Scripts ships [`useScriptUsercentrics()`{lang="ts"}](/scripts/usercentrics) so you can boot the CMP v3 ("Web CMP") loader, expose typed access to the `window.__ucCmp` programmatic API, and wire other registry scripts' consent triggers directly to Usercentrics' `UC_UI_CMP_EVENT` browser event. + +::script-stats +:: + +::script-docs +:: + +The composable comes with the following defaults: +- **Trigger: Client** Script will load when Nuxt is hydrating. +- **Bundle / proxy: off** The CMP is the consent surface itself, so it must hit the vendor origin directly. It is also exempt from consent gating. + +You can access the `ucCmp` object as a proxy directly or await the `$script` promise. It's recommended to use the proxy for any void / Promise-returning calls. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptUsercentrics({ + rulesetId: 'your-ruleset-id', +}) +function showSettings() { + proxy.ucCmp.showSecondLayer() +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptUsercentrics({ + rulesetId: 'your-ruleset-id', +}) +onLoaded(({ ucCmp }) => { + ucCmp.showFirstLayer() +}) +``` + +:: + +## Drive consent triggers from Usercentrics + +Pair `consent.onConsentChange(...)`{lang="ts"} with [`useScriptTriggerConsent`](/docs/api/use-script-trigger-consent) to load any third-party script the moment the user opts in via the Usercentrics banner. + +```vue + + + +``` + +`onConsentChange` returns a teardown function so you can unsubscribe inside `onScopeDispose`. The callback receives the raw `UC_UI_CMP_EVENT` detail (e.g. `{ type: 'ACCEPT_ALL' | 'DENY_ALL' | 'SAVE', ... }`). + +## Open the consent UI + +`__ucCmp`'s methods are no-ops until the CMP API is ready. Use `consent.whenReady()`{lang="ts"} to await it, or call the helpers on `consent` directly (they no-op while the CMP boots): + +```vue + + + +``` + +## Auto Blocking + +If your Usercentrics ruleset is configured for **Auto Blocking** (rather than Manual Blocking), set `autoblocker: true` to inject the autoblocker module ahead of the loader: + +```ts +useScriptUsercentrics({ + rulesetId: 'your-ruleset-id', + autoblocker: true, +}) +``` + +::script-types +:: + +## Example + +Loading Usercentrics through `app.vue` when Nuxt is ready. + +```vue [app.vue] + +``` + +## Partytown + +Usercentrics is not supported under Partytown. The `__ucCmp` API is method-heavy and not safe to forward across the worker boundary, and the CMP needs main-thread DOM access to render its UI overlays. diff --git a/package.json b/package.json index 87722e9f..36f1d514 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dev": "nuxt dev playground", "dev:ssl": "nuxt dev playground --https", "dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures", - "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn", + "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics", "typecheck": "nuxt typecheck", "release": "pnpm build && bumpp -r --output=CHANGELOG.md", "lint": "eslint .", diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 8e28d8d0..ab47c404 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -60,6 +60,7 @@ export const LOGOS = { googleAnalytics: ``, umamiAnalytics: ``, gravatar: ``, + usercentrics: ``, bingUet: ``, snapchatPixel: ``, } satisfies Record diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 0013a48e..8908d4f8 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -993,6 +993,33 @@ "code": "export interface UmamiAnalyticsApi {\n track: ((payload?: Record) => void) & ((event_name: string, event_data: Record) => void)\n identify: (session_data?: Record | string) => void\n}" } ], + "usercentrics": [ + { + "name": "UsercentricsOptions", + "kind": "const", + "code": "export const UsercentricsOptions = object({\n /**\n * Your Usercentrics CMP v3 ruleset ID. Find it in the admin under\n * Implementation; the snippet's `data-ruleset-id` value.\n */\n rulesetId: pipe(string(), minLength(1)),\n /**\n * Inject the Usercentrics autoblocker (`autoblocker.js`) ahead of the loader.\n * Enable when your ruleset relies on Auto Blocking (vs. Manual Blocking) to\n * gate third-party scripts before consent is granted.\n * @default false\n */\n autoblocker: optional(boolean()),\n /**\n * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`).\n */\n language: optional(string()),\n})" + }, + { + "name": "UsercentricsCmpEventDetail", + "kind": "interface", + "code": "export interface UsercentricsCmpEventDetail {\n type: string\n source?: string\n [key: string]: any\n}" + }, + { + "name": "UsercentricsCmp", + "kind": "interface", + "code": "export interface UsercentricsCmp {\n isInitialized: () => Promise\n isConsentRequired: () => Promise\n showFirstLayer: () => Promise\n showSecondLayer: () => Promise\n showServiceDetails: (id: string) => Promise\n showAutoblockerMoreInfoView: () => Promise\n closeCmp: () => Promise\n acceptAllConsents: () => Promise\n denyAllConsents: () => Promise\n saveConsents: () => Promise\n updateCategoriesConsents: (consents: Array<{ categorySlug: string, consent: boolean }>) => Promise\n updateServicesConsents: (consents: Array<{ templateId: string, consent: boolean }>) => Promise\n updateTcfConsents: (...args: unknown[]) => Promise\n refreshScripts: () => Promise\n clearUserSession: () => Promise\n getConsentDetails: () => Promise>\n getCmpConfig: () => Promise>\n getActiveLanguage: () => Promise\n getControllerId: () => Promise\n changeLanguage: (lang: string) => Promise\n [key: string]: any\n}" + }, + { + "name": "UsercentricsApi", + "kind": "interface", + "code": "export interface UsercentricsApi {\n ucCmp: UsercentricsCmp\n}" + }, + { + "name": "UsercentricsConsent", + "kind": "interface", + "code": "export interface UsercentricsConsent {\n /**\n * Resolves once the CMP API is ready (`UC_CMP_API_READY`) or immediately if\n * it already is. Resolves with `window.__ucCmp` so callers can query\n * consent state without polling.\n */\n whenReady: () => Promise\n /**\n * Subscribe to `UC_UI_CMP_EVENT` browser events (the v3 consent change\n * event). Returns a teardown function. The callback receives the event\n * detail, e.g. `{ type: 'ACCEPT_ALL' | 'DENY_ALL' | 'SAVE', ... }`.\n */\n onConsentChange: (cb: (detail: UsercentricsCmpEventDetail, event: Event) => void) => () => void\n /** Open the privacy settings (first layer banner). */\n showFirstLayer: () => Promise | void\n /** Open the detailed settings (second layer modal). */\n showSecondLayer: () => Promise | void\n /** Accept all consents. */\n acceptAll: () => Promise | void\n /** Reject all consents. */\n denyAll: () => Promise | void\n}" + } + ], "vercel-analytics": [ { "name": "VercelAnalyticsOptions", @@ -2090,6 +2117,27 @@ "defaultValue": "false" } ], + "UsercentricsOptions": [ + { + "name": "rulesetId", + "type": "string", + "required": true, + "description": "Your Usercentrics CMP v3 ruleset ID. Find it in the admin under Implementation; the snippet's `data-ruleset-id` value." + }, + { + "name": "autoblocker", + "type": "boolean", + "required": false, + "description": "Inject the Usercentrics autoblocker (`autoblocker.js`) ahead of the loader. Enable when your ruleset relies on Auto Blocking (vs. Manual Blocking) to gate third-party scripts before consent is granted.", + "defaultValue": "false" + }, + { + "name": "language", + "type": "string", + "required": false, + "description": "Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`)." + } + ], "SegmentOptions": [ { "name": "writeKey", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index ec00ddc9..2400191f 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -44,6 +44,7 @@ import { StripeOptions, TikTokPixelOptions, UmamiAnalyticsOptions, + UsercentricsOptions, VercelAnalyticsOptions, XEmbedOptions, XPixelOptions, @@ -167,6 +168,9 @@ export const registryMeta: RegistryScriptMeta[] = [ m('googleRecaptcha', 'Google reCAPTCHA', 'utility', 'useScriptGoogleRecaptcha', {}, null), m('googleSignIn', 'Google Sign-In', 'utility', 'useScriptGoogleSignIn', {}, null), m('gravatar', 'Gravatar', 'utility', 'useScriptGravatar', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), + // Usercentrics is the consent layer itself: must hit the vendor origin so + // signature/policy lookups succeed. Bundle/proxy are intentionally absent. + m('usercentrics', 'Usercentrics', 'utility', 'useScriptUsercentrics', {}, null), ] export const REGISTRY_CATEGORIES = [ @@ -799,6 +803,18 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, partytown: { forwards: ['umami', 'umami.track'] }, }), + def('usercentrics', { + composableName: 'useScriptUsercentrics', + schema: UsercentricsOptions, + label: 'Usercentrics', + // The runtime composable builds the actual script tag so it can include + // the required `data-ruleset-id` attribute. No bundle/proxy: the CMP + // must hit the vendor origin (it IS the consent surface, and is exempt + // from consent gating) so policies/signatures resolve correctly. + src: 'https://web.cmp.usercentrics.eu/ui/loader.js', + category: 'utility', + envDefaults: { rulesetId: '' }, + }), def('gravatar', { schema: GravatarOptions, label: 'Gravatar', diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 24e5f2e1..bb84f12b 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -871,6 +871,25 @@ export const LinkedInInsightOptions = object({ enableAutoSpaTracking: optional(boolean()), }) +export const UsercentricsOptions = object({ + /** + * Your Usercentrics CMP v3 ruleset ID. Find it in the admin under + * Implementation; the snippet's `data-ruleset-id` value. + */ + rulesetId: pipe(string(), minLength(1)), + /** + * Inject the Usercentrics autoblocker (`autoblocker.js`) ahead of the loader. + * Enable when your ruleset relies on Auto Blocking (vs. Manual Blocking) to + * gate third-party scripts before consent is granted. + * @default false + */ + autoblocker: optional(boolean()), + /** + * Override the language displayed by the CMP UI (BCP-47 code, e.g. `'en'`, `'de'`). + */ + language: optional(string()), +}) + export const SegmentOptions = object({ /** * Your Segment write key. diff --git a/packages/script/src/runtime/registry/usercentrics.ts b/packages/script/src/runtime/registry/usercentrics.ts new file mode 100644 index 00000000..5549a6d0 --- /dev/null +++ b/packages/script/src/runtime/registry/usercentrics.ts @@ -0,0 +1,146 @@ +import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' +import { useHead } from '@unhead/vue' +import { useRegistryScript } from '../utils' +import { UsercentricsOptions } from './schemas' + +export { UsercentricsOptions } + +export type UsercentricsInput = RegistryScriptInput + +export interface UsercentricsCmpEventDetail { + type: string + source?: string + [key: string]: any +} + +/** + * The Usercentrics CMP v3 programmatic API exposed on `window.__ucCmp`. + * Each method returns a Promise resolved once the CMP is ready. + */ +export interface UsercentricsCmp { + isInitialized: () => Promise + isConsentRequired: () => Promise + showFirstLayer: () => Promise + showSecondLayer: () => Promise + showServiceDetails: (id: string) => Promise + showAutoblockerMoreInfoView: () => Promise + closeCmp: () => Promise + acceptAllConsents: () => Promise + denyAllConsents: () => Promise + saveConsents: () => Promise + updateCategoriesConsents: (consents: Array<{ categorySlug: string, consent: boolean }>) => Promise + updateServicesConsents: (consents: Array<{ templateId: string, consent: boolean }>) => Promise + updateTcfConsents: (...args: unknown[]) => Promise + refreshScripts: () => Promise + clearUserSession: () => Promise + getConsentDetails: () => Promise> + getCmpConfig: () => Promise> + getActiveLanguage: () => Promise + getControllerId: () => Promise + changeLanguage: (lang: string) => Promise + [key: string]: any +} + +export interface UsercentricsApi { + ucCmp: UsercentricsCmp +} + +declare global { + interface Window { + __ucCmp?: UsercentricsCmp + } +} + +/** + * Vendor-native Usercentrics consent helpers exposed on the composable result. + * Use these to drive `useScript` consent triggers from CMP events. + */ +export interface UsercentricsConsent { + /** + * Resolves once the CMP API is ready (`UC_CMP_API_READY`) or immediately if + * it already is. Resolves with `window.__ucCmp` so callers can query + * consent state without polling. + */ + whenReady: () => Promise + /** + * Subscribe to `UC_UI_CMP_EVENT` browser events (the v3 consent change + * event). Returns a teardown function. The callback receives the event + * detail, e.g. `{ type: 'ACCEPT_ALL' | 'DENY_ALL' | 'SAVE', ... }`. + */ + onConsentChange: (cb: (detail: UsercentricsCmpEventDetail, event: Event) => void) => () => void + /** Open the privacy settings (first layer banner). */ + showFirstLayer: () => Promise | void + /** Open the detailed settings (second layer modal). */ + showSecondLayer: () => Promise | void + /** Accept all consents. */ + acceptAll: () => Promise | void + /** Reject all consents. */ + denyAll: () => Promise | void +} + +/** + * Load the Usercentrics CMP v3 ("Web CMP") loader and expose typed access to + * the `window.__ucCmp` programmatic API plus a `consent` helper with + * `onConsentChange` for wiring consent triggers (`useScript({ trigger: ... })`) + * to Usercentrics events. + * + * @see https://usercentrics.com/knowledge-hub/usercentrics-cmp-v3-migrations/ + */ +export function useScriptUsercentrics( + _options?: UsercentricsInput, +): UseScriptContext { + const instance = useRegistryScript('usercentrics', (options) => { + if (import.meta.client && options.autoblocker) { + useHead({ + script: [{ + src: 'https://web.cmp.usercentrics.eu/modules/autoblocker.js', + tagPosition: 'head', + tagPriority: 'high', + }], + }) + } + return { + scriptInput: { + 'src': 'https://web.cmp.usercentrics.eu/ui/loader.js', + 'id': 'usercentrics-cmp', + 'data-ruleset-id': options.rulesetId, + 'data-language': options.language, + 'crossorigin': false, + }, + schema: import.meta.dev ? UsercentricsOptions : undefined, + scriptOptions: { + use() { + return { ucCmp: window.__ucCmp } as unknown as T + }, + }, + } + }, _options) as UseScriptContext + + if (import.meta.client && !instance.consent) { + const whenReady = (): Promise => new Promise((resolve) => { + // __ucCmp is present from loader bootstrap, but methods aren't callable + // until UC_CMP_API_READY fires. Resolve on that event (or now if it + // already fired and __ucCmp is bound). + const onReady = () => { + window.removeEventListener('UC_CMP_API_READY', onReady) + resolve(window.__ucCmp as UsercentricsCmp) + } + window.addEventListener('UC_CMP_API_READY', onReady) + }) + + instance.consent = { + whenReady, + onConsentChange(cb) { + const handler = (e: Event) => cb((e as CustomEvent).detail, e) + window.addEventListener('UC_UI_CMP_EVENT', handler) + return () => window.removeEventListener('UC_UI_CMP_EVENT', handler) + }, + showFirstLayer: () => window.__ucCmp?.showFirstLayer?.(), + showSecondLayer: () => window.__ucCmp?.showSecondLayer?.(), + acceptAll: () => window.__ucCmp?.acceptAllConsents?.(), + denyAll: () => window.__ucCmp?.denyAllConsents?.(), + } + } + + return instance +} diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 9cbcb42f..f8575584 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -39,6 +39,7 @@ import type { SnapTrPixelInput } from './registry/snapchat-pixel' import type { StripeInput } from './registry/stripe' import type { TikTokPixelInput } from './registry/tiktok-pixel' import type { UmamiAnalyticsInput } from './registry/umami-analytics' +import type { UsercentricsInput } from './registry/usercentrics' import type { VercelAnalyticsInput } from './registry/vercel-analytics' import type { VimeoPlayerInput } from './registry/vimeo-player' import type { XEmbedInput } from './registry/x-embed' @@ -263,6 +264,7 @@ export interface ScriptRegistry { vercelAnalytics?: VercelAnalyticsInput vimeoPlayer?: VimeoPlayerInput umamiAnalytics?: UmamiAnalyticsInput + usercentrics?: UsercentricsInput gravatar?: GravatarInput npm?: NpmInput [key: `${string}-npm`]: NpmInput @@ -280,7 +282,7 @@ export type BuiltInRegistryScriptKey | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'youtubePlayer' | 'vercelAnalytics' - | 'vimeoPlayer' | 'umamiAnalytics' | 'gravatar' | 'npm' + | 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm' /** * Union of all explicit registry script keys (excludes npm pattern). diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index c8d87605..937b9a88 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -208,6 +208,12 @@ export const scriptMeta = { trackedData: [], }, + // CMP / Consent + usercentrics: { + urls: ['https://web.cmp.usercentrics.eu/ui/loader.js', 'https://web.cmp.usercentrics.eu/modules/autoblocker.js'], + trackedData: [], + }, + // Identity gravatar: { urls: ['https://secure.gravatar.com/js/gprofiles.js'], diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 1992ea7c..4bce9239 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -46,6 +46,7 @@ function getPlaygroundPath(script: any): string | null { 'youtube-player': '/third-parties/youtube/nuxt-scripts', 'google-maps': '/third-parties/google-maps/nuxt-scripts', 'google-recaptcha': '/third-parties/google-recaptcha/nuxt-scripts', + 'usercentrics': '/third-parties/usercentrics/nuxt-scripts', 'npm': '/npm/js-confetti', } @@ -277,6 +278,10 @@ const benchmark = [ name: 'Ahrefs Analytics (Default)', path: '/third-parties/ahrefs-analytics/default', }, + { + name: 'Usercentrics (Default)', + path: '/third-parties/usercentrics/default', + }, { name: 'Snapchat (Default)', path: '/third-parties/snapchat/default', diff --git a/playground/pages/third-parties/usercentrics/default.vue b/playground/pages/third-parties/usercentrics/default.vue new file mode 100644 index 00000000..fe88704a --- /dev/null +++ b/playground/pages/third-parties/usercentrics/default.vue @@ -0,0 +1,25 @@ + + + diff --git a/playground/pages/third-parties/usercentrics/nuxt-scripts.vue b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue new file mode 100644 index 00000000..b3c3ba5d --- /dev/null +++ b/playground/pages/third-parties/usercentrics/nuxt-scripts.vue @@ -0,0 +1,48 @@ + + + diff --git a/test/e2e/_usercentrics-suite.ts b/test/e2e/_usercentrics-suite.ts new file mode 100644 index 00000000..7088ea69 --- /dev/null +++ b/test/e2e/_usercentrics-suite.ts @@ -0,0 +1,98 @@ +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { expect, it } from 'vitest' + +// The real Usercentrics CMP v3 loader validates `data-ruleset-id` against +// registered domains and silently no-ops on unknown origins. Tests stub the +// loader request and inject a minimal `__ucCmp` shim plus synthesised +// `UC_CMP_API_READY` / `UC_UI_CMP_EVENT` events, so behavioural assertions +// run unconditionally on CI. +async function newPage() { + const browser = await getBrowser() + const page = await browser.newPage() + await page.route('**/web.cmp.usercentrics.eu/**', route => route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: '', + })) + await page.addInitScript(() => { + const w = window as unknown as { __ucCmp: Record } + w.__ucCmp = { + isInitialized: async () => true, + isConsentRequired: async () => true, + showFirstLayer: async () => {}, + showSecondLayer: async () => {}, + acceptAllConsents: async () => { + window.dispatchEvent(new CustomEvent('UC_UI_CMP_EVENT', { + detail: { type: 'ACCEPT_ALL', source: 'explicit' }, + })) + }, + denyAllConsents: async () => { + window.dispatchEvent(new CustomEvent('UC_UI_CMP_EVENT', { + detail: { type: 'DENY_ALL', source: 'explicit' }, + })) + }, + getConsentDetails: async () => ({}), + getControllerId: async () => 'test-controller', + getActiveLanguage: async () => 'en', + } + setTimeout(() => window.dispatchEvent(new CustomEvent('UC_CMP_API_READY')), 100) + }) + return page +} + +export function defineUsercentricsSuite() { + it('renders the v3 loader script tag with id + data-ruleset-id', async () => { + const page = await newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded', timeout: 30000 }) + await page.waitForSelector('script#usercentrics-cmp', { state: 'attached', timeout: 15000 }) + const attrs = await page.evaluate(() => { + const el = document.getElementById('usercentrics-cmp') as HTMLScriptElement | null + if (!el) + return null + return { + src: el.src, + rulesetId: el.getAttribute('data-ruleset-id'), + } + }) + expect(attrs).not.toBeNull() + expect(attrs!.src).toBe('https://web.cmp.usercentrics.eu/ui/loader.js') + expect(attrs!.rulesetId).toBe('test-ruleset-id') + } + finally { + await page.close() + } + }, 60000) + + it('fires UC_UI_CMP_EVENT events that the composable surfaces via onConsentChange', async () => { + const page = await newPage() + try { + await page.goto(url('/'), { waitUntil: 'networkidle', timeout: 30000 }) + await page.waitForFunction(() => typeof (window as any).__ucCmp === 'object', undefined, { timeout: 30000 }) + await page.evaluate(() => (window as any).__ucCmp.acceptAllConsents()) + await page.waitForFunction(() => { + const text = document.querySelector('#consent-events')?.textContent || '' + const m = text.match(/events: (\d+)/) + return !!m && Number(m[1]) > 0 + }, undefined, { timeout: 15000 }) + } + finally { + await page.close() + } + }, 90000) + + // Contract test: the live loader URL still serves a body that wires the v3 + // globals/events we ship against. If Usercentrics changes any of these, the + // integration breaks even though the stubbed behavioural tests above still + // pass. Skipped on offline CI (network failure is tolerated, not asserted). + it('live loader URL still serves a body that wires __ucCmp + UC_CMP_API_READY', async () => { + const res = await fetch('https://web.cmp.usercentrics.eu/ui/loader.js').catch(() => null) + if (!res || !res.ok) { + console.warn('[usercentrics] skipping live-loader contract check; fetch failed') + return + } + const body = await res.text() + expect(body).toMatch(/UC_CMP_API_READY/) + expect(body).toMatch(/__ucCmp/) + }, 30000) +} diff --git a/test/e2e/usercentrics.test.ts b/test/e2e/usercentrics.test.ts new file mode 100644 index 00000000..a16d1ee3 --- /dev/null +++ b/test/e2e/usercentrics.test.ts @@ -0,0 +1,14 @@ +import { createResolver } from '@nuxt/kit' +import { setup } from '@nuxt/test-utils/e2e' +import { describe } from 'vitest' +import { defineUsercentricsSuite } from './_usercentrics-suite' + +const { resolve } = createResolver(import.meta.url) + +describe('usercentrics (CMP v3 loader served from web.cmp.usercentrics.eu)', async () => { + await setup({ + rootDir: resolve('../fixtures/usercentrics'), + browser: true, + }) + defineUsercentricsSuite() +}) diff --git a/test/fixtures/usercentrics/app.vue b/test/fixtures/usercentrics/app.vue new file mode 100644 index 00000000..8f62b8bf --- /dev/null +++ b/test/fixtures/usercentrics/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/usercentrics/nuxt.config.ts b/test/fixtures/usercentrics/nuxt.config.ts new file mode 100644 index 00000000..6577b8d5 --- /dev/null +++ b/test/fixtures/usercentrics/nuxt.config.ts @@ -0,0 +1,16 @@ +import { defineNuxtConfig } from 'nuxt/config' + +// Usercentrics fixture: bundle is intentionally off (see registry capabilities) +// so the loader is requested directly from web.cmp.usercentrics.eu. +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + defaultScriptOptions: { trigger: 'onNuxtReady' }, + registry: { + usercentrics: { + rulesetId: 'test-ruleset-id', + }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/usercentrics/package.json b/test/fixtures/usercentrics/package.json new file mode 100644 index 00000000..b9826b34 --- /dev/null +++ b/test/fixtures/usercentrics/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/fixtures/usercentrics/pages/index.vue b/test/fixtures/usercentrics/pages/index.vue new file mode 100644 index 00000000..69219d19 --- /dev/null +++ b/test/fixtures/usercentrics/pages/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/test/fixtures/usercentrics/tsconfig.json b/test/fixtures/usercentrics/tsconfig.json new file mode 100644 index 00000000..4b34df15 --- /dev/null +++ b/test/fixtures/usercentrics/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 06b50e7d..2f69bcc8 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -49,6 +49,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() })