Skip to content

Commit 7131e69

Browse files
authored
feat: Add URLs for custom events and URL filtering. (#587)
1 parent 9d93d2d commit 7131e69

File tree

20 files changed

+355
-48
lines changed

20 files changed

+355
-48
lines changed
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { jest } from '@jest/globals';
2+
3+
import {
4+
AutoEnvAttributes,
5+
EventSourceCapabilities,
6+
EventSourceInitDict,
7+
Hasher,
8+
LDLogger,
9+
PlatformData,
10+
Requests,
11+
SdkData,
12+
} from '@launchdarkly/js-client-sdk-common';
13+
14+
import { BrowserClient } from '../src/BrowserClient';
15+
16+
function mockResponse(value: string, statusCode: number) {
17+
const response: Response = {
18+
headers: {
19+
// @ts-ignore
20+
get: jest.fn(),
21+
// @ts-ignore
22+
keys: jest.fn(),
23+
// @ts-ignore
24+
values: jest.fn(),
25+
// @ts-ignore
26+
entries: jest.fn(),
27+
// @ts-ignore
28+
has: jest.fn(),
29+
},
30+
status: statusCode,
31+
text: () => Promise.resolve(value),
32+
json: () => Promise.resolve(JSON.parse(value)),
33+
};
34+
return Promise.resolve(response);
35+
}
36+
37+
function mockFetch(value: string, statusCode: number = 200) {
38+
const f = jest.fn();
39+
// @ts-ignore
40+
f.mockResolvedValue(mockResponse(value, statusCode));
41+
return f;
42+
}
43+
44+
function makeRequests(): Requests {
45+
return {
46+
// @ts-ignore
47+
fetch: jest.fn((url: string, _options: any) => {
48+
if (url.includes('/sdk/goals/')) {
49+
return mockFetch(
50+
JSON.stringify([
51+
{
52+
key: 'pageview',
53+
kind: 'pageview',
54+
urls: [{ kind: 'exact', url: 'http://browserclientintegration.com' }],
55+
},
56+
{
57+
key: 'click',
58+
kind: 'click',
59+
selector: '.button',
60+
urls: [{ kind: 'exact', url: 'http://browserclientintegration.com' }],
61+
},
62+
]),
63+
200,
64+
)();
65+
}
66+
return mockFetch('{ "flagA": true }', 200)();
67+
}),
68+
// @ts-ignore
69+
createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource {
70+
throw new Error('Function not implemented.');
71+
},
72+
getEventSourceCapabilities(): EventSourceCapabilities {
73+
return {
74+
readTimeout: false,
75+
headers: false,
76+
customMethod: false,
77+
};
78+
},
79+
};
80+
}
81+
82+
class MockHasher implements Hasher {
83+
update(_data: string): Hasher {
84+
return this;
85+
}
86+
digest?(_encoding: string): string {
87+
return 'hashed';
88+
}
89+
async asyncDigest?(_encoding: string): Promise<string> {
90+
return 'hashed';
91+
}
92+
}
93+
94+
describe('given a mock platform for a BrowserClient', () => {
95+
const logger: LDLogger = {
96+
debug: jest.fn(),
97+
info: jest.fn(),
98+
warn: jest.fn(),
99+
error: jest.fn(),
100+
};
101+
102+
let platform: any;
103+
beforeEach(() => {
104+
Object.defineProperty(window, 'location', {
105+
value: { href: 'http://browserclientintegration.com' },
106+
writable: true,
107+
});
108+
jest.useFakeTimers().setSystemTime(new Date('2024-09-19'));
109+
platform = {
110+
requests: makeRequests(),
111+
info: {
112+
platformData(): PlatformData {
113+
return {
114+
name: 'node',
115+
};
116+
},
117+
sdkData(): SdkData {
118+
return {
119+
name: 'browser-sdk',
120+
version: '1.0.0',
121+
};
122+
},
123+
},
124+
crypto: {
125+
createHash: () => new MockHasher(),
126+
randomUUID: () => '123',
127+
},
128+
storage: {
129+
get: async (_key: string) => null,
130+
set: async (_key: string, _value: string) => {},
131+
clear: async (_key: string) => {},
132+
},
133+
encoding: {
134+
btoa: (str: string) => str,
135+
},
136+
};
137+
});
138+
139+
it('includes urls in custom events', async () => {
140+
const client = new BrowserClient(
141+
'client-side-id',
142+
AutoEnvAttributes.Disabled,
143+
{
144+
initialConnectionMode: 'polling',
145+
logger,
146+
diagnosticOptOut: true,
147+
},
148+
platform,
149+
);
150+
await client.identify({ key: 'user-key', kind: 'user' });
151+
await client.flush();
152+
client.track('user-key', undefined, 1);
153+
await client.flush();
154+
155+
expect(JSON.parse(platform.requests.fetch.mock.calls[3][1].body)[0]).toMatchObject({
156+
kind: 'custom',
157+
creationDate: 1726704000000,
158+
key: 'user-key',
159+
contextKeys: {
160+
user: 'user-key',
161+
},
162+
metricValue: 1,
163+
url: 'http://browserclientintegration.com',
164+
});
165+
});
166+
167+
it('can filter URLs in custom events', async () => {
168+
const client = new BrowserClient(
169+
'client-side-id',
170+
AutoEnvAttributes.Disabled,
171+
{
172+
initialConnectionMode: 'polling',
173+
logger,
174+
diagnosticOptOut: true,
175+
eventUrlTransformer: (url: string) =>
176+
url.replace('http://browserclientintegration.com', 'http://filtered.org'),
177+
},
178+
platform,
179+
);
180+
await client.identify({ key: 'user-key', kind: 'user' });
181+
await client.flush();
182+
client.track('user-key', undefined, 1);
183+
await client.flush();
184+
185+
const events = JSON.parse(platform.requests.fetch.mock.calls[3][1].body);
186+
const customEvent = events.find((e: any) => e.kind === 'custom');
187+
188+
expect(customEvent).toMatchObject({
189+
kind: 'custom',
190+
creationDate: 1726704000000,
191+
key: 'user-key',
192+
contextKeys: {
193+
user: 'user-key',
194+
},
195+
metricValue: 1,
196+
url: 'http://filtered.org',
197+
});
198+
});
199+
200+
it('can filter URLs in click events', async () => {
201+
const client = new BrowserClient(
202+
'client-side-id',
203+
AutoEnvAttributes.Disabled,
204+
{
205+
initialConnectionMode: 'polling',
206+
logger,
207+
diagnosticOptOut: true,
208+
eventUrlTransformer: (url: string) =>
209+
url.replace('http://browserclientintegration.com', 'http://filtered.org'),
210+
},
211+
platform,
212+
);
213+
await client.identify({ key: 'user-key', kind: 'user' });
214+
await client.flush();
215+
216+
// Simulate a click event
217+
const button = document.createElement('button');
218+
button.className = 'button';
219+
document.body.appendChild(button);
220+
button.click();
221+
222+
while (platform.requests.fetch.mock.calls.length < 4) {
223+
// eslint-disable-next-line no-await-in-loop
224+
await client.flush();
225+
jest.runAllTicks();
226+
}
227+
228+
const events = JSON.parse(platform.requests.fetch.mock.calls[3][1].body);
229+
const clickEvent = events.find((e: any) => e.kind === 'click');
230+
expect(clickEvent).toMatchObject({
231+
kind: 'click',
232+
creationDate: 1726704000000,
233+
key: 'click',
234+
contextKeys: {
235+
user: 'user-key',
236+
},
237+
url: 'http://filtered.org',
238+
});
239+
240+
document.body.removeChild(button);
241+
});
242+
243+
it('can filter URLs in pageview events', async () => {
244+
const client = new BrowserClient(
245+
'client-side-id',
246+
AutoEnvAttributes.Disabled,
247+
{
248+
initialConnectionMode: 'polling',
249+
logger,
250+
diagnosticOptOut: true,
251+
eventUrlTransformer: (url: string) =>
252+
url.replace('http://browserclientintegration.com', 'http://filtered.com'),
253+
},
254+
platform,
255+
);
256+
257+
await client.identify({ key: 'user-key', kind: 'user' });
258+
await client.flush();
259+
260+
const events = JSON.parse(platform.requests.fetch.mock.calls[2][1].body);
261+
const pageviewEvent = events.find((e: any) => e.kind === 'pageview');
262+
expect(pageviewEvent).toMatchObject({
263+
kind: 'pageview',
264+
creationDate: 1726704000000,
265+
key: 'pageview',
266+
contextKeys: {
267+
user: 'user-key',
268+
},
269+
url: 'http://filtered.com',
270+
});
271+
});
272+
});

packages/sdk/browser/__tests__/goals/GoalManager.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe('given a GoalManager with mocked dependencies', () => {
4747
} as any);
4848

4949
await goalManager.initialize();
50+
goalManager.startTracking();
5051

5152
expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential');
5253
expect(mockLocationWatcherFactory).toHaveBeenCalled();
@@ -58,6 +59,7 @@ describe('given a GoalManager with mocked dependencies', () => {
5859
mockRequests.fetch.mockRejectedValue(error);
5960

6061
await goalManager.initialize();
62+
goalManager.startTracking();
6163

6264
expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError));
6365
});
@@ -91,6 +93,7 @@ describe('given a GoalManager with mocked dependencies', () => {
9193
json: () => Promise.resolve(mockGoals),
9294
} as any);
9395
await goalManager.initialize();
96+
goalManager.startTracking();
9497

9598
// Check that no goal was emitted on initial load
9699
expect(mockReportGoal).not.toHaveBeenCalled();

packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { jest } from '@jest/globals';
22

3-
import { DefaultLocationWatcher, LOCATION_WATCHER_INTERVAL } from '../../src/goals/LocationWatcher';
3+
import {
4+
DefaultLocationWatcher,
5+
LOCATION_WATCHER_INTERVAL_MS,
6+
} from '../../src/goals/LocationWatcher';
47

58
let mockCallback: jest.Mock;
69

@@ -25,7 +28,7 @@ it('should call callback when URL changes', () => {
2528
value: { href: 'https://example.com/new-page' },
2629
writable: true,
2730
});
28-
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL);
31+
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL_MS);
2932

3033
expect(mockCallback).toHaveBeenCalledTimes(1);
3134

@@ -40,7 +43,7 @@ it('should not call callback when URL remains the same', () => {
4043

4144
const watcher = new DefaultLocationWatcher(mockCallback);
4245

43-
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL * 2);
46+
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL_MS * 2);
4447

4548
expect(mockCallback).not.toHaveBeenCalled();
4649

@@ -80,7 +83,7 @@ it('should stop watching when close is called', () => {
8083
value: { href: 'https://example.com/new-page' },
8184
writable: true,
8285
});
83-
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL);
86+
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL_MS);
8487
window.dispatchEvent(new Event('popstate'));
8588

8689
expect(mockCallback).not.toHaveBeenCalled();

packages/sdk/browser/__tests__/options.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ it('applies default options', () => {
5454
const opts = validateOptions({}, logger);
5555

5656
expect(opts.fetchGoals).toBe(true);
57-
expect(opts.eventUrlTransformer).toBeUndefined();
57+
expect(opts.eventUrlTransformer).toBeDefined();
5858

5959
expect(logger.debug).not.toHaveBeenCalled();
6060
expect(logger.info).not.toHaveBeenCalled();

packages/sdk/browser/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export default {
44
preset: 'ts-jest/presets/default-esm',
55
testEnvironment: 'jest-environment-jsdom',
66
transform: {
7-
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
7+
'^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }],
88
},
99
testPathIgnorePatterns: ['./dist'],
1010
};

packages/sdk/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"build": "rollup -c rollup.config.js",
3131
"lint": "eslint . --ext .ts,.tsx",
3232
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
33-
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest",
33+
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest --runInBand",
3434
"coverage": "yarn test --coverage",
3535
"check": "yarn prettier && yarn lint && yarn build && yarn test"
3636
},

0 commit comments

Comments
 (0)