Skip to content

Commit 2849c1f

Browse files
committed
Merge remote-tracking branch 'origin' into ta/sc-249239/data-source-status-rebase
2 parents 6ef2c49 + 7dfb14d commit 2849c1f

31 files changed

+1445
-62
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+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { jest } from '@jest/globals';
2+
3+
import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common';
4+
5+
import GoalManager from '../../src/goals/GoalManager';
6+
import { Goal } from '../../src/goals/Goals';
7+
import { LocationWatcher } from '../../src/goals/LocationWatcher';
8+
9+
describe('given a GoalManager with mocked dependencies', () => {
10+
let mockRequests: jest.Mocked<Requests>;
11+
let mockReportError: jest.Mock;
12+
let mockReportGoal: jest.Mock;
13+
let mockLocationWatcherFactory: () => { cb?: () => void } & LocationWatcher;
14+
let mockLocationWatcher: { cb?: () => void } & LocationWatcher;
15+
let goalManager: GoalManager;
16+
const mockCredential = 'test-credential';
17+
18+
beforeEach(() => {
19+
mockRequests = { fetch: jest.fn() } as any;
20+
mockReportError = jest.fn();
21+
mockReportGoal = jest.fn();
22+
mockLocationWatcher = { close: jest.fn() };
23+
// @ts-expect-error The type is correct, but TS cannot handle the jest.fn typing
24+
mockLocationWatcherFactory = jest.fn((cb: () => void) => {
25+
mockLocationWatcher.cb = cb;
26+
return mockLocationWatcher;
27+
});
28+
29+
goalManager = new GoalManager(
30+
mockCredential,
31+
mockRequests,
32+
'polling',
33+
mockReportError,
34+
mockReportGoal,
35+
mockLocationWatcherFactory,
36+
);
37+
});
38+
39+
it('should fetch goals and set up the location watcher', async () => {
40+
const mockGoals: Goal[] = [
41+
{ key: 'goal1', kind: 'click', selector: '#button1' },
42+
{ key: 'goal2', kind: 'click', selector: '#button2' },
43+
];
44+
45+
mockRequests.fetch.mockResolvedValue({
46+
json: () => Promise.resolve(mockGoals),
47+
} as any);
48+
49+
await goalManager.initialize();
50+
goalManager.startTracking();
51+
52+
expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential');
53+
expect(mockLocationWatcherFactory).toHaveBeenCalled();
54+
});
55+
56+
it('should handle failed initial fetch by reporting an unexpected response error', async () => {
57+
const error = new Error('Fetch failed');
58+
59+
mockRequests.fetch.mockRejectedValue(error);
60+
61+
await goalManager.initialize();
62+
goalManager.startTracking();
63+
64+
expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError));
65+
});
66+
67+
it('should close the watcher and tracker when closed', () => {
68+
goalManager.close();
69+
70+
expect(mockLocationWatcher.close).toHaveBeenCalled();
71+
});
72+
73+
it('should not emit a goal on initial for a non-matching URL, but should emit after URL change to a matching URL', async () => {
74+
const mockGoals: Goal[] = [
75+
{
76+
key: 'goal1',
77+
kind: 'pageview',
78+
urls: [
79+
{
80+
kind: 'exact',
81+
url: 'https://example.com/target',
82+
},
83+
],
84+
},
85+
];
86+
87+
Object.defineProperty(window, 'location', {
88+
value: { href: 'https://example.com/not-target' },
89+
writable: true,
90+
});
91+
92+
mockRequests.fetch.mockResolvedValue({
93+
json: () => Promise.resolve(mockGoals),
94+
} as any);
95+
await goalManager.initialize();
96+
goalManager.startTracking();
97+
98+
// Check that no goal was emitted on initial load
99+
expect(mockReportGoal).not.toHaveBeenCalled();
100+
101+
// Simulate URL change to match the goal
102+
Object.defineProperty(window, 'location', {
103+
value: { href: 'https://example.com/target' },
104+
writable: true,
105+
});
106+
107+
// Trigger the location change callback
108+
mockLocationWatcher.cb?.();
109+
110+
// Check that the goal was emitted after URL change
111+
expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', {
112+
key: 'goal1',
113+
kind: 'pageview',
114+
urls: [{ kind: 'exact', url: 'https://example.com/target' }],
115+
});
116+
});
117+
});

0 commit comments

Comments
 (0)