Skip to content

Commit

Permalink
feat: adds support for individual flag change listeners (#608)
Browse files Browse the repository at this point in the history
**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests)
- [x] I have validated my changes against all supported platform
versions

**Related issues**

SDK-708
  • Loading branch information
tanderson-ld authored Oct 4, 2024
1 parent 4e5dbee commit da31436
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 6 deletions.
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 change emitter', () => {
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}`);
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
8 changes: 7 additions & 1 deletion packages/shared/sdk-client/src/LDEmitter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { LDLogger } from '@launchdarkly/js-sdk-common';

export type EventName = 'error' | 'change' | 'dataSourceStatus';
type FlagChangeKey = `change:${string}`;

/**
* 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' | FlagChangeKey | 'dataSourceStatus' | 'error';

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

0 comments on commit da31436

Please sign in to comment.