Skip to content

Commit 938ea87

Browse files
bkboothclaude
andcommitted
feat(audience): add session_start/end events and fix test isolation
Add session lifecycle tracking to the Pixel class: - Fire session_start track event on new session creation - Fire session_end track event on pagehide/visibilitychange with duration - Refactor session module to return SessionResult with isNew flag - Fix test listener leaking by cleaning up pixel instances in afterEach Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f6bce0 commit 938ea87

5 files changed

Lines changed: 256 additions & 38 deletions

File tree

packages/audience/pixel/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ export type { PixelInitOptions } from './pixel';
44
export { createConsentManager } from './consent';
55
export type { ConsentManager } from './consent';
66

7-
export { getOrCreateSessionId, getSessionId } from './session';
7+
export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
8+
export type { SessionResult } from './session';
89

910
export { collectAttribution, clearAttribution } from './attribution';
1011
export type { Attribution } from './attribution';

packages/audience/pixel/src/pixel.test.ts

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jest.mock('@imtbl/audience-core', () => ({
3535
}),
3636
generateId: jest.fn().mockReturnValue('msg-uuid'),
3737
getTimestamp: jest.fn().mockReturnValue('2026-04-07T00:00:00.000Z'),
38+
isBrowser: jest.fn().mockReturnValue(true),
3839
getCookie: jest.fn(),
3940
setCookie: jest.fn(),
4041
}));
@@ -48,60 +49,99 @@ jest.mock('./attribution', () => ({
4849
}));
4950

5051
jest.mock('./session', () => ({
52+
getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true }),
5153
getOrCreateSessionId: jest.fn().mockReturnValue('session-abc'),
5254
}));
5355

5456
// Mock fetch globally
5557
global.fetch = jest.fn().mockResolvedValue({ ok: true });
5658

59+
// Access the mock to change return values per test
60+
const mockGetOrCreateSession = jest.requireMock('./session').getOrCreateSession;
61+
62+
let activePixel: Pixel | null = null;
63+
5764
beforeEach(() => {
5865
jest.clearAllMocks();
66+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: true });
67+
});
68+
69+
afterEach(() => {
70+
// Clean up any active pixel to remove event listeners
71+
if (activePixel) {
72+
activePixel.destroy();
73+
activePixel = null;
74+
}
5975
});
6076

6177
describe('Pixel', () => {
6278
describe('init', () => {
63-
it('creates queue, starts it, and fires a page view when consent is anonymous', () => {
79+
it('creates queue, starts it, and fires page view + session_start when consent is anonymous', () => {
6480
const pixel = new Pixel();
81+
activePixel = pixel;
6582
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
6683

6784
expect(mockStart).toHaveBeenCalled();
68-
expect(mockEnqueue).toHaveBeenCalledWith(
69-
expect.objectContaining({
70-
type: 'page',
71-
surface: 'pixel',
72-
anonymousId: 'anon-123',
73-
properties: expect.objectContaining({
74-
utm_source: 'google',
75-
sessionId: 'session-abc',
76-
}),
77-
}),
85+
86+
// Should fire session_start (new session) then page view
87+
const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
88+
const pageCall = calls.find((c) => c.type === 'page');
89+
const sessionStartCall = calls.find(
90+
(c) => c.type === 'track' && c.eventName === 'session_start',
7891
);
92+
93+
expect(pageCall).toBeDefined();
94+
expect(pageCall!.surface).toBe('pixel');
95+
expect(pageCall!.anonymousId).toBe('anon-123');
96+
expect((pageCall!.properties as Record<string, unknown>).utm_source).toBe('google');
97+
98+
expect(sessionStartCall).toBeDefined();
99+
expect((sessionStartCall!.properties as Record<string, unknown>).sessionId).toBe('session-abc');
79100
});
80101

81-
it('does not fire page view when consent is none', () => {
102+
it('does not fire page view or session_start when consent is none', () => {
82103
const pixel = new Pixel();
104+
activePixel = pixel;
83105
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' });
84106

85107
expect(mockStart).toHaveBeenCalled();
86108
expect(mockEnqueue).not.toHaveBeenCalled();
87109
});
88110

111+
it('does not fire session_start for existing sessions', () => {
112+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
113+
114+
const pixel = new Pixel();
115+
activePixel = pixel;
116+
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
117+
118+
const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
119+
const sessionStartCall = calls.find(
120+
(c) => c.type === 'track' && c.eventName === 'session_start',
121+
);
122+
123+
expect(sessionStartCall).toBeUndefined();
124+
// Page view should still fire
125+
expect(calls.find((c) => c.type === 'page')).toBeDefined();
126+
});
127+
89128
it('only initializes once', () => {
90129
const pixel = new Pixel();
130+
activePixel = pixel;
91131
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
92132
pixel.init({ key: 'pk_other', environment: 'dev', consent: 'anonymous' });
93133

94-
// Start called only once
95134
expect(mockStart).toHaveBeenCalledTimes(1);
96135
});
97136
});
98137

99138
describe('page', () => {
100139
it('enqueues a page message with attribution and session', () => {
140+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
141+
101142
const pixel = new Pixel();
143+
activePixel = pixel;
102144
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
103-
104-
// Clear the auto-fired page view
105145
mockEnqueue.mockClear();
106146

107147
pixel.page({ custom: 'prop' });
@@ -121,6 +161,7 @@ describe('Pixel', () => {
121161

122162
it('does not enqueue when consent is none', () => {
123163
const pixel = new Pixel();
164+
activePixel = pixel;
124165
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' });
125166

126167
pixel.page();
@@ -130,7 +171,10 @@ describe('Pixel', () => {
130171

131172
describe('identify', () => {
132173
it('enqueues identify message at full consent', () => {
174+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
175+
133176
const pixel = new Pixel();
177+
activePixel = pixel;
134178
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'full' });
135179
mockEnqueue.mockClear();
136180

@@ -151,23 +195,79 @@ describe('Pixel', () => {
151195

152196
it('does not enqueue identify at anonymous consent', () => {
153197
const pixel = new Pixel();
198+
activePixel = pixel;
154199
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
155200

156201
pixel.identify('user-1');
157-
// Only the auto page view, no identify
158-
expect(mockEnqueue).toHaveBeenCalledTimes(1);
159-
expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' }));
202+
// Only the auto page view + session_start, no identify
203+
const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
204+
expect(calls.find((c) => c.type === 'identify')).toBeUndefined();
205+
});
206+
});
207+
208+
describe('session_end', () => {
209+
it('fires session_end on pagehide when session is active', () => {
210+
const pixel = new Pixel();
211+
activePixel = pixel;
212+
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
213+
mockEnqueue.mockClear();
214+
215+
// Simulate pagehide
216+
window.dispatchEvent(new Event('pagehide'));
217+
218+
expect(mockEnqueue).toHaveBeenCalledWith(
219+
expect.objectContaining({
220+
type: 'track',
221+
eventName: 'session_end',
222+
properties: expect.objectContaining({
223+
sessionId: 'session-abc',
224+
}),
225+
}),
226+
);
227+
});
228+
229+
it('includes duration in session_end', () => {
230+
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000000);
231+
232+
const pixel = new Pixel();
233+
activePixel = pixel;
234+
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
235+
mockEnqueue.mockClear();
236+
237+
// Advance time by 15 seconds before triggering pagehide
238+
dateNowSpy.mockReturnValue(1015000);
239+
window.dispatchEvent(new Event('pagehide'));
240+
241+
const sessionEndCall = mockEnqueue.mock.calls.find(
242+
(c: unknown[]) => (c[0] as Record<string, unknown>).eventName === 'session_end',
243+
);
244+
expect(sessionEndCall).toBeDefined();
245+
expect((sessionEndCall![0] as Record<string, unknown>).properties).toEqual(
246+
expect.objectContaining({ duration: 15 }),
247+
);
248+
249+
dateNowSpy.mockRestore();
250+
});
251+
252+
it('does not fire session_end when consent is none', () => {
253+
const pixel = new Pixel();
254+
activePixel = pixel;
255+
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' });
256+
257+
window.dispatchEvent(new Event('pagehide'));
258+
expect(mockEnqueue).not.toHaveBeenCalled();
160259
});
161260
});
162261

163262
describe('setConsent', () => {
164263
it('updates consent level', () => {
165264
const pixel = new Pixel();
265+
activePixel = pixel;
166266
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' });
167267

168268
pixel.setConsent('anonymous');
169269

170-
// After upgrading consent, page() should work
270+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false });
171271
pixel.page();
172272
expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' }));
173273
});
@@ -176,6 +276,7 @@ describe('Pixel', () => {
176276
describe('destroy', () => {
177277
it('destroys the queue and resets state', () => {
178278
const pixel = new Pixel();
279+
activePixel = pixel;
179280
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
180281

181282
pixel.destroy();

0 commit comments

Comments
 (0)