Skip to content

Commit

Permalink
feat: Implement goals for client-side SDKs. (#585)
Browse files Browse the repository at this point in the history
Implements goals. Additionally adds browser specific configuration as it
is required by goals.

Also addresses SDK-563
  • Loading branch information
kinyoklion authored Sep 19, 2024
1 parent 916b724 commit fd38a8f
Show file tree
Hide file tree
Showing 20 changed files with 1,096 additions and 14 deletions.
114 changes: 114 additions & 0 deletions packages/sdk/browser/__tests__/goals/GoalManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { jest } from '@jest/globals';

import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common';

import GoalManager from '../../src/goals/GoalManager';
import { Goal } from '../../src/goals/Goals';
import { LocationWatcher } from '../../src/goals/LocationWatcher';

describe('given a GoalManager with mocked dependencies', () => {
let mockRequests: jest.Mocked<Requests>;
let mockReportError: jest.Mock;
let mockReportGoal: jest.Mock;
let mockLocationWatcherFactory: () => { cb?: () => void } & LocationWatcher;
let mockLocationWatcher: { cb?: () => void } & LocationWatcher;
let goalManager: GoalManager;
const mockCredential = 'test-credential';

beforeEach(() => {
mockRequests = { fetch: jest.fn() } as any;
mockReportError = jest.fn();
mockReportGoal = jest.fn();
mockLocationWatcher = { close: jest.fn() };
// @ts-expect-error The type is correct, but TS cannot handle the jest.fn typing
mockLocationWatcherFactory = jest.fn((cb: () => void) => {
mockLocationWatcher.cb = cb;
return mockLocationWatcher;
});

goalManager = new GoalManager(
mockCredential,
mockRequests,
'polling',
mockReportError,
mockReportGoal,
mockLocationWatcherFactory,
);
});

it('should fetch goals and set up the location watcher', async () => {
const mockGoals: Goal[] = [
{ key: 'goal1', kind: 'click', selector: '#button1' },
{ key: 'goal2', kind: 'click', selector: '#button2' },
];

mockRequests.fetch.mockResolvedValue({
json: () => Promise.resolve(mockGoals),
} as any);

await goalManager.initialize();

expect(mockRequests.fetch).toHaveBeenCalledWith('polling/sdk/goals/test-credential');
expect(mockLocationWatcherFactory).toHaveBeenCalled();
});

it('should handle failed initial fetch by reporting an unexpected response error', async () => {
const error = new Error('Fetch failed');

mockRequests.fetch.mockRejectedValue(error);

await goalManager.initialize();

expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError));
});

it('should close the watcher and tracker when closed', () => {
goalManager.close();

expect(mockLocationWatcher.close).toHaveBeenCalled();
});

it('should not emit a goal on initial for a non-matching URL, but should emit after URL change to a matching URL', async () => {
const mockGoals: Goal[] = [
{
key: 'goal1',
kind: 'pageview',
urls: [
{
kind: 'exact',
url: 'https://example.com/target',
},
],
},
];

Object.defineProperty(window, 'location', {
value: { href: 'https://example.com/not-target' },
writable: true,
});

mockRequests.fetch.mockResolvedValue({
json: () => Promise.resolve(mockGoals),
} as any);
await goalManager.initialize();

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

// Simulate URL change to match the goal
Object.defineProperty(window, 'location', {
value: { href: 'https://example.com/target' },
writable: true,
});

// Trigger the location change callback
mockLocationWatcher.cb?.();

// Check that the goal was emitted after URL change
expect(mockReportGoal).toHaveBeenCalledWith('https://example.com/target', {
key: 'goal1',
kind: 'pageview',
urls: [{ kind: 'exact', url: 'https://example.com/target' }],
});
});
});
115 changes: 115 additions & 0 deletions packages/sdk/browser/__tests__/goals/GoalTracker.matchesUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Matcher } from '../../src/goals/Goals';
import { matchesUrl } from '../../src/goals/GoalTracker';

it.each([
['https://example.com', '', '', 'https://example.com'],
[
'https://example.com?potato=true#hash',
'?potato=true',
'#hash',
'https://example.com?potato=true#hash',
],
])('returns true for exact match with "exact" matcher kind', (href, query, hash, matcherUrl) => {
const matcher: Matcher = { kind: 'exact', url: matcherUrl };
const result = matchesUrl(matcher, href, query, hash);
expect(result).toBe(true);
});

it.each([
['https://example.com/potato', '', '', 'https://example.com'],
[
'https://example.com?potato=true#hash',
'?potato=true',
'#hash',
'https://example.com?potato=true#brown',
],
])('returns false for non-matching "exact" matcher kind', (href, query, hash, matcherUrl) => {
const matcher: Matcher = { kind: 'exact', url: matcherUrl };
const result = matchesUrl(matcher, href, query, hash);
expect(result).toBe(false);
});

it('returns true for canonical match with "canonical" matcher kind', () => {
// For this type of match the hash and query parameters are not included.
const matcher: Matcher = { kind: 'canonical', url: 'https://example.com/some-path' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#hash',
'?query=1',
'#hash',
);
expect(result).toBe(true);
});

it('returns true for substring match with "substring" matcher kind', () => {
const matcher: Matcher = { kind: 'substring', substring: 'example' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#hash',
'?query=1',
'#hash',
);
expect(result).toBe(true);
});

it('returns false for non-matching substring with "substring" matcher kind', () => {
const matcher: Matcher = { kind: 'substring', substring: 'nonexistent' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#hash',
'?query=1',
'#hash',
);
expect(result).toBe(false);
});

it('returns true for regex match with "regex" matcher kind', () => {
const matcher: Matcher = { kind: 'regex', pattern: 'example\\.com' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#hash',
'?query=1',
'#hash',
);
expect(result).toBe(true);
});

it('returns false for non-matching regex with "regex" matcher kind', () => {
const matcher: Matcher = { kind: 'regex', pattern: 'nonexistent\\.com' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#hash',
'?query=1',
'#hash',
);
expect(result).toBe(false);
});

it('includes the hash for "path-like" hashes for "substring" matchers', () => {
const matcher: Matcher = { kind: 'substring', substring: 'example' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#/hash/path',
'?query=1',
'#/hash/path',
);
expect(result).toBe(true);
});

it('includes the hash for "path-like" hashes for "regex" matchers', () => {
const matcher: Matcher = { kind: 'regex', pattern: 'hash' };
const result = matchesUrl(
matcher,
'https://example.com/some-path?query=1#/hash/path',
'?query=1',
'#/hash/path',
);
expect(result).toBe(true);
});

it('returns false for unsupported matcher kind', () => {
// @ts-expect-error
const matcher: Matcher = { kind: 'unsupported' };
const result = matchesUrl(matcher, 'https://example.com', '', '');
expect(result).toBe(false);
});
Loading

0 comments on commit fd38a8f

Please sign in to comment.