diff --git a/packages/audience/core/src/cmp.test.ts b/packages/audience/core/src/cmp.test.ts new file mode 100644 index 0000000000..2c205bd6e8 --- /dev/null +++ b/packages/audience/core/src/cmp.test.ts @@ -0,0 +1,396 @@ +import { detectCmp, startCmpDetection } from './cmp'; + +// Helper: set up a fake dataLayer with a GCM consent command +function setupGcm( + analytics: 'granted' | 'denied' = 'granted', + ad: 'granted' | 'denied' = 'denied', + command: 'default' | 'update' = 'default', +): unknown[] { + const dataLayer: unknown[] = [ + ['consent', command, { analytics_storage: analytics, ad_storage: ad }], + ]; + (window as unknown as Record).dataLayer = dataLayer; + return dataLayer; +} + +// Helper: set up a fake __tcfapi +function setupTcf( + purposes: Record = {}, + gdprApplies = true, +): { fire: (purposes: Record) => void } { + const listeners: Array<{ id: number; cb: (data: unknown, success: boolean) => void }> = []; + let nextId = 1; + + const tcfapi = ( + command: string, + _version: number, + callback: (data: unknown, success: boolean) => void, + listenerId?: number, + ) => { + if (command === 'addEventListener') { + const id = nextId++; + listeners.push({ id, cb: callback }); + // Fire immediately with current state (simulates CMP already loaded) + callback( + { + gdprApplies, purpose: { consents: purposes }, listenerId: id, eventStatus: 'tcloaded', + }, + true, + ); + } + if (command === 'removeEventListener' && listenerId !== undefined) { + const idx = listeners.findIndex((l) => l.id === listenerId); + if (idx >= 0) listeners.splice(idx, 1); + } + }; + + // eslint-disable-next-line no-underscore-dangle + (window as unknown as Record).__tcfapi = tcfapi; + + return { + fire(newPurposes: Record) { + for (const l of listeners) { + l.cb( + { + gdprApplies, purpose: { consents: newPurposes }, listenerId: l.id, eventStatus: 'useractioncomplete', + }, + true, + ); + } + }, + }; +} + +function cleanup(): void { + delete (window as unknown as Record).dataLayer; + // eslint-disable-next-line no-underscore-dangle + delete (window as unknown as Record).__tcfapi; +} + +afterEach(cleanup); + +describe('CMP detection', () => { + describe('Google Consent Mode v2', () => { + it('detects GCM and returns correct source', () => { + setupGcm('granted', 'denied'); + const onUpdate = jest.fn(); + const detector = detectCmp(onUpdate); + + expect(detector).not.toBeNull(); + expect(detector!.source).toBe('gcm'); + }); + + it('maps analytics_storage denied to none', () => { + setupGcm('denied', 'denied'); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('none'); + }); + + it('maps analytics granted + ad denied to anonymous', () => { + setupGcm('granted', 'denied'); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('anonymous'); + }); + + it('maps analytics granted + ad granted to full', () => { + setupGcm('granted', 'granted'); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('full'); + }); + + it('reads the most recent consent command from dataLayer', () => { + const dataLayer: unknown[] = [ + ['consent', 'default', { analytics_storage: 'denied', ad_storage: 'denied' }], + ['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }], + ]; + (window as unknown as Record).dataLayer = dataLayer; + + const detector = detectCmp(jest.fn()); + expect(detector!.level).toBe('full'); + }); + + it('ignores non-consent dataLayer entries', () => { + const dataLayer: unknown[] = [ + ['event', 'page_view', {}], + { event: 'gtm.js' }, + ['consent', 'default', { analytics_storage: 'granted', ad_storage: 'denied' }], + ]; + (window as unknown as Record).dataLayer = dataLayer; + + const detector = detectCmp(jest.fn()); + expect(detector!.level).toBe('anonymous'); + }); + + it('returns null when dataLayer has no consent commands', () => { + (window as unknown as Record).dataLayer = [ + ['event', 'page_view'], + ]; + + const detector = detectCmp(jest.fn()); + expect(detector).toBeNull(); + }); + + it('returns null when dataLayer does not exist', () => { + const detector = detectCmp(jest.fn()); + expect(detector).toBeNull(); + }); + + it('intercepts dataLayer.push for consent updates', () => { + const dataLayer = setupGcm('granted', 'denied'); + const onUpdate = jest.fn(); + detectCmp(onUpdate); + + // Simulate a consent update via dataLayer.push + dataLayer.push(['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }]); + + expect(onUpdate).toHaveBeenCalledWith('full', 'gcm'); + }); + + it('does not fire callback for non-consent dataLayer pushes', () => { + const dataLayer = setupGcm('granted', 'denied'); + const onUpdate = jest.fn(); + detectCmp(onUpdate); + + dataLayer.push(['event', 'page_view']); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it('stops intercepting after destroy', () => { + const dataLayer = setupGcm('granted', 'denied'); + const onUpdate = jest.fn(); + const detector = detectCmp(onUpdate); + detector!.destroy(); + + dataLayer.push(['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }]); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it('restores original push on destroy', () => { + const dataLayer = setupGcm('granted', 'denied'); + const originalPush = dataLayer.push; + detectCmp(jest.fn()); + + // push was replaced + expect(dataLayer.push).not.toBe(originalPush); + + // After destroy, original push should be restored + const detector = detectCmp(jest.fn()); + detector!.destroy(); + }); + }); + + describe('IAB TCF v2', () => { + it('detects TCF and returns correct source', () => { + setupTcf({ 1: true }); + const detector = detectCmp(jest.fn()); + + expect(detector).not.toBeNull(); + expect(detector!.source).toBe('tcf'); + }); + + it('maps no purposes to none', () => { + setupTcf({}); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('none'); + }); + + it('maps purpose 1 only to anonymous', () => { + setupTcf({ + 1: true, 3: false, 4: false, 5: false, + }); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('anonymous'); + }); + + it('maps purpose 1 + purpose 3 to full', () => { + setupTcf({ 1: true, 3: true }); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('full'); + }); + + it('maps purpose 1 + purpose 4 to full', () => { + setupTcf({ 1: true, 4: true }); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('full'); + }); + + it('maps purpose 1 + purpose 5 to full', () => { + setupTcf({ 1: true, 5: true }); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('full'); + }); + + it('maps no purpose 1 to none even with other purposes', () => { + setupTcf({ 1: false, 3: true, 4: true }); + const detector = detectCmp(jest.fn()); + + expect(detector!.level).toBe('none'); + }); + + it('reacts to TCF consent changes', () => { + const tcf = setupTcf({ 1: true }); + const onUpdate = jest.fn(); + detectCmp(onUpdate); + + // Simulate user changing consent + tcf.fire({ 1: true, 3: true, 4: true }); + + expect(onUpdate).toHaveBeenCalledWith('full', 'tcf'); + }); + + it('reacts to TCF consent downgrade', () => { + const tcf = setupTcf({ 1: true, 3: true }); + const onUpdate = jest.fn(); + detectCmp(onUpdate); + + tcf.fire({ 1: true, 3: false }); + expect(onUpdate).toHaveBeenCalledWith('anonymous', 'tcf'); + }); + + it('returns null when __tcfapi does not exist', () => { + const detector = detectCmp(jest.fn()); + expect(detector).toBeNull(); + }); + }); + + describe('Detection priority', () => { + it('prefers GCM over TCF when both are present', () => { + setupGcm('granted', 'granted'); + setupTcf({ 1: true }); // would be 'anonymous' + + const detector = detectCmp(jest.fn()); + expect(detector!.source).toBe('gcm'); + expect(detector!.level).toBe('full'); + }); + + it('falls back to TCF when GCM has no consent commands', () => { + (window as unknown as Record).dataLayer = [['event', 'page_view']]; + setupTcf({ 1: true, 3: true }); + + const detector = detectCmp(jest.fn()); + expect(detector!.source).toBe('tcf'); + expect(detector!.level).toBe('full'); + }); + }); + + describe('startCmpDetection (polling)', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('detects CMP immediately when available', () => { + setupGcm('granted', 'denied'); + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + + startCmpDetection(onUpdate, onDetected); + + expect(onDetected).toHaveBeenCalledWith( + expect.objectContaining({ source: 'gcm', level: 'anonymous' }), + ); + }); + + it('polls and detects CMP that loads asynchronously', () => { + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + + startCmpDetection(onUpdate, onDetected); + expect(onDetected).not.toHaveBeenCalled(); + + // CMP loads after 1 poll interval + setupGcm('granted', 'granted'); + jest.advanceTimersByTime(800); + + expect(onDetected).toHaveBeenCalledWith( + expect.objectContaining({ source: 'gcm', level: 'full' }), + ); + }); + + it('stops polling after max attempts', () => { + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + + startCmpDetection(onUpdate, onDetected); + + // Advance past all 3 poll attempts (3 * 800ms = 2400ms) + jest.advanceTimersByTime(3000); + + expect(onDetected).not.toHaveBeenCalled(); + + // Even if CMP loads later, polling has stopped + setupGcm('granted', 'granted'); + jest.advanceTimersByTime(1000); + expect(onDetected).not.toHaveBeenCalled(); + }); + + it('cleanup function stops polling', () => { + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + + const teardown = startCmpDetection(onUpdate, onDetected); + teardown(); + + setupGcm('granted', 'granted'); + jest.advanceTimersByTime(1000); + expect(onDetected).not.toHaveBeenCalled(); + }); + + it('calls onTimeout when no CMP is found after all polls', () => { + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + const onTimeout = jest.fn(); + + startCmpDetection(onUpdate, onDetected, onTimeout); + + // Advance past all 3 poll attempts + jest.advanceTimersByTime(3000); + + expect(onDetected).not.toHaveBeenCalled(); + expect(onTimeout).toHaveBeenCalledTimes(1); + }); + + it('does not call onTimeout when CMP is detected', () => { + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + const onTimeout = jest.fn(); + + startCmpDetection(onUpdate, onDetected, onTimeout); + + // CMP loads after 1 poll + setupGcm('granted', 'denied'); + jest.advanceTimersByTime(800); + + expect(onDetected).toHaveBeenCalled(); + expect(onTimeout).not.toHaveBeenCalled(); + + // Advance past remaining polls — onTimeout should still not fire + jest.advanceTimersByTime(3000); + expect(onTimeout).not.toHaveBeenCalled(); + }); + + it('cleanup function destroys detected CMP listener', () => { + const dataLayer = setupGcm('granted', 'denied'); + const onUpdate = jest.fn(); + const onDetected = jest.fn(); + + const teardown = startCmpDetection(onUpdate, onDetected); + teardown(); + + // After teardown, consent updates should not fire + dataLayer.push(['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }]); + expect(onUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/audience/core/src/cmp.ts b/packages/audience/core/src/cmp.ts new file mode 100644 index 0000000000..fcd0c51bb9 --- /dev/null +++ b/packages/audience/core/src/cmp.ts @@ -0,0 +1,274 @@ +/** + * CMP (Consent Management Platform) auto-detection. + * + * Detects consent state from: + * 1. Google Consent Mode v2 (via `window.dataLayer`) + * 2. IAB TCF v2 (via `window.__tcfapi`) + * + * Priority: GCM → TCF → fall back to 'none'. + * Once a CMP is detected, registers a listener for ongoing consent changes. + */ +import type { ConsentLevel } from './types'; + +// -- Types for external CMP globals ------------------------------------------ + +interface GcmConsentParams { + analytics_storage?: 'granted' | 'denied'; + ad_storage?: 'granted' | 'denied'; + [key: string]: unknown; +} + +interface TcfData { + gdprApplies?: boolean; + purpose?: { consents?: Record }; + listenerId?: number; + eventStatus?: string; +} + +type TcfCallback = (data: TcfData, success: boolean) => void; + +type TcfApi = ( + command: string, + version: number, + callback: TcfCallback, + listenerId?: number, +) => void; + +export type CmpSource = 'gcm' | 'tcf'; + +export type ConsentCallback = (level: ConsentLevel, source: CmpSource) => void; + +export interface CmpDetector { + /** The CMP source that was detected, or null if none found. */ + source: CmpSource | null; + /** The initial consent level read from the CMP. */ + level: ConsentLevel; + /** Stop listening for consent changes and clean up. */ + destroy: () => void; +} + +// -- Google Consent Mode v2 --------------------------------------------------- + +/** + * Map GCM consent parameters to our three-tier consent level. + */ +function mapGcmConsent(params: GcmConsentParams): ConsentLevel { + if (params.analytics_storage !== 'granted') return 'none'; + if (params.ad_storage === 'granted') return 'full'; + return 'anonymous'; +} + +/** + * Scan the dataLayer for the most recent Google Consent Mode default/update + * command and return the consent parameters, or null if none found. + */ +function readGcmFromDataLayer( + dataLayer: unknown[], +): GcmConsentParams | null { + let latest: GcmConsentParams | null = null; + + for (let i = 0; i < dataLayer.length; i++) { + const entry = dataLayer[i]; + // gtag pushes [command, ...args] or {event, ...} — consent commands are: + // ['consent', 'default', {analytics_storage: ...}] + // ['consent', 'update', {analytics_storage: ...}] + if ( + Array.isArray(entry) + && entry[0] === 'consent' + && (entry[1] === 'default' || entry[1] === 'update') + && entry[2] + && typeof entry[2] === 'object' + ) { + latest = entry[2] as GcmConsentParams; + } + } + + return latest; +} + +/** + * Try to detect Google Consent Mode v2 and listen for changes. + * Returns a CmpDetector if GCM is present, or null. + */ +function detectGcm(onUpdate: ConsentCallback): CmpDetector | null { + const win = window as unknown as Record; + const dataLayer = win.dataLayer as unknown[] | undefined; + if (!Array.isArray(dataLayer)) return null; + + // Read initial state + const initial = readGcmFromDataLayer(dataLayer); + if (!initial) return null; + + const level = mapGcmConsent(initial); + + // Intercept future dataLayer.push() calls to watch for consent updates. + // This catches both gtag('consent','update',...) and direct dataLayer pushes. + const originalPush = dataLayer.push.bind(dataLayer); + let destroyed = false; + + const interceptor = (...args: unknown[]): number => { + const result = originalPush(...args); + if (destroyed) return result; + + for (const arg of args) { + if ( + Array.isArray(arg) + && arg[0] === 'consent' + && arg[1] === 'update' + && arg[2] + && typeof arg[2] === 'object' + ) { + onUpdate(mapGcmConsent(arg[2] as GcmConsentParams), 'gcm'); + } + } + return result; + }; + + dataLayer.push = interceptor; + + return { + source: 'gcm', + level, + destroy() { + destroyed = true; + // Restore original push — only if our interceptor is still the active one + if (dataLayer.push === interceptor) { + dataLayer.push = originalPush; + } + }, + }; +} + +// -- IAB TCF v2 --------------------------------------------------------------- + +/** + * Map TCF purpose consents to our three-tier consent level. + * + * Purpose 1: Store/access info on a device + * Purpose 3: Create personalised ads profile + * Purpose 4: Select personalised ads + * Purpose 5: Create personalised content profile + * + * Mapping: + * No Purpose 1 → 'none' + * Purpose 1 only → 'anonymous' + * Purpose 1 + any of (3,4,5) → 'full' + */ +function mapTcfConsent(data: TcfData): ConsentLevel { + const purposes = data.purpose?.consents; + if (!purposes) return 'none'; + if (!purposes[1]) return 'none'; + if (purposes[3] || purposes[4] || purposes[5]) return 'full'; + return 'anonymous'; +} + +/** + * Try to detect IAB TCF v2 and listen for changes. + * Returns a CmpDetector if TCF is present, or null. + */ +function detectTcf(onUpdate: ConsentCallback): CmpDetector | null { + const win = window as unknown as Record; + // eslint-disable-next-line no-underscore-dangle + const tcfapi = win.__tcfapi as TcfApi | undefined; + if (typeof tcfapi !== 'function') return null; + + let initialLevel: ConsentLevel = 'none'; + let listenerId: number | undefined; + let resolved = false; + + // getTCData gives us the current state synchronously (callback fires immediately + // if CMP has already loaded consent). + tcfapi('addEventListener', 2, (data: TcfData, success: boolean) => { + if (!success) return; + + // Store listenerId for cleanup + if (data.listenerId !== undefined) { + listenerId = data.listenerId; + } + + const level = mapTcfConsent(data); + + if (!resolved) { + // First callback — this is the initial state + initialLevel = level; + resolved = true; + } else { + // Subsequent callbacks — consent changed + onUpdate(level, 'tcf'); + } + }); + + // If the callback never fired synchronously, tcfapi exists but CMP hasn't + // loaded consent yet — we'll get it via the addEventListener callback later. + return { + source: 'tcf', + level: initialLevel, + destroy() { + if (listenerId !== undefined && typeof tcfapi === 'function') { + tcfapi('removeEventListener', 2, () => {}, listenerId); + } + }, + }; +} + +// -- Public API --------------------------------------------------------------- + +const POLL_INTERVAL = 800; // ms between retries +const MAX_POLLS = 3; // 3 attempts over ~2.4s total + +/** + * Attempt to detect a CMP on the page. + * + * Detection priority: Google Consent Mode v2 → IAB TCF v2 → null. + */ +export function detectCmp(onUpdate: ConsentCallback): CmpDetector | null { + return detectGcm(onUpdate) ?? detectTcf(onUpdate) ?? null; +} + +/** + * Start CMP detection with polling for async CMP loading. + * + * Many CMPs load asynchronously (e.g. OneTrust script injected by a tag manager). + * This function tries detection immediately, then polls up to MAX_POLLS times. + * Once a CMP is found, polling stops and the callback is invoked for future changes. + * + * If no CMP is found after all polling attempts, `onTimeout` is called so callers + * can distinguish "CMP said none" from "no CMP found at all". + * + * Returns a cleanup function that stops polling and tears down CMP listeners. + */ +export function startCmpDetection( + onUpdate: ConsentCallback, + onDetected: (detector: CmpDetector) => void, + onTimeout?: () => void, +): () => void { + // Try immediately + let detector = detectCmp(onUpdate); + if (detector) { + onDetected(detector); + return () => detector!.destroy(); + } + + // Poll for async CMP loading + let pollCount = 0; + const timer = setInterval(() => { + pollCount++; + detector = detectCmp(onUpdate); + + if (detector) { + clearInterval(timer); + onDetected(detector); + return; + } + + if (pollCount >= MAX_POLLS) { + clearInterval(timer); + onTimeout?.(); + } + }, POLL_INTERVAL); + + return () => { + clearInterval(timer); + if (detector) detector.destroy(); + }; +} diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 64a3c55ab8..b3bcedb077 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -52,3 +52,6 @@ export type { Attribution } from './attribution'; export { createConsentManager, detectDoNotTrack } from './consent'; export type { ConsentManager } from './consent'; + +export { detectCmp, startCmpDetection } from './cmp'; +export type { CmpSource, ConsentCallback, CmpDetector } from './cmp'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index 4b238d8ab7..e9a9d7390c 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -7,6 +7,10 @@ jest.mock('./autocapture', () => ({ setupAutocapture: (...args: unknown[]) => mockSetupAutocapture(...args), })); +// CMP detection mock — defined here, wired into the audience-core mock below +const mockTeardownCmp = jest.fn(); +const mockStartCmpDetection = jest.fn().mockReturnValue(mockTeardownCmp); + // Mock audience-core const mockEnqueue = jest.fn(); const mockStart = jest.fn(); @@ -50,6 +54,7 @@ jest.mock('@imtbl/audience-core', () => ({ landing_page: 'https://example.com', }), getOrCreateSession: (...args: unknown[]) => mockGetOrCreateSession(...args), + startCmpDetection: (...args: unknown[]) => mockStartCmpDetection(...args), createConsentManager: jest.fn().mockImplementation( ( _queue: unknown, @@ -336,16 +341,30 @@ describe('Pixel', () => { }); describe('setConsent', () => { - it('updates consent level', () => { + it('updates consent level and allows tracking', () => { const pixel = new Pixel(); activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); pixel.setConsent('anonymous'); - mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false }); - pixel.page(); - expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' })); + // setConsent should have auto-fired the deferred initial page view + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeDefined(); + }); + + it('fires deferred page view only once on repeated setConsent calls', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + pixel.setConsent('anonymous'); + mockEnqueue.mockClear(); + + // Second setConsent should NOT fire another page view + pixel.setConsent('full'); + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeUndefined(); }); }); @@ -374,6 +393,160 @@ describe('Pixel', () => { }); }); + describe('consentMode: auto', () => { + it('starts CMP detection when consentMode is auto', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + + expect(mockStartCmpDetection).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + ); + }); + + it('does not start CMP detection when consentMode is not auto', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + expect(mockStartCmpDetection).not.toHaveBeenCalled(); + }); + + it('starts at consent none and does not fire page view', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + + // No events should be enqueued — waiting for CMP detection + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('fires page view when CMP detection upgrades consent', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + mockEnqueue.mockClear(); + + // Simulate CMP detected with anonymous consent + const onDetected = mockStartCmpDetection.mock.calls[0][1]; + onDetected({ source: 'gcm', level: 'anonymous', destroy: jest.fn() }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeDefined(); + }); + + it('fires page view when CMP update callback upgrades from none', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + mockEnqueue.mockClear(); + + // Simulate CMP update callback (ongoing consent change) + const onUpdate = mockStartCmpDetection.mock.calls[0][0]; + onUpdate('anonymous', 'gcm'); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeDefined(); + }); + + it('does not fire duplicate page view on subsequent CMP updates', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + + // First upgrade — fires page view + const onUpdate = mockStartCmpDetection.mock.calls[0][0]; + onUpdate('anonymous', 'gcm'); + mockEnqueue.mockClear(); + + // Second update (anonymous → full) — should NOT fire another page view + onUpdate('full', 'gcm'); + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeUndefined(); + }); + + it('does not fire page view when CMP detects consent as none', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + mockEnqueue.mockClear(); + + const onDetected = mockStartCmpDetection.mock.calls[0][1]; + onDetected({ source: 'gcm', level: 'none', destroy: jest.fn() }); + + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('tears down CMP detection on destroy', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + + pixel.destroy(); + expect(mockTeardownCmp).toHaveBeenCalled(); + }); + + it('consentMode auto starts consent at none regardless of consent param', () => { + const { createConsentManager } = jest.requireMock('@imtbl/audience-core') as { + createConsentManager: jest.Mock; + }; + createConsentManager.mockClear(); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ + key: 'pk_test', + environment: 'dev', + consent: 'anonymous', + consentMode: 'auto', + }); + + // consentMode: 'auto' should start at 'none' regardless of consent param + expect(createConsentManager).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'pk_test', + 'anon-123', + 'dev', + 'pixel', + 'none', + ); + }); + + it('setConsent fires deferred page view when CMP detection has not upgraded consent', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + + // No page view yet — waiting for CMP + expect(mockEnqueue).not.toHaveBeenCalled(); + mockEnqueue.mockClear(); + + // Manually set consent as a fallback (e.g. CMP detection timed out) + pixel.setConsent('anonymous'); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeDefined(); + }); + + it('setConsent does not fire duplicate page view after CMP already upgraded', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consentMode: 'auto' }); + + // CMP upgrades consent first + const onUpdate = mockStartCmpDetection.mock.calls[0][0]; + onUpdate('anonymous', 'gcm'); + mockEnqueue.mockClear(); + + // Manual setConsent should NOT fire another page view + pixel.setConsent('full'); + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'page')).toBeUndefined(); + }); + }); + describe('autocapture integration', () => { it('sets up autocapture with default options on init', () => { const pixel = new Pixel(); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index d9f5fdf95d..2957d00bfe 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -6,6 +6,7 @@ import type { IdentifyMessage, UserTraits, ConsentManager, + CmpDetector, IdentityType, } from '@imtbl/audience-core'; import { @@ -24,6 +25,7 @@ import { collectAttribution, getOrCreateSession, createConsentManager, + startCmpDetection, } from '@imtbl/audience-core'; import { setupAutocapture } from './autocapture'; import type { AutocaptureOptions } from './autocapture'; @@ -38,6 +40,8 @@ export interface PixelInitOptions { key: string; environment?: Environment; consent?: ConsentLevel; + /** Set to 'auto' to auto-detect consent from CMPs (Google Consent Mode, IAB TCF). */ + consentMode?: 'auto'; domain?: string; autocapture?: AutocaptureOptions; } @@ -67,6 +71,10 @@ export class Pixel { private teardownAutocapture?: () => void; + private teardownCmp?: () => void; + + private initialPageViewFired = false; + init(options: PixelInitOptions): void { if (this.initialized) return; @@ -74,6 +82,7 @@ export class Pixel { key, environment = 'production', consent: consentLevel, + consentMode, domain, autocapture, } = options; @@ -95,6 +104,10 @@ export class Pixel { this.anonymousId = getOrCreateAnonymousId(domain); + // Resolve initial consent level. + // 'auto' starts at 'none' until a CMP is detected. + const isAutoConsent = consentMode === 'auto'; + this.consent = createConsentManager( this.queue, httpSend, @@ -102,7 +115,7 @@ export class Pixel { this.anonymousId, environment, 'pixel', - consentLevel, + isAutoConsent ? 'none' : consentLevel, ); this.initialized = true; @@ -113,8 +126,12 @@ export class Pixel { this.registerSessionEnd(); this.queue.start(); - // Auto-fire page view if consent allows - if (this.consent.level !== 'none') { + if (isAutoConsent) { + // CMP detection will fire the deferred page view when consent upgrades. + this.startCmpDetection(); + } else if (this.consent.level !== 'none') { + // Static consent — fire page view immediately. + this.initialPageViewFired = true; this.page(); } @@ -173,10 +190,22 @@ export class Pixel { setConsent(level: ConsentLevel): void { if (!this.isReady()) return; this.consent!.setLevel(level); + + // Fire the deferred page view if consent was upgraded from 'none' + // (covers the case where CMP detection failed and the caller + // manually sets consent as a fallback). + if (level !== 'none' && !this.initialPageViewFired) { + this.initialPageViewFired = true; + this.page(); + } } destroy(): void { this.removeSessionEnd(); + if (this.teardownCmp) { + this.teardownCmp(); + this.teardownCmp = undefined; + } if (this.teardownAutocapture) { this.teardownAutocapture(); this.teardownAutocapture = undefined; @@ -189,6 +218,31 @@ export class Pixel { this.initialized = false; } + // -- CMP auto-detection --------------------------------------------------- + + private startCmpDetection(): void { + const onCmpUpdate = (level: ConsentLevel): void => { + if (!this.isReady()) return; + this.consent!.setLevel(level); + + // Fire the deferred page view on first consent upgrade from 'none'. + if (level !== 'none' && !this.initialPageViewFired) { + this.initialPageViewFired = true; + this.page(); + } + }; + + this.teardownCmp = startCmpDetection( + onCmpUpdate, + (detector: CmpDetector) => { + // CMP found — apply the initial consent level it reported. + if (detector.level !== 'none') { + onCmpUpdate(detector.level); + } + }, + ); + } + // -- Auto-capture helper -------------------------------------------------- private track(eventName: string, properties: Record): void {