Skip to content

Commit fd38a8f

Browse files
authored
feat: Implement goals for client-side SDKs. (#585)
Implements goals. Additionally adds browser specific configuration as it is required by goals. Also addresses SDK-563
1 parent 916b724 commit fd38a8f

20 files changed

+1096
-14
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
51+
expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential');
52+
expect(mockLocationWatcherFactory).toHaveBeenCalled();
53+
});
54+
55+
it('should handle failed initial fetch by reporting an unexpected response error', async () => {
56+
const error = new Error('Fetch failed');
57+
58+
mockRequests.fetch.mockRejectedValue(error);
59+
60+
await goalManager.initialize();
61+
62+
expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError));
63+
});
64+
65+
it('should close the watcher and tracker when closed', () => {
66+
goalManager.close();
67+
68+
expect(mockLocationWatcher.close).toHaveBeenCalled();
69+
});
70+
71+
it('should not emit a goal on initial for a non-matching URL, but should emit after URL change to a matching URL', async () => {
72+
const mockGoals: Goal[] = [
73+
{
74+
key: 'goal1',
75+
kind: 'pageview',
76+
urls: [
77+
{
78+
kind: 'exact',
79+
url: 'https://example.com/target',
80+
},
81+
],
82+
},
83+
];
84+
85+
Object.defineProperty(window, 'location', {
86+
value: { href: 'https://example.com/not-target' },
87+
writable: true,
88+
});
89+
90+
mockRequests.fetch.mockResolvedValue({
91+
json: () => Promise.resolve(mockGoals),
92+
} as any);
93+
await goalManager.initialize();
94+
95+
// Check that no goal was emitted on initial load
96+
expect(mockReportGoal).not.toHaveBeenCalled();
97+
98+
// Simulate URL change to match the goal
99+
Object.defineProperty(window, 'location', {
100+
value: { href: 'https://example.com/target' },
101+
writable: true,
102+
});
103+
104+
// Trigger the location change callback
105+
mockLocationWatcher.cb?.();
106+
107+
// Check that the goal was emitted after URL change
108+
expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', {
109+
key: 'goal1',
110+
kind: 'pageview',
111+
urls: [{ kind: 'exact', url: 'https://example.com/target' }],
112+
});
113+
});
114+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Matcher } from '../../src/goals/Goals';
2+
import { matchesUrl } from '../../src/goals/GoalTracker';
3+
4+
it.each([
5+
['https://example.com', '', '', 'https://example.com'],
6+
[
7+
'https://example.com?potato=true#hash',
8+
'?potato=true',
9+
'#hash',
10+
'https://example.com?potato=true#hash',
11+
],
12+
])('returns true for exact match with "exact" matcher kind', (href, query, hash, matcherUrl) => {
13+
const matcher: Matcher = { kind: 'exact', url: matcherUrl };
14+
const result = matchesUrl(matcher, href, query, hash);
15+
expect(result).toBe(true);
16+
});
17+
18+
it.each([
19+
['https://example.com/potato', '', '', 'https://example.com'],
20+
[
21+
'https://example.com?potato=true#hash',
22+
'?potato=true',
23+
'#hash',
24+
'https://example.com?potato=true#brown',
25+
],
26+
])('returns false for non-matching "exact" matcher kind', (href, query, hash, matcherUrl) => {
27+
const matcher: Matcher = { kind: 'exact', url: matcherUrl };
28+
const result = matchesUrl(matcher, href, query, hash);
29+
expect(result).toBe(false);
30+
});
31+
32+
it('returns true for canonical match with "canonical" matcher kind', () => {
33+
// For this type of match the hash and query parameters are not included.
34+
const matcher: Matcher = { kind: 'canonical', url: 'https://example.com/some-path' };
35+
const result = matchesUrl(
36+
matcher,
37+
'https://example.com/some-path?query=1#hash',
38+
'?query=1',
39+
'#hash',
40+
);
41+
expect(result).toBe(true);
42+
});
43+
44+
it('returns true for substring match with "substring" matcher kind', () => {
45+
const matcher: Matcher = { kind: 'substring', substring: 'example' };
46+
const result = matchesUrl(
47+
matcher,
48+
'https://example.com/some-path?query=1#hash',
49+
'?query=1',
50+
'#hash',
51+
);
52+
expect(result).toBe(true);
53+
});
54+
55+
it('returns false for non-matching substring with "substring" matcher kind', () => {
56+
const matcher: Matcher = { kind: 'substring', substring: 'nonexistent' };
57+
const result = matchesUrl(
58+
matcher,
59+
'https://example.com/some-path?query=1#hash',
60+
'?query=1',
61+
'#hash',
62+
);
63+
expect(result).toBe(false);
64+
});
65+
66+
it('returns true for regex match with "regex" matcher kind', () => {
67+
const matcher: Matcher = { kind: 'regex', pattern: 'example\\.com' };
68+
const result = matchesUrl(
69+
matcher,
70+
'https://example.com/some-path?query=1#hash',
71+
'?query=1',
72+
'#hash',
73+
);
74+
expect(result).toBe(true);
75+
});
76+
77+
it('returns false for non-matching regex with "regex" matcher kind', () => {
78+
const matcher: Matcher = { kind: 'regex', pattern: 'nonexistent\\.com' };
79+
const result = matchesUrl(
80+
matcher,
81+
'https://example.com/some-path?query=1#hash',
82+
'?query=1',
83+
'#hash',
84+
);
85+
expect(result).toBe(false);
86+
});
87+
88+
it('includes the hash for "path-like" hashes for "substring" matchers', () => {
89+
const matcher: Matcher = { kind: 'substring', substring: 'example' };
90+
const result = matchesUrl(
91+
matcher,
92+
'https://example.com/some-path?query=1#/hash/path',
93+
'?query=1',
94+
'#/hash/path',
95+
);
96+
expect(result).toBe(true);
97+
});
98+
99+
it('includes the hash for "path-like" hashes for "regex" matchers', () => {
100+
const matcher: Matcher = { kind: 'regex', pattern: 'hash' };
101+
const result = matchesUrl(
102+
matcher,
103+
'https://example.com/some-path?query=1#/hash/path',
104+
'?query=1',
105+
'#/hash/path',
106+
);
107+
expect(result).toBe(true);
108+
});
109+
110+
it('returns false for unsupported matcher kind', () => {
111+
// @ts-expect-error
112+
const matcher: Matcher = { kind: 'unsupported' };
113+
const result = matchesUrl(matcher, 'https://example.com', '', '');
114+
expect(result).toBe(false);
115+
});

0 commit comments

Comments
 (0)