diff --git a/packages/audience/pixel/src/bootstrap.test.ts b/packages/audience/pixel/src/bootstrap.test.ts new file mode 100644 index 0000000000..f811564609 --- /dev/null +++ b/packages/audience/pixel/src/bootstrap.test.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-require-imports, global-require */ + +// We need to control when bootstrap.ts runs, so we use dynamic require +// after setting up the window stub and mocks. + +const mockInit = jest.fn(); +const mockPage = jest.fn(); +const mockIdentify = jest.fn(); +const mockSetConsent = jest.fn(); +const mockDestroy = jest.fn(); + +jest.mock('./pixel', () => ({ + Pixel: jest.fn().mockImplementation(() => ({ + init: mockInit, + page: mockPage, + identify: mockIdentify, + setConsent: mockSetConsent, + destroy: mockDestroy, + })), +})); + +jest.mock('@imtbl/audience-core', () => ({ + MessageQueue: jest.fn(), + httpTransport: {}, + getBaseUrl: jest.fn(), + INGEST_PATH: '', + FLUSH_INTERVAL_MS: 5000, + FLUSH_SIZE: 20, + getOrCreateAnonymousId: jest.fn(), + collectContext: jest.fn(), + generateId: jest.fn(), + getTimestamp: jest.fn(), + isBrowser: jest.fn().mockReturnValue(true), + getCookie: jest.fn(), + setCookie: jest.fn(), + collectAttribution: jest.fn().mockReturnValue({}), + getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 's', isNew: false }), + createConsentManager: jest.fn().mockReturnValue({ level: 'none', setLevel: jest.fn() }), +})); + +beforeEach(() => { + jest.clearAllMocks(); + delete (window as Record).__imtbl; + // Re-isolate module so the side-effect runs fresh + jest.resetModules(); +}); + +describe('bootstrap', () => { + it('replays queued init command from snippet stub', () => { + // Simulate snippet having queued an init command + (window as Record).__imtbl = [ + ['init', { key: 'pk_test', environment: 'dev', consent: 'anonymous' }], + ]; + + require('./bootstrap'); + + expect(mockInit).toHaveBeenCalledWith({ + key: 'pk_test', + environment: 'dev', + consent: 'anonymous', + }); + }); + + it('replays multiple queued commands in order', () => { + (window as Record).__imtbl = [ + ['init', { key: 'pk_test' }], + ['consent', 'full'], + ['identify', 'user-1', { email: 'a@b.com' }], + ]; + + require('./bootstrap'); + + expect(mockInit).toHaveBeenCalledWith({ key: 'pk_test' }); + expect(mockSetConsent).toHaveBeenCalledWith('full'); + expect(mockIdentify).toHaveBeenCalledWith('user-1', { email: 'a@b.com' }); + }); + + it('installs loader and handles new commands after load', () => { + require('./bootstrap'); + + const loader = (window as Record).__imtbl as { + push: (...args: unknown[]) => void; + _loaded: boolean; + }; + + expect(loader._loaded).toBe(true); + + loader.push(['page', { custom: 'prop' }]); + expect(mockPage).toHaveBeenCalledWith({ custom: 'prop' }); + + loader.push(['consent', 'anonymous']); + expect(mockSetConsent).toHaveBeenCalledWith('anonymous'); + }); + + it('ignores unknown commands', () => { + (window as Record).__imtbl = [ + ['nonexistent', 'arg1'], + ]; + + // Should not throw + expect(() => require('./bootstrap')).not.toThrow(); + }); + + it('works when no stub exists on window', () => { + expect(() => require('./bootstrap')).not.toThrow(); + + const loader = (window as Record).__imtbl as { + _loaded: boolean; + }; + expect(loader._loaded).toBe(true); + }); +}); diff --git a/packages/audience/pixel/src/bootstrap.ts b/packages/audience/pixel/src/bootstrap.ts new file mode 100644 index 0000000000..4d13470f7c --- /dev/null +++ b/packages/audience/pixel/src/bootstrap.ts @@ -0,0 +1,41 @@ +/** + * Self-executing bootstrap that wires the command-queue loader to the Pixel. + * + * When the IIFE bundle loads, this module: + * 1. Creates a Pixel singleton + * 2. Maps command names to Pixel methods + * 3. Installs the loader on window.__imtbl (replacing the stub array) + * 4. Replays any commands the snippet queued before the script loaded + */ +import { Pixel } from './pixel'; +import { createLoader } from './loader'; +import type { Command } from './loader'; + +const pixel = new Pixel(); + +function handleCommand(command: Command): void { + const [name, ...args] = command; + + switch (name) { + case 'init': + pixel.init(args[0] as Parameters[0]); + break; + case 'page': + pixel.page(args[0] as Parameters[0]); + break; + case 'identify': + pixel.identify( + args[0] as string, + args[1] as Parameters[1], + ); + break; + case 'consent': + pixel.setConsent(args[0] as Parameters[0]); + break; + default: + // Unknown command — ignore silently + break; + } +} + +createLoader(handleCommand); diff --git a/packages/audience/pixel/src/iife.ts b/packages/audience/pixel/src/iife.ts new file mode 100644 index 0000000000..e305eb2250 --- /dev/null +++ b/packages/audience/pixel/src/iife.ts @@ -0,0 +1,8 @@ +/** + * IIFE entry point for the CDN bundle (dist/imtbl.js). + * + * Only imports the bootstrap side-effect — no development utilities + * like snippet generator or createLoader are exposed, keeping the + * bundle as small as possible. + */ +import './bootstrap'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index dec2505901..a37a0079f1 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -58,6 +58,8 @@ jest.mock('@imtbl/audience-core', () => ({ // Mock fetch globally global.fetch = jest.fn().mockResolvedValue({ ok: true }); +const mockGetCookie = jest.requireMock('@imtbl/audience-core').getCookie as jest.Mock; + let activePixel: Pixel | null = null; beforeEach(() => { @@ -166,6 +168,47 @@ describe('Pixel', () => { pixel.page(); expect(mockEnqueue).not.toHaveBeenCalled(); }); + + it('includes GA and Meta cookies in page properties when present', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + mockGetCookie.mockImplementation((name: string) => { + const cookies: Record = { + _ga: 'GA1.2.123456.789012', + _fbc: 'fb.1.1234567890.AbCdEf', + _fbp: 'fb.1.1234567890.987654321', + }; + return cookies[name]; + }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const props = pageCall!.properties as Record; + + expect(props.gaClientId).toBe('GA1.2.123456.789012'); + expect(props.fbClickId).toBe('fb.1.1234567890.AbCdEf'); + expect(props.fbBrowserId).toBe('fb.1.1234567890.987654321'); + }); + + it('omits third-party IDs when cookies are not set', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + mockGetCookie.mockReturnValue(undefined); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const props = pageCall!.properties as Record; + + expect(props.gaClientId).toBeUndefined(); + expect(props.fbClickId).toBeUndefined(); + expect(props.fbBrowserId).toBeUndefined(); + }); }); describe('identify', () => { diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index d646212baa..c68458d644 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -19,6 +19,7 @@ import { generateId, getTimestamp, isBrowser, + getCookie, collectAttribution, getOrCreateSession, createConsentManager, @@ -96,15 +97,18 @@ export class Pixel { consentLevel, ); - this.queue.start(); this.initialized = true; + // Register session_end listener BEFORE starting the queue so that + // on page unload, session_end is enqueued before the queue flushes. + // DOM event listeners fire in registration order. + this.registerSessionEnd(); + this.queue.start(); + // Auto-fire page view if consent allows if (this.consent.level !== 'none') { this.page(); } - - this.registerSessionEnd(); } page(properties?: Record): void { @@ -113,12 +117,14 @@ export class Pixel { const { sessionId, isNew } = getOrCreateSession(this.domain); this.refreshSession(sessionId, isNew); const attribution = collectAttribution(); + const thirdPartyIds = this.collectThirdPartyIds(); const message: PageMessage = { ...this.buildBase(), type: 'page', properties: { ...attribution, + ...thirdPartyIds, sessionId, ...properties, }, @@ -230,6 +236,25 @@ export class Pixel { } } + // -- Third-party identity signals ---------------------------------------- + + /** + * Read GA Client ID and Meta Pixel cookies when present. + * These are set by Google Analytics / Meta Pixel scripts and allow + * cross-platform identity stitching without requiring full consent. + */ + // eslint-disable-next-line class-methods-use-this + private collectThirdPartyIds(): Record { + const ids: Record = {}; + const ga = getCookie('_ga'); + if (ga) ids.gaClientId = ga; + const fbc = getCookie('_fbc'); + if (fbc) ids.fbClickId = fbc; + const fbp = getCookie('_fbp'); + if (fbp) ids.fbBrowserId = fbp; + return ids; + } + // -- Helpers ------------------------------------------------------------ // eslint-disable-next-line class-methods-use-this diff --git a/packages/audience/pixel/tsup.config.ts b/packages/audience/pixel/tsup.config.ts index 6cc4c7459b..5b56d10709 100644 --- a/packages/audience/pixel/tsup.config.ts +++ b/packages/audience/pixel/tsup.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsup'; const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/iife.ts'], outDir: 'dist', format: ['iife'], globalName: '__imtblPixelInternal',