diff --git a/package.json b/package.json index 1913303..d9e8268 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "launchdarkly-js-test-helpers": "1.1.0", "prettier": "1.19.1", "readline-sync": "^1.4.9", - "typescript": "~5.4.5", - "typedoc": "^0.25.13" + "typedoc": "^0.25.13", + "typescript": "~5.4.5" }, "dependencies": { "base64-js": "^1.3.0", diff --git a/src/FlagStore.js b/src/FlagStore.js new file mode 100644 index 0000000..2583399 --- /dev/null +++ b/src/FlagStore.js @@ -0,0 +1,144 @@ +const utils = require('./utils'); + +/** + * FlagStore - Centralized flag store and access point for all feature flags + * + * This module manages two types of feature flags: + * 1. Regular flags - Retrieved from LaunchDarkly servers or bootstrap data + * 2. Override flags - Local overrides for debugging/testing + * + * When a flag is requested: + * - If an override exists for that flag, the override value is returned + * - Otherwise, the regular flag value is returned + */ +function FlagStore() { + let flags = {}; + // The flag overrides are set lazily to allow bypassing property checks when no overrides are present. + let flagOverrides; + + /** + * Gets a single flag by key, with overrides taking precedence over regular flags + * @param {string} key The flag key to retrieve + * @returns {Object|null} The flag object or null if not found + */ + function get(key) { + // Check overrides first, then real flags + if (flagOverrides && utils.objectHasOwnProperty(flagOverrides, key) && flagOverrides[key]) { + return flagOverrides[key]; + } + + if (flags && utils.objectHasOwnProperty(flags, key) && flags[key] && !flags[key].deleted) { + return flags[key]; + } + + return null; + } + + /** + * Gets all flags with overrides applied + * @returns {Object} Object containing all flags with any overrides applied + */ + function getFlagsWithOverrides() { + const result = {}; + + // Add all flags first + for (const key in flags) { + const flag = get(key); + if (flag) { + result[key] = flag; + } + } + + // Override with any flagOverrides (they take precedence) + if (flagOverrides) { + for (const key in flagOverrides) { + const override = get(key); + if (override) { + result[key] = override; + } + } + } + + return result; + } + + /** + * Replaces all flags with new flag data + * @param {Object} newFlags - Object containing the new flag data + */ + function setFlags(newFlags) { + flags = { ...newFlags }; + } + + /** + * Sets an override value for a specific flag + * @param {string} key The flag key to override + * @param {*} value The override value for the flag + */ + function setOverride(key, value) { + if (!flagOverrides) { + flagOverrides = {}; + } + flagOverrides[key] = { value }; + } + + /** + * Removes an override for a specific flag + * @param {string} key The flag key to remove the override for + */ + function removeOverride(key) { + if (!flagOverrides || !flagOverrides[key]) { + return; // No override to remove + } + + delete flagOverrides[key]; + + // If no more overrides, reset to undefined for performance + if (Object.keys(flagOverrides).length === 0) { + flagOverrides = undefined; + } + } + + /** + * Clears all flag overrides and returns the cleared overrides + * @returns {Object} The overrides that were cleared, useful for tracking what was removed + */ + function clearAllOverrides() { + if (!flagOverrides) { + return {}; // No overrides to clear, return empty object for consistency + } + + const clearedOverrides = { ...flagOverrides }; + flagOverrides = undefined; // Reset to undefined + return clearedOverrides; + } + + /** + * Gets the internal flag state without overrides applied + * @returns {Object} The internal flag data structure + */ + function getFlags() { + return flags; + } + + /** + * Gets the flag overrides data + * @returns {Object} The flag overrides object, or empty object if no overrides exist + */ + function getFlagOverrides() { + return flagOverrides || {}; + } + + return { + clearAllOverrides, + get, + getFlagOverrides, + getFlags, + getFlagsWithOverrides, + removeOverride, + setFlags, + setOverride, + }; +} + +module.exports = FlagStore; diff --git a/src/__tests__/LDClient-debugOverride-test.js b/src/__tests__/LDClient-debugOverride-test.js new file mode 100644 index 0000000..8b4ffdd --- /dev/null +++ b/src/__tests__/LDClient-debugOverride-test.js @@ -0,0 +1,342 @@ +const { initialize } = require('../index'); +const stubPlatform = require('./stubPlatform'); +const { respondJson } = require('./mockHttp'); +const { makeBootstrap } = require('./testUtils'); + +// Mock the logger functions +const mockLogger = () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}); + +// Define a basic Plugin structure for tests +const createTestPlugin = (name = 'Test Plugin') => { + const plugin = { + getMetadata: jest.fn().mockReturnValue({ name }), + register: jest.fn(), + getHooks: jest.fn().mockReturnValue([]), + registerDebug: jest.fn(), + }; + + return plugin; +}; + +// Helper to initialize the client for tests +async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) { + const platform = stubPlatform.defaults(); + const server = platform.testing.http.newServer(); + const logger = mockLogger(); + + // Disable streaming and event sending unless overridden + const defaults = { + baseUrl: server.url, + streaming: false, + sendEvents: false, + useLdd: false, + logger: logger, + plugins: plugins, + }; + const config = { ...defaults, ...configOverrides }; + const { client, start } = initialize('env', initialContext, config, platform); + + server.byDefault(respondJson({})); + start(); + + try { + await client.waitForInitialization(10); + await testFn(client, logger, platform); + } finally { + await client.close(); + server.close(); + } +} + +describe('LDDebugOverride', () => { + describe('setOverride method', () => { + it('should set override value returned by variation method', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + expect(client.variation('test-flag', 'default')).toBe('default'); + + debugOverrideInterface.setOverride('test-flag', 'override-value'); + expect(client.variation('test-flag', 'default')).toBe('override-value'); + }); + }); + + it('should override values taking precedence over real flag values from bootstrap', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + const flags = makeBootstrap({ 'existing-flag': { value: 'real-value', version: 1 } }); + + await withClient({ key: 'user-key', kind: 'user' }, { bootstrap: flags }, [mockPlugin], async client => { + expect(client.variation('existing-flag', 'default')).toBe('real-value'); + + debugOverrideInterface.setOverride('existing-flag', 'override-value'); + expect(client.variation('existing-flag', 'default')).toBe('override-value'); + + debugOverrideInterface.removeOverride('existing-flag'); + expect(client.variation('existing-flag', 'default')).toBe('real-value'); + }); + }); + }); + + describe('removeOverride method', () => { + it('should remove individual override and revert to default', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('test-flag', 'override-value'); + expect(client.variation('test-flag', 'default')).toBe('override-value'); + + debugOverrideInterface.removeOverride('test-flag'); + expect(client.variation('test-flag', 'default')).toBe('default'); + }); + }); + + it('should remove only the specified override leaving others intact', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('flag1', 'value1'); + debugOverrideInterface.setOverride('flag2', 'value2'); + debugOverrideInterface.setOverride('flag3', 'value3'); + + debugOverrideInterface.removeOverride('flag2'); + + expect(client.variation('flag1', 'default')).toBe('value1'); + expect(client.variation('flag2', 'default')).toBe('default'); + expect(client.variation('flag3', 'default')).toBe('value3'); + + const allOverrides = debugOverrideInterface.getAllOverrides(); + expect(allOverrides).toEqual({ + flag1: 'value1', + flag3: 'value3', + }); + }); + }); + + it('should handle removing non-existent override without throwing error', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('existing-flag', 'value'); + + // Should not throw error + expect(() => { + debugOverrideInterface.removeOverride('non-existent-flag'); + }).not.toThrow(); + + // Existing override should remain + expect(client.variation('existing-flag', 'default')).toBe('value'); + const allOverrides = debugOverrideInterface.getAllOverrides(); + expect(allOverrides).toEqual({ 'existing-flag': 'value' }); + }); + }); + + it('should be callable multiple times on same flag key safely', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('test-flag', 'value'); + + debugOverrideInterface.removeOverride('test-flag'); + expect(client.variation('test-flag', 'default')).toBe('default'); + + // Removing again should not cause issues + expect(() => { + debugOverrideInterface.removeOverride('test-flag'); + }).not.toThrow(); + + expect(debugOverrideInterface.getAllOverrides()).toEqual({}); + }); + }); + }); + + describe('clearAllOverrides method', () => { + it('should clear all overrides and revert all flags to their default values', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('flag1', 'value1'); + debugOverrideInterface.setOverride('flag2', 'value2'); + + debugOverrideInterface.clearAllOverrides(); + expect(client.variation('flag1', 'default')).toBe('default'); + expect(client.variation('flag2', 'default')).toBe('default'); + expect(debugOverrideInterface.getAllOverrides()).toEqual({}); + }); + }); + + it('should operate safely when no overrides exist', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => { + // Should not throw error when no overrides exist + expect(() => { + debugOverrideInterface.clearAllOverrides(); + }).not.toThrow(); + + expect(debugOverrideInterface.getAllOverrides()).toEqual({}); + }); + }); + }); + + describe('getAllOverrides method', () => { + it('should return all current overrides', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => { + debugOverrideInterface.setOverride('test-flag', 'override-value'); + + const allOverrides = debugOverrideInterface.getAllOverrides(); + expect(allOverrides).toEqual({ 'test-flag': 'override-value' }); + }); + }); + + it('should return empty object when no overrides have been set', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => { + const allOverrides = debugOverrideInterface.getAllOverrides(); + expect(allOverrides).toEqual({}); + expect(typeof allOverrides).toBe('object'); + expect(Array.isArray(allOverrides)).toBe(false); + }); + }); + + it('should return immutable copy not reference to internal state', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('test-flag', 'original-value'); + + const overrides1 = debugOverrideInterface.getAllOverrides(); + const overrides2 = debugOverrideInterface.getAllOverrides(); + + // Should be different objects + expect(overrides1).not.toBe(overrides2); + + // Modifying returned object should not affect internal state + overrides1['new-flag'] = 'new-value'; + delete overrides1['test-flag']; + + expect(client.variation('test-flag', 'default')).toBe('original-value'); + expect(client.variation('new-flag', 'default')).toBe('default'); + + const overrides3 = debugOverrideInterface.getAllOverrides(); + expect(overrides3).toEqual({ 'test-flag': 'original-value' }); + }); + }); + + it('should maintain consistency across different operations', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => { + // Test consistency through various operations + expect(debugOverrideInterface.getAllOverrides()).toEqual({}); + + debugOverrideInterface.setOverride('flag1', 'value1'); + expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag1: 'value1' }); + + debugOverrideInterface.setOverride('flag2', 'value2'); + expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag1: 'value1', flag2: 'value2' }); + + debugOverrideInterface.removeOverride('flag1'); + expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag2: 'value2' }); + + debugOverrideInterface.setOverride('flag2', 'updated-value2'); + expect(debugOverrideInterface.getAllOverrides()).toEqual({ flag2: 'updated-value2' }); + + debugOverrideInterface.clearAllOverrides(); + expect(debugOverrideInterface.getAllOverrides()).toEqual({}); + }); + }); + }); + + describe('integration with client methods', () => { + it('should work correctly with variationDetail method', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + debugOverrideInterface.setOverride('test-flag', 'override-value'); + + const detail = client.variationDetail('test-flag', 'default'); + expect(detail.value).toBe('override-value'); + }); + }); + + it('should include overrides in allFlags method output', async () => { + let debugOverrideInterface; + const mockPlugin = createTestPlugin('test-plugin'); + mockPlugin.registerDebug.mockImplementation(debugOverride => { + debugOverrideInterface = debugOverride; + }); + + const flags = makeBootstrap({ 'real-flag': { value: 'real-value', version: 1 } }); + + await withClient({ key: 'user-key', kind: 'user' }, { bootstrap: flags }, [mockPlugin], async client => { + debugOverrideInterface.setOverride('override-flag', 'override-value'); + debugOverrideInterface.setOverride('real-flag', 'overridden-real-value'); + + const allFlags = client.allFlags(); + expect(allFlags['real-flag']).toBe('overridden-real-value'); + expect(allFlags['override-flag']).toBe('override-value'); + }); + }); + }); +}); diff --git a/src/__tests__/LDClient-plugins-test.js b/src/__tests__/LDClient-plugins-test.js index fcc690b..411405a 100644 --- a/src/__tests__/LDClient-plugins-test.js +++ b/src/__tests__/LDClient-plugins-test.js @@ -21,11 +21,19 @@ const createTestHook = (name = 'Test Hook') => ({ }); // Define a basic Plugin structure for tests -const createTestPlugin = (name = 'Test Plugin', hooks = []) => ({ - getMetadata: jest.fn().mockReturnValue({ name }), - register: jest.fn(), - getHooks: jest.fn().mockReturnValue(hooks), -}); +const createTestPlugin = (name = 'Test Plugin', hooks = [], includeDebug = false) => { + const plugin = { + getMetadata: jest.fn().mockReturnValue({ name }), + register: jest.fn(), + getHooks: jest.fn().mockReturnValue(hooks), + }; + + if (includeDebug) { + plugin.registerDebug = jest.fn(); + } + + return plugin; +}; // Helper to initialize the client for tests async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) { @@ -212,3 +220,53 @@ it('passes correct environmentMetadata without optional fields', async () => { } ); }); + +it('registers plugins and calls registerDebug when a plugin implements it', async () => { + const mockPlugin = createTestPlugin('test-plugin', [], true); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => { + expect(mockPlugin.register).toHaveBeenCalled(); + + // Verify that registerDebug was called + expect(mockPlugin.registerDebug).toHaveBeenCalledTimes(1); + }); +}); + +it('registers plugins but does not call registerDebug when a plugin does not implement it', async () => { + const mockPlugin = createTestPlugin('test-plugin', [], false); + + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async () => { + expect(mockPlugin.register).toHaveBeenCalled(); + + // Verify that registerDebug was not called + expect(mockPlugin.registerDebug).toBeUndefined(); + }); +}); + +it('registers multiple plugins and calls registerDebug selectively', async () => { + const mockPluginWithDebug1 = createTestPlugin('test-plugin-with-debug-1', [], true); + const mockPluginWithDebug2 = createTestPlugin('test-plugin-with-debug-2', [], true); + const mockPluginWithoutDebug1 = createTestPlugin('test-plugin-without-debug-1', [], false); + const mockPluginWithoutDebug2 = createTestPlugin('test-plugin-without-debug-2', [], false); + + await withClient( + { key: 'user-key', kind: 'user' }, + {}, + [mockPluginWithDebug1, mockPluginWithoutDebug1, mockPluginWithDebug2, mockPluginWithoutDebug2], + async () => { + // Verify all plugins were registered + expect(mockPluginWithDebug1.register).toHaveBeenCalled(); + expect(mockPluginWithDebug2.register).toHaveBeenCalled(); + expect(mockPluginWithoutDebug1.register).toHaveBeenCalled(); + expect(mockPluginWithoutDebug2.register).toHaveBeenCalled(); + + // Verify that registerDebug was called only on plugins that implement it + expect(mockPluginWithDebug1.registerDebug).toHaveBeenCalledTimes(1); + expect(mockPluginWithDebug2.registerDebug).toHaveBeenCalledTimes(1); + + // Verify that registerDebug was not called on plugins that don't implement it + expect(mockPluginWithoutDebug1.registerDebug).toBeUndefined(); + expect(mockPluginWithoutDebug2.registerDebug).toBeUndefined(); + } + ); +}); diff --git a/src/index.js b/src/index.js index 86d6752..e05fec6 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,13 @@ const { checkContext, getContextKeys } = require('./context'); const { InspectorTypes, InspectorManager } = require('./InspectorManager'); const timedPromise = require('./timedPromise'); const createHookRunner = require('./HookRunner'); -const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins'); +const FlagStore = require('./FlagStore'); +const { + getPluginHooks, + registerPlugins, + registerPluginsForDebugOverride, + createPluginEnvironment, +} = require('./plugins'); const changeEvent = 'change'; const internalChangeEvent = 'internal-change'; const highTimeoutThreshold = 5; @@ -76,7 +82,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const requestor = Requestor(platform, options, environment); - let flags = {}; + const flagStore = FlagStore(); + let useLocalStorage; let streamActive; let streamForcedState = options.streaming; @@ -188,7 +195,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { function notifyInspectionFlagsChanged() { if (inspectorManager.hasListeners(InspectorTypes.flagDetailsChanged)) { inspectorManager.onFlags( - Object.entries(flags) + Object.entries(flagStore.getFlagsWithOverrides()) .map(([key, value]) => ({ key, detail: getFlagDetail(value) })) .reduce((acc, cur) => { // eslint-disable-next-line no-param-reassign @@ -232,7 +239,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { default: defaultValue, creationDate: now.getTime(), }; - const flag = flags[key]; + const flag = flagStore.getFlags()[key]; if (flag) { event.version = flag.flagVersion ? flag.flagVersion : flag.version; event.trackEvents = flag.trackEvents; @@ -262,7 +269,10 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { if (stateProvider) { // We're being controlled by another client instance, so only that instance is allowed to change the context logger.warn(messages.identifyDisabled()); - return utils.wrapPromiseCallback(Promise.resolve(utils.transformVersionedValuesToValues(flags)), onDone); + return utils.wrapPromiseCallback( + Promise.resolve(utils.transformVersionedValuesToValues(flagStore.getFlagsWithOverrides())), + onDone + ); } let afterIdentify; const clearFirst = useLocalStorage && persistentFlagStore ? persistentFlagStore.clearFlags() : Promise.resolve(); @@ -328,10 +338,10 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { function variationDetailInternal(key, defaultValue, sendEvent, includeReasonInEvent, isAllFlags, notifyInspection) { let detail; - let flag; - if (flags && utils.objectHasOwnProperty(flags, key) && flags[key] && !flags[key].deleted) { - flag = flags[key]; + const flag = flagStore.get(key); + + if (flag) { detail = getFlagDetail(flag); if (flag.value === null || flag.value === undefined) { detail.value = defaultValue; @@ -373,21 +383,14 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { function allFlags() { const results = {}; - if (!flags) { + const allFlags = flagStore.getFlagsWithOverrides(); + + if (!allFlags) { return results; } - for (const key in flags) { - if (utils.objectHasOwnProperty(flags, key) && !flags[key].deleted) { - results[key] = variationDetailInternal( - key, - null, - !options.sendEventsOnlyForVariation, - false, - true, - false - ).value; - } + for (const key in allFlags) { + results[key] = variationDetailInternal(key, null, !options.sendEventsOnlyForVariation, false, true, false).value; } return results; @@ -485,6 +488,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { // If both the flag and the patch have a version property, then the patch version must be // greater than the flag version for us to accept the patch. If either one has no version // then the patch always succeeds. + const flags = flagStore.getFlags(); const oldFlag = flags[data.key]; if (!oldFlag || !oldFlag.version || !data.version || oldFlag.version < data.version) { logger.debug(messages.debugStreamPatch(data.key)); @@ -492,6 +496,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const newFlag = utils.extend({}, data); delete newFlag['key']; flags[data.key] = newFlag; + flagStore.setFlags(flags); const newDetail = getFlagDetail(newFlag); if (oldFlag) { mods[data.key] = { previous: oldFlag.value, current: newDetail }; @@ -509,6 +514,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { if (!data) { return; } + const flags = flagStore.getFlags(); if (!flags[data.key] || flags[data.key].version < data.version) { logger.debug(messages.debugStreamDelete(data.key)); const mods = {}; @@ -516,6 +522,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { mods[data.key] = { previous: flags[data.key].value }; } flags[data.key] = { version: data.version, deleted: true }; + flagStore.setFlags(flags); notifyInspectionFlagChanged(data, flags[data.key]); handleFlagChanges(mods); // don't wait for this Promise to be resolved } else { @@ -542,6 +549,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { return Promise.resolve(); } + const flags = flagStore.getFlags(); for (const key in flags) { if (utils.objectHasOwnProperty(flags, key) && flags[key]) { if (newFlags[key] && !utils.deepEquals(newFlags[key].value, flags[key].value)) { @@ -557,7 +565,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { } } - flags = { ...newFlags }; + flagStore.setFlags({ ...newFlags }); notifyInspectionFlagsChanged(); @@ -580,7 +588,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { }); emitter.emit(changeEvent, changeEventParams); - emitter.emit(internalChangeEvent, flags); + emitter.emit(internalChangeEvent, flagStore.getFlagsWithOverrides()); // By default, we send feature evaluation events whenever we have received new flag values - // the client has in effect evaluated these flags just by receiving them. This can be suppressed @@ -595,7 +603,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { } if (useLocalStorage && persistentFlagStore) { - return persistentFlagStore.saveFlags(flags); + return persistentFlagStore.saveFlags(flagStore.getFlags()); } else { return Promise.resolve(); } @@ -666,7 +674,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { if (typeof options.bootstrap === 'object') { // Set the flags as soon as possible before we get into any async code, so application code can read // them even if the ready event has not yet fired. - flags = readFlagsFromBootstrap(options.bootstrap); + flagStore.setFlags(readFlagsFromBootstrap(options.bootstrap)); } if (stateProvider) { @@ -718,7 +726,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { function finishInitWithLocalStorage() { return persistentFlagStore.loadFlags().then(storedFlags => { if (storedFlags === null || storedFlags === undefined) { - flags = {}; + flagStore.setFlags({}); return requestor .fetchFlagSettings(ident.getContext(), hash) .then(requestedFlags => replaceAllFlags(requestedFlags || {})) @@ -731,7 +739,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { // We're reading the flags from local storage. Signal that we're ready, // then update localStorage for the next page load. We won't signal changes or update // the in-memory flags unless you subscribe for changes - flags = storedFlags; + flagStore.setFlags(storedFlags); utils.onNextTick(signalSuccessfulInit); return requestor @@ -746,14 +754,14 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { return requestor .fetchFlagSettings(ident.getContext(), hash) .then(requestedFlags => { - flags = requestedFlags || {}; + flagStore.setFlags(requestedFlags || {}); notifyInspectionFlagsChanged(); // Note, we don't need to call updateSettings here because local storage and change events are not relevant signalSuccessfulInit(); }) .catch(err => { - flags = {}; + flagStore.setFlags({}); signalFailedInit(err); }); } @@ -761,7 +769,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { function initFromStateProvider(state) { environment = state.environment; ident.setContext(state.context); - flags = { ...state.flags }; + flagStore.setFlags({ ...state.flags }); utils.onNextTick(signalSuccessfulInit); } @@ -800,7 +808,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { } const finishClose = () => { closed = true; - flags = {}; + flagStore.setFlags({}); }; const p = Promise.resolve() .then(() => { @@ -820,7 +828,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { function getFlagsInternal() { // used by Electron integration - return flags; + return flagStore.getFlagsWithOverrides(); } function waitForInitializationWithTimeout(timeout) { @@ -880,6 +888,94 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { registerPlugins(logger, pluginEnvironment, client, plugins); + function setOverride(key, value) { + const mods = {}; + + const currentFlag = flagStore.get(key); + const currentValue = currentFlag ? currentFlag.value : null; + + if (currentValue === value) { + logger.debug(`setOverride: No change needed for ${key}, value already ${value}`); + return; + } + + flagStore.setOverride(key, value); + const newFlag = flagStore.get(key); + const newDetail = getFlagDetail(newFlag); + + mods[key] = { previous: currentValue, current: newDetail }; + + notifyInspectionFlagChanged({ key }, newFlag); + handleFlagChanges(mods); + } + + function removeOverride(key) { + const flagOverrides = flagStore.getFlagOverrides(); + if (!flagOverrides[key]) { + return; // No override to remove + } + + const mods = {}; + const oldOverride = flagOverrides[key]; + const flags = flagStore.getFlags(); + const realFlag = flags[key]; + + mods[key] = { previous: oldOverride.value, current: realFlag ? getFlagDetail(realFlag) : undefined }; + + flagStore.removeOverride(key); + notifyInspectionFlagChanged({ key }, realFlag); + handleFlagChanges(mods); // don't wait for this Promise to be resolved + } + + function clearAllOverrides() { + const flagOverrides = flagStore.getFlagOverrides(); + if (Object.keys(flagOverrides).length === 0) { + return; // No overrides to clear + } + + const mods = {}; + const flags = flagStore.getFlags(); + Object.keys(flagOverrides).forEach(key => { + const oldOverride = flagOverrides[key]; + const realFlag = flags[key]; + + mods[key] = { previous: oldOverride.value, current: realFlag ? getFlagDetail(realFlag) : undefined }; + }); + + flagStore.clearAllOverrides(); + + if (Object.keys(mods).length > 0) { + handleFlagChanges(mods); // don't wait for this Promise to be resolved + } + } + + function getAllOverrides() { + const flagOverrides = flagStore.getFlagOverrides(); + + if (!flagOverrides) { + return {}; // No overrides set + } + + const result = {}; + Object.keys(flagOverrides).forEach(key => { + const override = flagOverrides[key]; + if (override) { + result[key] = override.value; + } + }); + return result; + } + + const debugOverride = { + setOverride: setOverride, + removeOverride: removeOverride, + clearAllOverrides: clearAllOverrides, + getAllOverrides: getAllOverrides, + }; + + // Register plugins for debug override capabilities + registerPluginsForDebugOverride(logger, debugOverride, plugins); + return { client: client, // The client object containing all public methods. options: options, // The validated configuration object, including all defaults. diff --git a/src/plugins.js b/src/plugins.js index 561635b..8bd8b10 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -56,6 +56,22 @@ function registerPlugins(logger, environmentMetadata, client, plugins) { }); } +/** + * Registers plugins for debug override capabilities + * @param {{ error: (message: string) => void }} logger - The logger instance + * @param {Object} debugOverride - The debug override interface object + * @param {Array<{registerDebug?: (debugOverride: object) => void}>} plugins - Array of plugin objects that may implement registerDebug + */ +function registerPluginsForDebugOverride(logger, debugOverride, plugins) { + plugins.forEach(plugin => { + try { + plugin.registerDebug?.(debugOverride); + } catch (error) { + logger.error(`Exception thrown registering debug override with plugin ${getPluginName(logger, plugin)}.`); + } + }); +} + /** * Creates a plugin environment object * @param {{userAgent: string, version: string}} platform - The platform object @@ -105,5 +121,6 @@ function createPluginEnvironment(platform, env, options) { module.exports = { getPluginHooks, registerPlugins, + registerPluginsForDebugOverride, createPluginEnvironment, }; diff --git a/test-types.ts b/test-types.ts index b27906f..e0bbc73 100644 --- a/test-types.ts +++ b/test-types.ts @@ -55,7 +55,7 @@ const plugin: ld.LDPlugin = { }), register(client: ld.LDClientBase, environmentMetadata: ld.LDPluginEnvironmentMetadata): void { }, - + registerDebug(debugOverride: ld.LDDebugOverride): void {}, getHooks(metadata: ld.LDPluginEnvironmentMetadata): ld.Hook[] { return []; }, diff --git a/typings.d.ts b/typings.d.ts index 6484457..3aae737 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -74,7 +74,7 @@ declare module 'launchdarkly-js-sdk-common' { } /** - * Meta-data about a hook implementation. + * Metadata about a hook implementation. */ export interface HookMetadata { /** @@ -149,7 +149,6 @@ declare module 'launchdarkly-js-sdk-common' { readonly metricValue?: number; } - /** * Interface for extending SDK functionality via hooks. */ @@ -251,7 +250,7 @@ declare module 'launchdarkly-js-sdk-common' { } /** - * Meta-data about a plugin implementation. + * Metadata about a plugin implementation. * * May be used in logs and analytics to identify the plugin. */ @@ -363,6 +362,59 @@ export interface LDPlugin { * @param metadata */ getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[]; + + /** + * An optional function called if the plugin wants to register debug capabilities. + * This method allows plugins to receive a debug override interface for + * temporarily overriding flag values during development and testing. + * + * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. + * The API may change in future versions. + * + * @param debugOverride The debug override interface instance + */ + registerDebug?(debugOverride: LDDebugOverride): void; +} + +/** + * Debug interface for plugins that need to override flag values during development. + * This interface provides methods to temporarily override flag values that take + * precedence over the actual flag values from LaunchDarkly. These overrides are + * useful for testing, development, and debugging scenarios. + * + * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. + * The API may change in future versions. + */ +export interface LDDebugOverride { + /** + * Set an override value for a flag that takes precedence over the real flag value. + * + * @param flagKey The flag key. + * @param value The override value. + */ + setOverride(flagKey: string, value: LDFlagValue): void; + + /** + * Remove an override value for a flag, reverting to the real flag value. + * + * @param flagKey The flag key. + */ + removeOverride(flagKey: string): void; + + /** + * Clear all override values, reverting all flags to their real values. + */ + clearAllOverrides(): void; + + /** + * Get all currently active flag overrides. + * + * @returns + * An object containing all active overrides as key-value pairs, + * where keys are flag keys and values are the overridden flag values. + * Returns an empty object if no overrides are active. + */ + getAllOverrides(): LDFlagSet; } /**