From eb986255ab0ae521bc8d99d1c73612b4d3687faf Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Thu, 9 Apr 2026 16:35:25 +1000 Subject: [PATCH 1/4] feat(audience): automatic CMP consent detection (Google Consent Mode v2 + IAB TCF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add consentMode: 'auto' init option to the pixel that auto-detects consent from existing Consent Management Platforms on the page, removing the need for studios to write custom JS to wire up consent. Supported CMPs: - Google Consent Mode v2 (via window.dataLayer) — covers OneTrust, Cookiebot, TrustArc, Didomi, Osano and most major CMPs - IAB TCF v2 (via window.__tcfapi) — vendor-agnostic standard for EU markets Detection polls up to 3 times over ~2.4s for async CMP loading, then registers listeners for real-time consent changes mid-session. CMP detection lives in @imtbl/audience-core so the web SDK can reuse it. Refs: SDK-87 Co-Authored-By: Claude Opus 4.6 --- packages/audience/core/src/cmp.test.ts | 355 ++++++++++++++++++++++ packages/audience/core/src/cmp.ts | 268 ++++++++++++++++ packages/audience/core/src/index.ts | 3 + packages/audience/pixel/src/pixel.test.ts | 126 ++++++++ packages/audience/pixel/src/pixel.ts | 52 +++- 5 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 packages/audience/core/src/cmp.test.ts create mode 100644 packages/audience/core/src/cmp.ts diff --git a/packages/audience/core/src/cmp.test.ts b/packages/audience/core/src/cmp.test.ts new file mode 100644 index 0000000000..c90677dd77 --- /dev/null +++ b/packages/audience/core/src/cmp.test.ts @@ -0,0 +1,355 @@ +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); + } + }; + + (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; + 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('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..d19b38c6c3 --- /dev/null +++ b/packages/audience/core/src/cmp.ts @@ -0,0 +1,268 @@ +/** + * 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; + 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. + * + * Returns a cleanup function that stops polling and tears down CMP listeners. + */ +export function startCmpDetection( + onUpdate: ConsentCallback, + onDetected: (detector: CmpDetector) => 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); + } + }, 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 3ab824a4ea..02ad584648 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -60,3 +60,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 4bb9785357..6f344f9409 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(); @@ -51,6 +55,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, _key: unknown, _anonId: unknown, _env: unknown, _source: unknown, level?: string) => { let current = level ?? 'none'; @@ -347,6 +352,127 @@ 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 static level takes precedence over 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: 'full', + }); + + // consentMode: 'full' should win over consent: 'anonymous' + expect(createConsentManager).toHaveBeenCalledWith( + expect.anything(), + 'pk_test', + 'anon-123', + 'dev', + 'pixel', + 'full', + ); + }); + }); + 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 99de797555..9e87201234 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, } from '@imtbl/audience-core'; import { MessageQueue, @@ -23,6 +24,7 @@ import { collectAttribution, getOrCreateSession, createConsentManager, + startCmpDetection, } from '@imtbl/audience-core'; import { setupAutocapture } from './autocapture'; import type { AutocaptureOptions } from './autocapture'; @@ -37,6 +39,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' | ConsentLevel; domain?: string; autocapture?: AutocaptureOptions; } @@ -66,6 +70,8 @@ export class Pixel { private teardownAutocapture?: () => void; + private teardownCmp?: () => void; + init(options: PixelInitOptions): void { if (this.initialized) return; @@ -73,6 +79,7 @@ export class Pixel { key, environment = 'production', consent: consentLevel, + consentMode, domain, autocapture, } = options; @@ -94,13 +101,21 @@ export class Pixel { this.anonymousId = getOrCreateAnonymousId(domain); + // Resolve initial consent level. + // consentMode takes precedence over consent when set to a static level. + // 'auto' starts at 'none' until a CMP is detected. + const isAutoConsent = consentMode === 'auto'; + const staticLevel: ConsentLevel | undefined = isAutoConsent + ? undefined + : (consentMode as ConsentLevel | undefined) ?? consentLevel; + this.consent = createConsentManager( this.queue, key, this.anonymousId, environment, 'pixel', - consentLevel, + isAutoConsent ? 'none' : staticLevel, ); this.initialized = true; @@ -111,6 +126,12 @@ export class Pixel { this.registerSessionEnd(); this.queue.start(); + // If consentMode is 'auto', start CMP detection. + // The pixel begins at 'none' and upgrades when a CMP is found. + if (isAutoConsent) { + this.startCmpDetection(); + } + // Auto-fire page view if consent allows if (this.consent.level !== 'none') { this.page(); @@ -174,6 +195,10 @@ export class Pixel { destroy(): void { this.removeSessionEnd(); + if (this.teardownCmp) { + this.teardownCmp(); + this.teardownCmp = undefined; + } if (this.teardownAutocapture) { this.teardownAutocapture(); this.teardownAutocapture = undefined; @@ -186,6 +211,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); + + // If this is the first consent upgrade from 'none', fire the initial + // page view that was deferred at init. + if (level !== 'none' && !this.sessionId) { + 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 { From e66df76e3aff5db7860bb9dc2584ea041a1b82ee Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Thu, 9 Apr 2026 17:24:34 +1000 Subject: [PATCH 2/4] fix(audience): fix eslint errors in CMP module Add eslint-disable for no-underscore-dangle on __tcfapi (external API name). Fix object-curly-newline formatting in test file. Co-Authored-By: Claude Opus 4.6 --- packages/audience/core/src/cmp.test.ts | 14 +++++++++++--- packages/audience/core/src/cmp.ts | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/audience/core/src/cmp.test.ts b/packages/audience/core/src/cmp.test.ts index c90677dd77..d37aa65429 100644 --- a/packages/audience/core/src/cmp.test.ts +++ b/packages/audience/core/src/cmp.test.ts @@ -32,7 +32,9 @@ function setupTcf( listeners.push({ id, cb: callback }); // Fire immediately with current state (simulates CMP already loaded) callback( - { gdprApplies, purpose: { consents: purposes }, listenerId: id, eventStatus: 'tcloaded' }, + { + gdprApplies, purpose: { consents: purposes }, listenerId: id, eventStatus: 'tcloaded', + }, true, ); } @@ -42,13 +44,16 @@ function setupTcf( } }; + // 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' }, + { + gdprApplies, purpose: { consents: newPurposes }, listenerId: l.id, eventStatus: 'useractioncomplete', + }, true, ); } @@ -58,6 +63,7 @@ function setupTcf( function cleanup(): void { delete (window as unknown as Record).dataLayer; + // eslint-disable-next-line no-underscore-dangle delete (window as unknown as Record).__tcfapi; } @@ -193,7 +199,9 @@ describe('CMP detection', () => { }); it('maps purpose 1 only to anonymous', () => { - setupTcf({ 1: true, 3: false, 4: false, 5: false }); + setupTcf({ + 1: true, 3: false, 4: false, 5: false, + }); const detector = detectCmp(jest.fn()); expect(detector!.level).toBe('anonymous'); diff --git a/packages/audience/core/src/cmp.ts b/packages/audience/core/src/cmp.ts index d19b38c6c3..8d158b6bdc 100644 --- a/packages/audience/core/src/cmp.ts +++ b/packages/audience/core/src/cmp.ts @@ -168,6 +168,7 @@ function mapTcfConsent(data: TcfData): ConsentLevel { */ 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; From 942391623fc583c178be8f1061698cb870202076 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Fri, 10 Apr 2026 12:13:49 +1000 Subject: [PATCH 3/4] fix(audience): address PR review feedback for CMP consent detection - Add onTimeout callback to startCmpDetection for distinguishing "CMP said none" from "no CMP found" - Use else-if to prevent duplicate page view when CMP detects synchronously - Replace sessionId guard with initialPageViewFired flag for reliable deferred page view tracking - Narrow consentMode type to literal 'auto' (remove ConsentLevel union) - Add setConsent() deferred page view as fallback for failed CMP detection - Update tests for all fixes Co-Authored-By: Claude Opus 4.6 --- packages/audience/core/src/cmp.test.ts | 33 +++++++++++++++++++ packages/audience/core/src/cmp.ts | 5 +++ packages/audience/pixel/src/pixel.test.ts | 40 ++++++++++++++++++++--- packages/audience/pixel/src/pixel.ts | 34 ++++++++++--------- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/packages/audience/core/src/cmp.test.ts b/packages/audience/core/src/cmp.test.ts index d37aa65429..2c205bd6e8 100644 --- a/packages/audience/core/src/cmp.test.ts +++ b/packages/audience/core/src/cmp.test.ts @@ -347,6 +347,39 @@ describe('CMP detection', () => { 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(); diff --git a/packages/audience/core/src/cmp.ts b/packages/audience/core/src/cmp.ts index 8d158b6bdc..fcd0c51bb9 100644 --- a/packages/audience/core/src/cmp.ts +++ b/packages/audience/core/src/cmp.ts @@ -232,11 +232,15 @@ export function detectCmp(onUpdate: ConsentCallback): CmpDetector | null { * 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); @@ -259,6 +263,7 @@ export function startCmpDetection( if (pollCount >= MAX_POLLS) { clearInterval(timer); + onTimeout?.(); } }, POLL_INTERVAL); diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index 1179b79ff5..bff4f224a3 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -473,7 +473,7 @@ describe('Pixel', () => { expect(mockTeardownCmp).toHaveBeenCalled(); }); - it('consentMode static level takes precedence over consent param', () => { + it('consentMode auto starts consent at none regardless of consent param', () => { const { createConsentManager } = jest.requireMock('@imtbl/audience-core') as { createConsentManager: jest.Mock; }; @@ -485,10 +485,10 @@ describe('Pixel', () => { key: 'pk_test', environment: 'dev', consent: 'anonymous', - consentMode: 'full', + consentMode: 'auto', }); - // consentMode: 'full' should win over consent: 'anonymous' + // consentMode: 'auto' should start at 'none' regardless of consent param expect(createConsentManager).toHaveBeenCalledWith( expect.anything(), expect.anything(), @@ -496,9 +496,41 @@ describe('Pixel', () => { 'anon-123', 'dev', 'pixel', - 'full', + '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', () => { diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index 7b551cac08..2957d00bfe 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -41,7 +41,7 @@ export interface PixelInitOptions { environment?: Environment; consent?: ConsentLevel; /** Set to 'auto' to auto-detect consent from CMPs (Google Consent Mode, IAB TCF). */ - consentMode?: 'auto' | ConsentLevel; + consentMode?: 'auto'; domain?: string; autocapture?: AutocaptureOptions; } @@ -73,6 +73,8 @@ export class Pixel { private teardownCmp?: () => void; + private initialPageViewFired = false; + init(options: PixelInitOptions): void { if (this.initialized) return; @@ -103,12 +105,8 @@ export class Pixel { this.anonymousId = getOrCreateAnonymousId(domain); // Resolve initial consent level. - // consentMode takes precedence over consent when set to a static level. // 'auto' starts at 'none' until a CMP is detected. const isAutoConsent = consentMode === 'auto'; - const staticLevel: ConsentLevel | undefined = isAutoConsent - ? undefined - : (consentMode as ConsentLevel | undefined) ?? consentLevel; this.consent = createConsentManager( this.queue, @@ -117,7 +115,7 @@ export class Pixel { this.anonymousId, environment, 'pixel', - isAutoConsent ? 'none' : staticLevel, + isAutoConsent ? 'none' : consentLevel, ); this.initialized = true; @@ -128,14 +126,12 @@ export class Pixel { this.registerSessionEnd(); this.queue.start(); - // If consentMode is 'auto', start CMP detection. - // The pixel begins at 'none' and upgrades when a CMP is found. if (isAutoConsent) { + // CMP detection will fire the deferred page view when consent upgrades. this.startCmpDetection(); - } - - // Auto-fire page view if consent allows - if (this.consent.level !== 'none') { + } else if (this.consent.level !== 'none') { + // Static consent — fire page view immediately. + this.initialPageViewFired = true; this.page(); } @@ -194,6 +190,14 @@ 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 { @@ -221,9 +225,9 @@ export class Pixel { if (!this.isReady()) return; this.consent!.setLevel(level); - // If this is the first consent upgrade from 'none', fire the initial - // page view that was deferred at init. - if (level !== 'none' && !this.sessionId) { + // Fire the deferred page view on first consent upgrade from 'none'. + if (level !== 'none' && !this.initialPageViewFired) { + this.initialPageViewFired = true; this.page(); } }; From 2fc8664fc59f00f6bcbe0ad856ca3b44c3491fad Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Fri, 10 Apr 2026 12:23:29 +1000 Subject: [PATCH 4/4] test(pixel): improve setConsent tests for deferred page view behavior Replace weak setConsent test with two precise tests that verify: - setConsent auto-fires the deferred initial page view on consent upgrade - Repeated setConsent calls don't fire duplicate page views Co-Authored-By: Claude Opus 4.6 --- packages/audience/pixel/src/pixel.test.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index bff4f224a3..e9a9d7390c 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -341,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(); }); });