Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions packages/audience/pixel/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).__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<string, unknown>).__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<string, unknown>).__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<string, unknown>).__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<string, unknown>).__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<string, unknown>).__imtbl as {
_loaded: boolean;
};
expect(loader._loaded).toBe(true);
});
});
41 changes: 41 additions & 0 deletions packages/audience/pixel/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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<Pixel['init']>[0]);
break;
case 'page':
pixel.page(args[0] as Parameters<Pixel['page']>[0]);
break;
case 'identify':
pixel.identify(
args[0] as string,
args[1] as Parameters<Pixel['identify']>[1],
);
break;
case 'consent':
pixel.setConsent(args[0] as Parameters<Pixel['setConsent']>[0]);
break;
default:
// Unknown command — ignore silently
break;
}
}

createLoader(handleCommand);
8 changes: 8 additions & 0 deletions packages/audience/pixel/src/iife.ts
Original file line number Diff line number Diff line change
@@ -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';
43 changes: 43 additions & 0 deletions packages/audience/pixel/src/pixel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<string, string> = {
_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<string, unknown>));
const pageCall = calls.find((c) => c.type === 'page');
const props = pageCall!.properties as Record<string, unknown>;

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<string, unknown>));
const pageCall = calls.find((c) => c.type === 'page');
const props = pageCall!.properties as Record<string, unknown>;

expect(props.gaClientId).toBeUndefined();
expect(props.fbClickId).toBeUndefined();
expect(props.fbBrowserId).toBeUndefined();
});
});

describe('identify', () => {
Expand Down
31 changes: 28 additions & 3 deletions packages/audience/pixel/src/pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
generateId,
getTimestamp,
isBrowser,
getCookie,
collectAttribution,
getOrCreateSession,
createConsentManager,
Expand Down Expand Up @@ -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<string, unknown>): void {
Expand All @@ -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,
},
Expand Down Expand Up @@ -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<string, string> {
const ids: Record<string, string> = {};
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
Expand Down
2 changes: 1 addition & 1 deletion packages/audience/pixel/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading