Skip to content

Commit 17f5e7d

Browse files
joker23kinyoklion
andauthored
feat: adding support for debug override plugins (#1033)
**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 sdk-1653 sdk-1565 **Describe the solution you've provided** - Added `LDDebugOverride` interface to manage flag value overrides during development. - Introduced `safeRegisterDebugOverridePlugins` function to register plugins with debug capabilities. - Updated `FlagManager` to support debug overrides, including methods to set, remove, and clear overrides. - Enhanced `LDClientImpl` to utilize debug overrides during client initialization. - Refactored `LDPlugin` interface to include optional `registerDebug` method for plugins. This PR will enable `@launchdarkly/toolbar` to use 4.x **Additional context** - ~This PR is based off of #1028 (will need to do some rebase magic later)~ - There are a few places that I've marked `REVIEWER` that I would like some additional discussions before merging - In this change we are assuming that the plugin support (eg registering plugins) is still the responsibility of individual SDK implementations. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces a debug flag override interface, wires plugin registration for overrides, and updates flag management and clients to support and emit override-driven changes. > > - **Flag management**: > - Add `LDDebugOverride` interface and implement in `DefaultFlagManager` (`setOverride`, `removeOverride`, `clearAllOverrides`, `getAllOverrides`). > - `get`/`getAll` now merge in-memory overrides with stored flags; override values take precedence. > - Emit change callbacks with type `"override"` via `FlagUpdater.handleFlagChanges`. > - **Updater**: > - Extend `FlagChangeType` with `"override"`; add `handleFlagChanges` helper; refactor `init`/`upsert` to use it. > - **Client integration**: > - `LDClientImpl`: expose `getDebugOverrides()` from `FlagManager` and continue inspection updates on changes. > - Browser client: during `registerPlugins`, call `safeRegisterDebugOverridePlugins` to pass overrides to plugins. > - **Plugin API & utilities**: > - Add `LDPluginBase.registerDebug(debugOverride)` optional method. > - New `safeRegisterDebugOverridePlugins` utility to safely register debug override plugins. > - **Exports**: > - Re-export `LDPluginBase`, `LDDebugOverride`, and `safeRegisterDebugOverridePlugins` in public entrypoints. > - **Tests**: > - Add comprehensive `FlagManager` override tests covering precedence, callbacks, clearing, and merging. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2bf10a3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Ryan Lamb <[email protected]>
1 parent 7c2869b commit 17f5e7d

File tree

10 files changed

+458
-18
lines changed

10 files changed

+458
-18
lines changed

packages/sdk/browser/src/BrowserClient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
LDIdentifyResult,
1818
LDPluginEnvironmentMetadata,
1919
Platform,
20+
safeRegisterDebugOverridePlugins,
2021
} from '@launchdarkly/js-client-sdk-common';
2122

2223
import { readFlagsFromBootstrap } from './bootstrap';
@@ -211,6 +212,11 @@ class BrowserClientImpl extends LDClientImpl {
211212
client,
212213
this._plugins || [],
213214
);
215+
216+
const override = this.getDebugOverrides();
217+
if (override) {
218+
safeRegisterDebugOverridePlugins(this.logger, override, this._plugins || []);
219+
}
214220
}
215221

216222
override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void> {

packages/sdk/browser/src/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type {
4343
LDIdentifyError,
4444
LDIdentifyTimeout,
4545
LDIdentifyShed,
46+
LDDebugOverride,
4647
} from '@launchdarkly/js-client-sdk-common';
4748

4849
/**
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common';
2+
3+
import DefaultFlagManager from '../../src/flag-manager/FlagManager';
4+
import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater';
5+
import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor';
6+
import { Flag } from '../../src/types';
7+
8+
const TEST_SDK_KEY = 'test-sdk-key';
9+
const TEST_MAX_CACHED_CONTEXTS = 5;
10+
11+
function makeMockPlatform(storage: Storage, crypto: Crypto): Platform {
12+
return {
13+
storage,
14+
crypto,
15+
info: {
16+
platformData: jest.fn(),
17+
sdkData: jest.fn(),
18+
},
19+
requests: {
20+
fetch: jest.fn(),
21+
createEventSource: jest.fn(),
22+
getEventSourceCapabilities: jest.fn(),
23+
},
24+
};
25+
}
26+
27+
function makeMemoryStorage(): Storage {
28+
const data = new Map<string, string>();
29+
return {
30+
get: async (key: string) => {
31+
const value = data.get(key);
32+
return value !== undefined ? value : null;
33+
},
34+
set: async (key: string, value: string) => {
35+
data.set(key, value);
36+
},
37+
clear: async (key: string) => {
38+
data.delete(key);
39+
},
40+
};
41+
}
42+
43+
function makeMockCrypto() {
44+
let counter = 0;
45+
let lastInput = '';
46+
const hasher: Hasher = {
47+
update: jest.fn((input) => {
48+
lastInput = input;
49+
return hasher;
50+
}),
51+
digest: jest.fn(() => `${lastInput}Hashed`),
52+
};
53+
54+
return {
55+
createHash: jest.fn(() => hasher),
56+
createHmac: jest.fn(),
57+
randomUUID: jest.fn(() => {
58+
counter += 1;
59+
return `${counter}`;
60+
}),
61+
};
62+
}
63+
64+
function makeMockLogger(): LDLogger {
65+
return {
66+
error: jest.fn(),
67+
warn: jest.fn(),
68+
info: jest.fn(),
69+
debug: jest.fn(),
70+
};
71+
}
72+
73+
function makeMockFlag(version: number = 1, value: any = 'test-value'): Flag {
74+
return {
75+
version,
76+
flagVersion: version,
77+
value,
78+
variation: 0,
79+
trackEvents: false,
80+
};
81+
}
82+
83+
function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): ItemDescriptor {
84+
return {
85+
version,
86+
flag: makeMockFlag(version, value),
87+
};
88+
}
89+
90+
describe('FlagManager override tests', () => {
91+
let flagManager: DefaultFlagManager;
92+
let mockPlatform: Platform;
93+
let mockLogger: LDLogger;
94+
95+
beforeEach(() => {
96+
mockLogger = makeMockLogger();
97+
mockPlatform = makeMockPlatform(makeMemoryStorage(), makeMockCrypto());
98+
flagManager = new DefaultFlagManager(
99+
mockPlatform,
100+
TEST_SDK_KEY,
101+
TEST_MAX_CACHED_CONTEXTS,
102+
mockLogger,
103+
);
104+
});
105+
106+
it('setOverride takes precedence over flag store value', async () => {
107+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
108+
const flags = {
109+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
110+
};
111+
112+
await flagManager.init(context, flags);
113+
expect(flagManager.get('test-flag')?.flag.value).toBe('store-value');
114+
115+
const debugOverride = flagManager.getDebugOverride();
116+
debugOverride?.setOverride('test-flag', 'override-value');
117+
118+
expect(flagManager.get('test-flag')?.flag.value).toBe('override-value');
119+
});
120+
121+
it('setOverride triggers flag change callback', async () => {
122+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
123+
const flags = {
124+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
125+
};
126+
127+
await flagManager.init(context, flags);
128+
129+
const mockCallback: FlagsChangeCallback = jest.fn();
130+
flagManager.on(mockCallback);
131+
132+
const debugOverride = flagManager.getDebugOverride();
133+
debugOverride?.setOverride('test-flag', 'override-value');
134+
135+
expect(mockCallback).toHaveBeenCalledTimes(1);
136+
expect(mockCallback).toHaveBeenCalledWith(context, ['test-flag'], 'override');
137+
});
138+
139+
it('removeOverride does nothing when override does not exist', () => {
140+
const debugOverride = flagManager.getDebugOverride();
141+
expect(() => {
142+
debugOverride?.removeOverride('non-existent-flag');
143+
}).not.toThrow();
144+
});
145+
146+
it('removeOverride reverts to flag store value when override is removed', async () => {
147+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
148+
const flags = {
149+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
150+
};
151+
152+
await flagManager.init(context, flags);
153+
const debugOverride = flagManager.getDebugOverride();
154+
debugOverride?.setOverride('test-flag', 'override-value');
155+
expect(flagManager.get('test-flag')?.flag.value).toBe('override-value');
156+
157+
debugOverride?.removeOverride('test-flag');
158+
expect(flagManager.get('test-flag')?.flag.value).toBe('store-value');
159+
});
160+
161+
it('removeOverride triggers flag change callback', async () => {
162+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
163+
const flags = {
164+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
165+
};
166+
167+
await flagManager.init(context, flags);
168+
169+
const mockCallback: FlagsChangeCallback = jest.fn();
170+
flagManager.on(mockCallback);
171+
172+
const debugOverride = flagManager.getDebugOverride();
173+
debugOverride?.setOverride('test-flag', 'override-value');
174+
debugOverride?.removeOverride('test-flag');
175+
176+
expect(mockCallback).toHaveBeenCalledTimes(2);
177+
expect(mockCallback).toHaveBeenNthCalledWith(1, context, ['test-flag'], 'override');
178+
expect(mockCallback).toHaveBeenNthCalledWith(2, context, ['test-flag'], 'override');
179+
});
180+
181+
it('clearAllOverrides removes all overrides', () => {
182+
const debugOverride = flagManager.getDebugOverride();
183+
debugOverride?.setOverride('flag1', 'value1');
184+
debugOverride?.setOverride('flag2', 'value2');
185+
debugOverride?.setOverride('flag3', 'value3');
186+
187+
expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(3);
188+
189+
debugOverride?.clearAllOverrides();
190+
expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(0);
191+
});
192+
193+
it('clearAllOverrides triggers flag change callback for all flags', async () => {
194+
const mockCallback: FlagsChangeCallback = jest.fn();
195+
flagManager.on(mockCallback);
196+
197+
const debugOverride = flagManager.getDebugOverride();
198+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
199+
const flags = {
200+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
201+
};
202+
203+
await flagManager.init(context, flags);
204+
205+
debugOverride?.setOverride('flag1', 'value1');
206+
debugOverride?.setOverride('flag2', 'value2');
207+
(mockCallback as jest.Mock).mockClear();
208+
209+
debugOverride?.clearAllOverrides();
210+
expect(mockCallback).toHaveBeenCalledTimes(1);
211+
expect(mockCallback).toHaveBeenCalledWith(context, ['flag1', 'flag2'], 'override');
212+
});
213+
214+
it('getAllOverrides returns all overrides as ItemDescriptors', () => {
215+
const debugOverride = flagManager.getDebugOverride();
216+
debugOverride?.setOverride('flag1', 'value1');
217+
debugOverride?.setOverride('flag2', 42);
218+
debugOverride?.setOverride('flag3', true);
219+
220+
const overrides = debugOverride?.getAllOverrides();
221+
expect(overrides).toHaveProperty('flag1');
222+
expect(overrides).toHaveProperty('flag2');
223+
expect(overrides).toHaveProperty('flag3');
224+
expect(overrides?.flag1.flag.value).toBe('value1');
225+
expect(overrides?.flag2.flag.value).toBe(42);
226+
expect(overrides?.flag3.flag.value).toBe(true);
227+
expect(overrides?.flag1.version).toBe(0);
228+
expect(overrides?.flag2.version).toBe(0);
229+
expect(overrides?.flag3.version).toBe(0);
230+
});
231+
232+
it('getAll merges overrides with flag store values', async () => {
233+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
234+
const flags = {
235+
'store-flag': makeMockItemDescriptor(1, 'store-value'),
236+
'shared-flag': makeMockItemDescriptor(1, 'store-value'),
237+
};
238+
239+
await flagManager.init(context, flags);
240+
const debugOverride = flagManager.getDebugOverride();
241+
debugOverride?.setOverride('shared-flag', 'override-value');
242+
debugOverride?.setOverride('override-only-flag', 'override-value');
243+
244+
const allFlags = flagManager.getAll();
245+
expect(allFlags).toHaveProperty('store-flag');
246+
expect(allFlags).toHaveProperty('shared-flag');
247+
expect(allFlags).toHaveProperty('override-only-flag');
248+
expect(allFlags['store-flag'].flag.value).toBe('store-value');
249+
expect(allFlags['shared-flag'].flag.value).toBe('override-value');
250+
expect(allFlags['override-only-flag'].flag.value).toBe('override-value');
251+
});
252+
});

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
} from './evaluation/evaluationDetail';
4646
import createEventProcessor from './events/createEventProcessor';
4747
import EventFactory from './events/EventFactory';
48-
import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager';
48+
import DefaultFlagManager, { FlagManager, LDDebugOverride } from './flag-manager/FlagManager';
4949
import { FlagChangeType } from './flag-manager/FlagUpdater';
5050
import { ItemDescriptor } from './flag-manager/ItemDescriptor';
5151
import HookRunner from './HookRunner';
@@ -607,6 +607,10 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
607607
this._eventProcessor?.sendEvent(event);
608608
}
609609

610+
protected getDebugOverrides(): LDDebugOverride | undefined {
611+
return this._flagManager.getDebugOverride?.();
612+
}
613+
610614
private _handleInspectionChanged(flagKeys: Array<string>, type: FlagChangeType) {
611615
if (!this._inspectorManager.hasInspectors()) {
612616
return;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { LDPluginBase as LDPluginBaseCommon } from '@launchdarkly/js-sdk-common';
2+
3+
import { LDDebugOverride } from '../flag-manager/FlagManager';
4+
5+
export interface LDPluginBase<TClient, THook> extends LDPluginBaseCommon<TClient, THook> {
6+
/**
7+
* An optional function called if the plugin wants to register debug capabilities.
8+
* This method allows plugins to receive a debug override interface for
9+
* temporarily overriding flag values during development and testing.
10+
*
11+
* @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time.
12+
* The API may change in future versions.
13+
*
14+
* @param debugOverride The debug override interface instance
15+
*/
16+
registerDebug?(debugOverride: LDDebugOverride): void;
17+
}

packages/shared/sdk-client/src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { ConnectionMode };
99
export * from './LDIdentifyOptions';
1010
export * from './LDInspection';
1111
export * from './LDIdentifyResult';
12+
export * from './LDPlugin';

0 commit comments

Comments
 (0)