Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds support for individual flag change listeners #608

Merged
merged 3 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { AutoEnvAttributes, clone, type LDContext, LDLogger } from '@launchdarkly/js-sdk-common';

import LDClientImpl from '../src/LDClientImpl';
import LDEmitter from '../src/LDEmitter';
import { Flags, PatchFlag } from '../src/types';
import { createBasicPlatform } from './createBasicPlatform';
import * as mockResponseJson from './evaluation/mockResponse.json';
import { MockEventSource } from './streaming/LDClientImpl.mocks';
import { makeTestDataManagerFactory } from './TestDataManager';

let mockPlatform: ReturnType<typeof createBasicPlatform>;
let logger: LDLogger;

beforeEach(() => {
mockPlatform = createBasicPlatform();
logger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
});

const testSdkKey = 'test-sdk-key';
const context: LDContext = { kind: 'org', key: 'Testy Pizza' };
const flagStorageKey = 'LaunchDarkly_1234567890123456_1234567890123456';
const indexStorageKey = 'LaunchDarkly_1234567890123456_ContextIndex';
let ldc: LDClientImpl;
let mockEventSource: MockEventSource;
let emitter: LDEmitter;
let defaultPutResponse: Flags;
let defaultFlagKeys: string[];

// Promisify on.change listener so we can await it in tests.
const onChangePromise = () =>
new Promise<string[]>((res) => {
ldc.on('change', (_context: LDContext, changes: string[]) => {
res(changes);
});
});

describe('sdk-client storage', () => {
beforeEach(() => {
jest.useFakeTimers();
defaultPutResponse = clone<Flags>(mockResponseJson);
defaultFlagKeys = Object.keys(defaultPutResponse);

(mockPlatform.storage.get as jest.Mock).mockImplementation((storageKey: string) => {
switch (storageKey) {
case flagStorageKey:
return JSON.stringify(defaultPutResponse);
case indexStorageKey:
return undefined;
default:
return undefined;
}
});

ldc = new LDClientImpl(
testSdkKey,
AutoEnvAttributes.Disabled,
mockPlatform,
{
logger,
sendEvents: false,
},
makeTestDataManagerFactory(testSdkKey, mockPlatform),
);

// @ts-ignore
emitter = ldc.emitter;
jest.spyOn(emitter as LDEmitter, 'emit');
});

afterEach(() => {
jest.resetAllMocks();
});

test('initialize from storage emits flags as changed', async () => {
mockPlatform.requests.createEventSource.mockImplementation(
(streamUri: string = '', options: any = {}) => {
mockEventSource = new MockEventSource(streamUri, options);
mockEventSource.simulateError({ status: 404, message: 'error-to-force-cache' });
return mockEventSource;
},
);

const changePromise = onChangePromise();
await ldc.identify(context);
await changePromise;

expect(mockPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey);

expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys);

// a few specific flag changes to verify those are also called
expect(emitter.emit).toHaveBeenCalledWith('change:moonshot-demo', context);
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
expect(emitter.emit).toHaveBeenCalledWith('change:this-is-a-test', context);
});

test('put should emit changed flags', async () => {
const putResponse = clone<Flags>(defaultPutResponse);
putResponse['dev-test-flag'].version = 999;
putResponse['dev-test-flag'].value = false;

const simulatedEvents = [{ data: JSON.stringify(putResponse) }];
mockPlatform.requests.createEventSource.mockImplementation(
(streamUri: string = '', options: any = {}) => {
mockEventSource = new MockEventSource(streamUri, options);
mockEventSource.simulateEvents('put', simulatedEvents);
return mockEventSource;
},
);

const changePromise = onChangePromise();
await ldc.identify(context);
await changePromise;
await jest.runAllTimersAsync();

expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']);
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
});

test('patch should emit changed flags', async () => {
const patchResponse = clone<PatchFlag>(defaultPutResponse['dev-test-flag']);
patchResponse.key = 'dev-test-flag';
patchResponse.value = false;
patchResponse.version += 1;

const putEvents = [{ data: JSON.stringify(defaultPutResponse) }];
const patchEvents = [{ data: JSON.stringify(patchResponse) }];
mockPlatform.requests.createEventSource.mockImplementation(
(streamUri: string = '', options: any = {}) => {
mockEventSource = new MockEventSource(streamUri, options);
mockEventSource.simulateEvents('put', putEvents);
mockEventSource.simulateEvents('patch', patchEvents);
return mockEventSource;
},
);

const changePromise = onChangePromise();
await ldc.identify(context);
await changePromise;
await jest.runAllTimersAsync();

expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']);
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
});

test('delete should emit changed flags', async () => {
const deleteResponse = {
key: 'dev-test-flag',
version: defaultPutResponse['dev-test-flag'].version + 1,
};

const putEvents = [{ data: JSON.stringify(defaultPutResponse) }];
const deleteEvents = [{ data: JSON.stringify(deleteResponse) }];
mockPlatform.requests.createEventSource.mockImplementation(
(streamUri: string = '', options: any = {}) => {
mockEventSource = new MockEventSource(streamUri, options);
mockEventSource.simulateEvents('put', putEvents);
mockEventSource.simulateEvents('delete', deleteEvents);
return mockEventSource;
},
);

const changePromise = onChangePromise();
await ldc.identify(context);
await changePromise;
await jest.runAllTimersAsync();

expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']);
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
});
});
12 changes: 7 additions & 5 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ export default class LDClientImpl implements LDClient {

this.flagManager.on((context, flagKeys) => {
const ldContext = Context.toLDContext(context);
this.logger.debug(`change: context: ${JSON.stringify(ldContext)}, flags: ${flagKeys}`);
kinyoklion marked this conversation as resolved.
Show resolved Hide resolved
this.emitter.emit('change', ldContext, flagKeys);
flagKeys.forEach((it) => {
this.emitter.emit(`change:${it}`, ldContext);
});
});

this.dataManager = dataManagerFactory(
Expand Down Expand Up @@ -249,14 +251,14 @@ export default class LDClientImpl implements LDClient {
return identifyPromise;
}

off(eventName: EventName, listener: Function): void {
this.emitter.off(eventName, listener);
}

on(eventName: EventName, listener: Function): void {
this.emitter.on(eventName, listener);
}

off(eventName: EventName, listener: Function): void {
this.emitter.off(eventName, listener);
}

track(key: string, data?: any, metricValue?: number): void {
if (!this.checkedContext || !this.checkedContext.valid) {
this.logger.warn(ClientMessages.missingContextKeyNoEvent);
Expand Down
6 changes: 5 additions & 1 deletion packages/shared/sdk-client/src/LDEmitter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { LDLogger } from '@launchdarkly/js-sdk-common';

export type EventName = 'error' | 'change' | 'dataSourceStatus';
/**
* Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used
* for specific flag changes.
*/
export type EventName = 'change' | 'dataSourceStatus' | 'error' | string;
tanderson-ld marked this conversation as resolved.
Show resolved Hide resolved

/**
* Implementation Note: There should not be any default listeners for change events in a client
Expand Down
Loading