From 8a4bc86706a966537bfa609ebc45743b64b09e11 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 4 Oct 2024 12:32:38 -0500 Subject: [PATCH 1/3] feat: adds support for individual flag change listeners --- .../LDClientImpl.changeemitter.test.ts | 176 ++++++++++++++++++ .../shared/sdk-client/src/LDClientImpl.ts | 12 +- packages/shared/sdk-client/src/LDEmitter.ts | 6 +- 3 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts new file mode 100644 index 000000000..c26a7dbef --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts @@ -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; +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((res) => { + ldc.on('change', (_context: LDContext, changes: string[]) => { + res(changes); + }); + }); + +describe('sdk-client storage', () => { + beforeEach(() => { + jest.useFakeTimers(); + defaultPutResponse = clone(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(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(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); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 7802bbbcf..a766fbb5a 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -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( @@ -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); diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index e07752caf..5cade0805 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -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; /** * Implementation Note: There should not be any default listeners for change events in a client From 79a3170caa46ce098a11ae33b3193d14b7f9ba53 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 4 Oct 2024 12:59:47 -0500 Subject: [PATCH 2/3] fixing copy pasta test name --- .../sdk-client/__tests__/LDClientImpl.changeemitter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts index c26a7dbef..3484aba0f 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts @@ -39,7 +39,7 @@ const onChangePromise = () => }); }); -describe('sdk-client storage', () => { +describe('sdk-client change emitter', () => { beforeEach(() => { jest.useFakeTimers(); defaultPutResponse = clone(mockResponseJson); From 8a965d20e2838d76f22350070280a892ec3c673e Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 4 Oct 2024 14:13:05 -0500 Subject: [PATCH 3/3] revising EventName typing --- packages/shared/sdk-client/src/LDEmitter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 5cade0805..9c56f3b1b 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -1,10 +1,12 @@ import { LDLogger } from '@launchdarkly/js-sdk-common'; +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' | 'dataSourceStatus' | 'error' | string; +export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error'; /** * Implementation Note: There should not be any default listeners for change events in a client