Skip to content

Commit

Permalink
feat: Add URLs for custom events and URL filtering. (#587)
Browse files Browse the repository at this point in the history
  • Loading branch information
kinyoklion authored Sep 20, 2024
1 parent 9d93d2d commit 7131e69
Show file tree
Hide file tree
Showing 20 changed files with 355 additions and 48 deletions.
272 changes: 272 additions & 0 deletions packages/sdk/browser/__tests__/BrowserClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { jest } from '@jest/globals';

import {
AutoEnvAttributes,
EventSourceCapabilities,
EventSourceInitDict,
Hasher,
LDLogger,
PlatformData,
Requests,
SdkData,
} from '@launchdarkly/js-client-sdk-common';

import { BrowserClient } from '../src/BrowserClient';

function mockResponse(value: string, statusCode: number) {
const response: Response = {
headers: {
// @ts-ignore
get: jest.fn(),
// @ts-ignore
keys: jest.fn(),
// @ts-ignore
values: jest.fn(),
// @ts-ignore
entries: jest.fn(),
// @ts-ignore
has: jest.fn(),
},
status: statusCode,
text: () => Promise.resolve(value),
json: () => Promise.resolve(JSON.parse(value)),
};
return Promise.resolve(response);
}

function mockFetch(value: string, statusCode: number = 200) {
const f = jest.fn();
// @ts-ignore
f.mockResolvedValue(mockResponse(value, statusCode));
return f;
}

function makeRequests(): Requests {
return {
// @ts-ignore
fetch: jest.fn((url: string, _options: any) => {
if (url.includes('/sdk/goals/')) {
return mockFetch(
JSON.stringify([
{
key: 'pageview',
kind: 'pageview',
urls: [{ kind: 'exact', url: 'http://browserclientintegration.com' }],
},
{
key: 'click',
kind: 'click',
selector: '.button',
urls: [{ kind: 'exact', url: 'http://browserclientintegration.com' }],
},
]),
200,
)();
}
return mockFetch('{ "flagA": true }', 200)();
}),
// @ts-ignore
createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource {
throw new Error('Function not implemented.');
},
getEventSourceCapabilities(): EventSourceCapabilities {
return {
readTimeout: false,
headers: false,
customMethod: false,
};
},
};
}

class MockHasher implements Hasher {
update(_data: string): Hasher {
return this;
}
digest?(_encoding: string): string {
return 'hashed';
}
async asyncDigest?(_encoding: string): Promise<string> {
return 'hashed';
}
}

describe('given a mock platform for a BrowserClient', () => {
const logger: LDLogger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};

let platform: any;
beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { href: 'http://browserclientintegration.com' },
writable: true,
});
jest.useFakeTimers().setSystemTime(new Date('2024-09-19'));
platform = {
requests: makeRequests(),
info: {
platformData(): PlatformData {
return {
name: 'node',
};
},
sdkData(): SdkData {
return {
name: 'browser-sdk',
version: '1.0.0',
};
},
},
crypto: {
createHash: () => new MockHasher(),
randomUUID: () => '123',
},
storage: {
get: async (_key: string) => null,
set: async (_key: string, _value: string) => {},
clear: async (_key: string) => {},
},
encoding: {
btoa: (str: string) => str,
},
};
});

it('includes urls in custom events', async () => {
const client = new BrowserClient(
'client-side-id',
AutoEnvAttributes.Disabled,
{
initialConnectionMode: 'polling',
logger,
diagnosticOptOut: true,
},
platform,
);
await client.identify({ key: 'user-key', kind: 'user' });
await client.flush();
client.track('user-key', undefined, 1);
await client.flush();

expect(JSON.parse(platform.requests.fetch.mock.calls[3][1].body)[0]).toMatchObject({
kind: 'custom',
creationDate: 1726704000000,
key: 'user-key',
contextKeys: {
user: 'user-key',
},
metricValue: 1,
url: 'http://browserclientintegration.com',
});
});

it('can filter URLs in custom events', async () => {
const client = new BrowserClient(
'client-side-id',
AutoEnvAttributes.Disabled,
{
initialConnectionMode: 'polling',
logger,
diagnosticOptOut: true,
eventUrlTransformer: (url: string) =>
url.replace('http://browserclientintegration.com', 'http://filtered.org'),
},
platform,
);
await client.identify({ key: 'user-key', kind: 'user' });
await client.flush();
client.track('user-key', undefined, 1);
await client.flush();

const events = JSON.parse(platform.requests.fetch.mock.calls[3][1].body);
const customEvent = events.find((e: any) => e.kind === 'custom');

expect(customEvent).toMatchObject({
kind: 'custom',
creationDate: 1726704000000,
key: 'user-key',
contextKeys: {
user: 'user-key',
},
metricValue: 1,
url: 'http://filtered.org',
});
});

it('can filter URLs in click events', async () => {
const client = new BrowserClient(
'client-side-id',
AutoEnvAttributes.Disabled,
{
initialConnectionMode: 'polling',
logger,
diagnosticOptOut: true,
eventUrlTransformer: (url: string) =>
url.replace('http://browserclientintegration.com', 'http://filtered.org'),
},
platform,
);
await client.identify({ key: 'user-key', kind: 'user' });
await client.flush();

// Simulate a click event
const button = document.createElement('button');
button.className = 'button';
document.body.appendChild(button);
button.click();

while (platform.requests.fetch.mock.calls.length < 4) {
// eslint-disable-next-line no-await-in-loop
await client.flush();
jest.runAllTicks();
}

const events = JSON.parse(platform.requests.fetch.mock.calls[3][1].body);
const clickEvent = events.find((e: any) => e.kind === 'click');
expect(clickEvent).toMatchObject({
kind: 'click',
creationDate: 1726704000000,
key: 'click',
contextKeys: {
user: 'user-key',
},
url: 'http://filtered.org',
});

document.body.removeChild(button);
});

it('can filter URLs in pageview events', async () => {
const client = new BrowserClient(
'client-side-id',
AutoEnvAttributes.Disabled,
{
initialConnectionMode: 'polling',
logger,
diagnosticOptOut: true,
eventUrlTransformer: (url: string) =>
url.replace('http://browserclientintegration.com', 'http://filtered.com'),
},
platform,
);

await client.identify({ key: 'user-key', kind: 'user' });
await client.flush();

const events = JSON.parse(platform.requests.fetch.mock.calls[2][1].body);
const pageviewEvent = events.find((e: any) => e.kind === 'pageview');
expect(pageviewEvent).toMatchObject({
kind: 'pageview',
creationDate: 1726704000000,
key: 'pageview',
contextKeys: {
user: 'user-key',
},
url: 'http://filtered.com',
});
});
});
3 changes: 3 additions & 0 deletions packages/sdk/browser/__tests__/goals/GoalManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('given a GoalManager with mocked dependencies', () => {
} as any);

await goalManager.initialize();
goalManager.startTracking();

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

await goalManager.initialize();
goalManager.startTracking();

expect(mockReportError).toHaveBeenCalledWith(expect.any(LDUnexpectedResponseError));
});
Expand Down Expand Up @@ -91,6 +93,7 @@ describe('given a GoalManager with mocked dependencies', () => {
json: () => Promise.resolve(mockGoals),
} as any);
await goalManager.initialize();
goalManager.startTracking();

// Check that no goal was emitted on initial load
expect(mockReportGoal).not.toHaveBeenCalled();
Expand Down
11 changes: 7 additions & 4 deletions packages/sdk/browser/__tests__/goals/LocationWatcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { jest } from '@jest/globals';

import { DefaultLocationWatcher, LOCATION_WATCHER_INTERVAL } from '../../src/goals/LocationWatcher';
import {
DefaultLocationWatcher,
LOCATION_WATCHER_INTERVAL_MS,
} from '../../src/goals/LocationWatcher';

let mockCallback: jest.Mock;

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

expect(mockCallback).toHaveBeenCalledTimes(1);

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

const watcher = new DefaultLocationWatcher(mockCallback);

jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL * 2);
jest.advanceTimersByTime(LOCATION_WATCHER_INTERVAL_MS * 2);

expect(mockCallback).not.toHaveBeenCalled();

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

expect(mockCallback).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/browser/__tests__/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ it('applies default options', () => {
const opts = validateOptions({}, logger);

expect(opts.fetchGoals).toBe(true);
expect(opts.eventUrlTransformer).toBeUndefined();
expect(opts.eventUrlTransformer).toBeDefined();

expect(logger.debug).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/browser/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'jest-environment-jsdom',
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
'^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }],
},
testPathIgnorePatterns: ['./dist'],
};
2 changes: 1 addition & 1 deletion packages/sdk/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"build": "rollup -c rollup.config.js",
"lint": "eslint . --ext .ts,.tsx",
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest",
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest --runInBand",
"coverage": "yarn test --coverage",
"check": "yarn prettier && yarn lint && yarn build && yarn test"
},
Expand Down
Loading

0 comments on commit 7131e69

Please sign in to comment.