Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
6 changes: 6 additions & 0 deletions packages/sdk/browser/src/BrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
LDIdentifyResult,
LDPluginEnvironmentMetadata,
Platform,
safeRegisterDebugOverridePlugins,
} from '@launchdarkly/js-client-sdk-common';

import { readFlagsFromBootstrap } from './bootstrap';
Expand Down Expand Up @@ -211,6 +212,11 @@ class BrowserClientImpl extends LDClientImpl {
client,
this._plugins || [],
);

const override = this.getDebugOverrides();
if (override) {
safeRegisterDebugOverridePlugins(this.logger, override, this._plugins || []);
}
}

override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void> {
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/browser/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type {
LDIdentifyError,
LDIdentifyTimeout,
LDIdentifyShed,
LDDebugOverride,
} from '@launchdarkly/js-client-sdk-common';

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common';

import DefaultFlagManager from '../../src/flag-manager/FlagManager';
import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater';
import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor';
import { Flag } from '../../src/types';

const TEST_SDK_KEY = 'test-sdk-key';
const TEST_MAX_CACHED_CONTEXTS = 5;

function makeMockPlatform(storage: Storage, crypto: Crypto): Platform {
return {
storage,
crypto,
info: {
platformData: jest.fn(),
sdkData: jest.fn(),
},
requests: {
fetch: jest.fn(),
createEventSource: jest.fn(),
getEventSourceCapabilities: jest.fn(),
},
};
}

function makeMemoryStorage(): Storage {
const data = new Map<string, string>();
return {
get: async (key: string) => {
const value = data.get(key);
return value !== undefined ? value : null;
},
set: async (key: string, value: string) => {
data.set(key, value);
},
clear: async (key: string) => {
data.delete(key);
},
};
}

function makeMockCrypto() {
let counter = 0;
let lastInput = '';
const hasher: Hasher = {
update: jest.fn((input) => {
lastInput = input;
return hasher;
}),
digest: jest.fn(() => `${lastInput}Hashed`),
};

return {
createHash: jest.fn(() => hasher),
createHmac: jest.fn(),
randomUUID: jest.fn(() => {
counter += 1;
return `${counter}`;
}),
};
}

function makeMockLogger(): LDLogger {
return {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
}

function makeMockFlag(version: number = 1, value: any = 'test-value'): Flag {
return {
version,
flagVersion: version,
value,
variation: 0,
trackEvents: false,
};
}

function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): ItemDescriptor {
return {
version,
flag: makeMockFlag(version, value),
};
}

describe('FlagManager override tests', () => {
let flagManager: DefaultFlagManager;
let mockPlatform: Platform;
let mockLogger: LDLogger;

beforeEach(() => {
mockLogger = makeMockLogger();
mockPlatform = makeMockPlatform(makeMemoryStorage(), makeMockCrypto());
flagManager = new DefaultFlagManager(
mockPlatform,
TEST_SDK_KEY,
TEST_MAX_CACHED_CONTEXTS,
mockLogger,
);
});

it('setOverride takes precedence over flag store value', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
const flags = {
'test-flag': makeMockItemDescriptor(1, 'store-value'),
};

await flagManager.init(context, flags);
expect(flagManager.get('test-flag')?.flag.value).toBe('store-value');

const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('test-flag', 'override-value');

expect(flagManager.get('test-flag')?.flag.value).toBe('override-value');
});

it('setOverride triggers flag change callback', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
const flags = {
'test-flag': makeMockItemDescriptor(1, 'store-value'),
};

await flagManager.init(context, flags);

const mockCallback: FlagsChangeCallback = jest.fn();
flagManager.on(mockCallback);

const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('test-flag', 'override-value');

expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(context, ['test-flag'], 'override');
});

it('removeOverride does nothing when override does not exist', () => {
const debugOverride = flagManager.getDebugOverride();
expect(() => {
debugOverride?.removeOverride('non-existent-flag');
}).not.toThrow();
});

it('removeOverride reverts to flag store value when override is removed', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
const flags = {
'test-flag': makeMockItemDescriptor(1, 'store-value'),
};

await flagManager.init(context, flags);
const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('test-flag', 'override-value');
expect(flagManager.get('test-flag')?.flag.value).toBe('override-value');

debugOverride?.removeOverride('test-flag');
expect(flagManager.get('test-flag')?.flag.value).toBe('store-value');
});

it('removeOverride triggers flag change callback', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
const flags = {
'test-flag': makeMockItemDescriptor(1, 'store-value'),
};

await flagManager.init(context, flags);

const mockCallback: FlagsChangeCallback = jest.fn();
flagManager.on(mockCallback);

const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('test-flag', 'override-value');
debugOverride?.removeOverride('test-flag');

expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenNthCalledWith(1, context, ['test-flag'], 'override');
expect(mockCallback).toHaveBeenNthCalledWith(2, context, ['test-flag'], 'override');
});

it('clearAllOverrides removes all overrides', () => {
const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('flag1', 'value1');
debugOverride?.setOverride('flag2', 'value2');
debugOverride?.setOverride('flag3', 'value3');

expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(3);

debugOverride?.clearAllOverrides();
expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(0);
});

it('clearAllOverrides triggers flag change callback for all flags', async () => {
const mockCallback: FlagsChangeCallback = jest.fn();
flagManager.on(mockCallback);

const debugOverride = flagManager.getDebugOverride();
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
const flags = {
'test-flag': makeMockItemDescriptor(1, 'store-value'),
};

await flagManager.init(context, flags);

debugOverride?.setOverride('flag1', 'value1');
debugOverride?.setOverride('flag2', 'value2');
(mockCallback as jest.Mock).mockClear();

debugOverride?.clearAllOverrides();
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(context, ['flag1', 'flag2'], 'override');
});

it('getAllOverrides returns all overrides as ItemDescriptors', () => {
const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('flag1', 'value1');
debugOverride?.setOverride('flag2', 42);
debugOverride?.setOverride('flag3', true);

const overrides = debugOverride?.getAllOverrides();
expect(overrides).toHaveProperty('flag1');
expect(overrides).toHaveProperty('flag2');
expect(overrides).toHaveProperty('flag3');
expect(overrides?.flag1.flag.value).toBe('value1');
expect(overrides?.flag2.flag.value).toBe(42);
expect(overrides?.flag3.flag.value).toBe(true);
expect(overrides?.flag1.version).toBe(0);
expect(overrides?.flag2.version).toBe(0);
expect(overrides?.flag3.version).toBe(0);
});

it('getAll merges overrides with flag store values', async () => {
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
const flags = {
'store-flag': makeMockItemDescriptor(1, 'store-value'),
'shared-flag': makeMockItemDescriptor(1, 'store-value'),
};

await flagManager.init(context, flags);
const debugOverride = flagManager.getDebugOverride();
debugOverride?.setOverride('shared-flag', 'override-value');
debugOverride?.setOverride('override-only-flag', 'override-value');

const allFlags = flagManager.getAll();
expect(allFlags).toHaveProperty('store-flag');
expect(allFlags).toHaveProperty('shared-flag');
expect(allFlags).toHaveProperty('override-only-flag');
expect(allFlags['store-flag'].flag.value).toBe('store-value');
expect(allFlags['shared-flag'].flag.value).toBe('override-value');
expect(allFlags['override-only-flag'].flag.value).toBe('override-value');
});
});
6 changes: 5 additions & 1 deletion packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from './evaluation/evaluationDetail';
import createEventProcessor from './events/createEventProcessor';
import EventFactory from './events/EventFactory';
import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager';
import DefaultFlagManager, { FlagManager, LDDebugOverride } from './flag-manager/FlagManager';
import { FlagChangeType } from './flag-manager/FlagUpdater';
import { ItemDescriptor } from './flag-manager/ItemDescriptor';
import HookRunner from './HookRunner';
Expand Down Expand Up @@ -607,6 +607,10 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
this._eventProcessor?.sendEvent(event);
}

protected getDebugOverrides(): LDDebugOverride | undefined {
return this._flagManager.getDebugOverride?.();
}

private _handleInspectionChanged(flagKeys: Array<string>, type: FlagChangeType) {
if (!this._inspectorManager.hasInspectors()) {
return;
Expand Down
17 changes: 17 additions & 0 deletions packages/shared/sdk-client/src/api/LDPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { LDPluginBase as LDPluginBaseCommon } from '@launchdarkly/js-sdk-common';

import { LDDebugOverride } from '../flag-manager/FlagManager';

export interface LDPluginBase<TClient, THook> extends LDPluginBaseCommon<TClient, THook> {
/**
* 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;
}
1 change: 1 addition & 0 deletions packages/shared/sdk-client/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { ConnectionMode };
export * from './LDIdentifyOptions';
export * from './LDInspection';
export * from './LDIdentifyResult';
export * from './LDPlugin';
Loading