diff --git a/packages/sdk/electron/__tests__/ElectronClient.ipcMain.test.ts b/packages/sdk/electron/__tests__/ElectronClient.ipcMain.test.ts index 15b0e5dbad..4a6ab731c2 100644 --- a/packages/sdk/electron/__tests__/ElectronClient.ipcMain.test.ts +++ b/packages/sdk/electron/__tests__/ElectronClient.ipcMain.test.ts @@ -6,14 +6,14 @@ import type { LDEvaluationDetail, LDEvaluationDetailTyped, LDIdentifyOptions, - LDLogger, } from '@launchdarkly/js-client-sdk-common'; import { ElectronClient } from '../src/ElectronClient'; -import { getIPCChannelName } from '../src/ElectronIPC'; +import { deriveNamespace, getIPCChannelName } from '../src/ElectronIPC'; import ElectronCrypto from '../src/platform/ElectronCrypto'; import ElectronEncoding from '../src/platform/ElectronEncoding'; import ElectronInfo from '../src/platform/ElectronInfo'; +import { createMockLogger } from './testHelpers'; type MockIpcMain = IpcMain & { getHandler: (eventName: string) => Function | undefined; @@ -63,7 +63,7 @@ const mockPort: MockPort = { }; const getEventName = (baseName: Parameters[1]) => - getIPCChannelName(clientSideId, baseName); + getIPCChannelName(deriveNamespace(clientSideId), baseName); const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'test-user' }; @@ -72,12 +72,7 @@ beforeEach(() => { }); describe('given an initialized ElectronClient', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); const client = new ElectronClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { initialConnectionMode: 'offline', @@ -514,12 +509,7 @@ describe('given an initialized ElectronClient', () => { }); describe('close()', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); it('removes all ipcMain listeners and handlers for the client so channels are no longer registered', async () => { const client = new ElectronClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { diff --git a/packages/sdk/electron/__tests__/ElectronIPC.test.ts b/packages/sdk/electron/__tests__/ElectronIPC.test.ts new file mode 100644 index 0000000000..9122f5703a --- /dev/null +++ b/packages/sdk/electron/__tests__/ElectronIPC.test.ts @@ -0,0 +1,28 @@ +import { deriveNamespace, getIPCChannelName } from '../src/ElectronIPC'; + +it('derives namespace from credential alone', () => { + expect(deriveNamespace('mob-abc-123')).toBe('mob-abc-123'); +}); + +it('derives namespace from credential with custom namespace', () => { + expect(deriveNamespace('mob-abc-123', 'my-namespace')).toBe('my-namespace_mob-abc-123'); +}); + +it('produces different namespaces with and without custom namespace', () => { + const credential = 'mob-abc-123'; + expect(deriveNamespace(credential)).not.toBe(deriveNamespace(credential, 'ns')); +}); + +it('produces different namespaces for different custom namespaces', () => { + const credential = 'mob-abc-123'; + expect(deriveNamespace(credential, 'ns-a')).not.toBe(deriveNamespace(credential, 'ns-b')); +}); + +it('undefined namespace equals no namespace', () => { + const credential = 'mob-abc-123'; + expect(deriveNamespace(credential, undefined)).toBe(deriveNamespace(credential)); +}); + +it('builds IPC channel names', () => { + expect(getIPCChannelName('ns', 'allFlags')).toBe('ld:ns:allFlags'); +}); diff --git a/packages/sdk/electron/__tests__/bridge/LDClientBridge.test.ts b/packages/sdk/electron/__tests__/bridge/LDClientBridge.test.ts index 4c254278a6..e5a8de2e14 100644 --- a/packages/sdk/electron/__tests__/bridge/LDClientBridge.test.ts +++ b/packages/sdk/electron/__tests__/bridge/LDClientBridge.test.ts @@ -2,10 +2,11 @@ import { ipcRenderer } from 'electron'; import '../../src/bridge'; import type { LDClientBridge } from '../../src/bridge/LDClientBridge'; -import type { LDContext, LDEvaluationDetail, LDEvaluationDetailTyped } from '../../src/index'; +import { deriveNamespace } from '../../src/ElectronIPC'; +import type { LDContext } from '../../src/index'; const clientSideId = 'client-side-id'; -let ldClientBridge: (clientSideId: string) => LDClientBridge; +let ldClientBridge: (namespace: string) => LDClientBridge; jest.mock('electron', () => ({ contextBridge: { @@ -43,7 +44,7 @@ globalThis.MessageChannel = jest.fn().mockImplementation(() => ({ port2: port2Mock, })); -const getEventName = (baseName: string) => `ld:${clientSideId}:${baseName}`; +const getEventName = (baseName: string) => `ld:${deriveNamespace(clientSideId)}:${baseName}`; beforeEach(() => { jest.clearAllMocks(); @@ -60,7 +61,7 @@ describe('given a registered LDClientBridge', () => { let bridge: LDClientBridge; beforeEach(() => { - bridge = ldClientBridge(clientSideId); + bridge = ldClientBridge(deriveNamespace(clientSideId)); }); it('passes allFlags() call through to ipcRenderer', () => { @@ -73,37 +74,28 @@ describe('given a registered LDClientBridge', () => { expect(result).toEqual({ flag1: true }); }); - it('passes boolVariation() call through to ipcRenderer', () => { - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(true); - - const result = bridge.boolVariation('flag1', false); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('boolVariation'), - 'flag1', - false, - ); - expect(result).toEqual(true); - }); - - it('passes boolVariationDetail() call through to ipcRenderer', () => { - const expected: LDEvaluationDetailTyped = { - value: true, - reason: { kind: 'RULE_MATCH' }, - }; - + it.each([ + ['boolVariation', true, false], + ['boolVariationDetail', { value: true, reason: { kind: 'RULE_MATCH' } }, false], + ['numberVariation', 1234.5, 0], + ['numberVariationDetail', { value: 1234.5, reason: { kind: 'RULE_MATCH' } }, 0], + ['stringVariation', 'value', ''], + ['stringVariationDetail', { value: 'value', reason: { kind: 'RULE_MATCH' } }, ''], + ['jsonVariation', { key1: 'value1' }, {}], + ['jsonVariationDetail', { value: { key1: 'value1' }, reason: { kind: 'RULE_MATCH' } }, {}], + ['variation', true, false], + ['variationDetail', { value: true, reason: { kind: 'RULE_MATCH' } }, false], + ])('passes %s() call through to ipcRenderer', (method, expected, defaultValue) => { (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected); - const result = bridge.boolVariationDetail('flag1', false); + const result = (bridge as any)[method]('flag1', defaultValue); expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( 1, - getEventName('boolVariationDetail'), + getEventName(method), 'flag1', - false, + defaultValue, ); expect(result).toEqual(expected); }); @@ -141,113 +133,6 @@ describe('given a registered LDClientBridge', () => { }); }); - it('passes jsonVariation() call through to ipcRenderer', () => { - const expected = { key1: 'value1', key2: true }; - - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected); - - const result = bridge.jsonVariation('flag1', {}); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('jsonVariation'), - 'flag1', - {}, - ); - expect(result).toEqual(expected); - }); - - it('passes jsonVariationDetail() call through to ipcRenderer', () => { - const expected: LDEvaluationDetailTyped = { - value: { key1: 'value1', key2: true }, - reason: { kind: 'RULE_MATCH' }, - }; - - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected); - - const result = bridge.jsonVariationDetail('flag1', {}); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('jsonVariationDetail'), - 'flag1', - {}, - ); - expect(result).toEqual(expected); - }); - - it('passes numberVariation() call through to ipcRenderer', () => { - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(1234.5); - - const result = bridge.numberVariation('flag1', 0); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('numberVariation'), - 'flag1', - 0, - ); - expect(result).toEqual(1234.5); - }); - - it('passes numberVariationDetail() call through to ipcRenderer', () => { - const expected: LDEvaluationDetailTyped = { - value: 1234.5, - reason: { kind: 'RULE_MATCH' }, - }; - - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected); - - const result = bridge.numberVariationDetail('flag1', 0); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('numberVariationDetail'), - 'flag1', - 0, - ); - expect(result).toEqual(expected); - }); - - it('passes stringVariation() call through to ipcRenderer', () => { - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce('value'); - - const result = bridge.stringVariation('flag1', ''); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('stringVariation'), - 'flag1', - '', - ); - expect(result).toEqual('value'); - }); - - it('passes stringVariationDetail() call through to ipcRenderer', () => { - const expected: LDEvaluationDetailTyped = { - value: 'value', - reason: { kind: 'RULE_MATCH' }, - }; - - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected); - - const result = bridge.stringVariationDetail('flag1', ''); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('stringVariationDetail'), - 'flag1', - '', - ); - expect(result).toEqual(expected); - }); - it('passes track() call through to ipcRenderer', () => { bridge.track('event1', { key1: 'value1' }, 1234.5); @@ -261,41 +146,6 @@ describe('given a registered LDClientBridge', () => { ); }); - it('passes variation() call through to ipcRenderer', () => { - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(true); - - const result = bridge.variation('flag1', false); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('variation'), - 'flag1', - false, - ); - expect(result).toEqual(true); - }); - - it('passes variationDetail() call through to ipcRenderer', () => { - const expected: LDEvaluationDetail = { - value: true, - reason: { kind: 'RULE_MATCH' }, - }; - - (ipcRenderer.sendSync as jest.Mock).mockReturnValueOnce(expected); - - const result = bridge.variationDetail('flag1', false); - - expect(ipcRenderer.sendSync).toHaveBeenCalledTimes(1); - expect(ipcRenderer.sendSync).toHaveBeenNthCalledWith( - 1, - getEventName('variationDetail'), - 'flag1', - false, - ); - expect(result).toEqual(expected); - }); - it('passes setConnectionMode() call through to ipcRenderer', async () => { await bridge.setConnectionMode('streaming'); diff --git a/packages/sdk/electron/__tests__/options.test.ts b/packages/sdk/electron/__tests__/options.test.ts index 1eedf3bc20..7520e6cbb5 100644 --- a/packages/sdk/electron/__tests__/options.test.ts +++ b/packages/sdk/electron/__tests__/options.test.ts @@ -1,15 +1,9 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk-common'; - import { ElectronOptions } from '../src/ElectronOptions'; import validateOptions, { filterToBaseOptions } from '../src/options'; +import { createMockLogger } from './testHelpers'; it('logs no warnings when all configuration is valid', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); validateOptions( { @@ -19,6 +13,7 @@ it('logs no warnings when all configuration is valid', () => { initialConnectionMode: 'streaming', enableIPC: true, plugins: [], + namespace: 'test-ns', }, logger, ); @@ -30,12 +25,7 @@ it('logs no warnings when all configuration is valid', () => { }); it('warns for invalid configuration', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); validateOptions( { @@ -77,12 +67,7 @@ it('warns for invalid configuration', () => { }); it('applies default options', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); const opts = validateOptions({}, logger); expect(opts.proxyOptions).toBeUndefined(); @@ -92,6 +77,7 @@ it('applies default options', () => { expect(opts.plugins).toEqual([]); expect(opts.enableIPC).toEqual(true); expect(opts.useClientSideId).toEqual(false); + expect(opts.namespace).toBeUndefined(); expect(logger.debug).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); @@ -99,25 +85,39 @@ it('applies default options', () => { expect(logger.error).not.toHaveBeenCalled(); }); +it('applies namespace when set', () => { + const logger = createMockLogger(); + const opts = validateOptions({ namespace: 'my-ns' }, logger); + + expect(opts.namespace).toEqual('my-ns'); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('warns for invalid namespace type', () => { + const logger = createMockLogger(); + + validateOptions( + { + // @ts-ignore + namespace: 42, + }, + logger, + ); + + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "namespace" should be of type string, got number, using default value', + ); +}); + it('applies useClientSideId when set to true', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); const opts = validateOptions({ useClientSideId: true }, logger); expect(opts.useClientSideId).toEqual(true); }); it('warns for invalid useClientSideId type', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); validateOptions( { @@ -133,12 +133,7 @@ it('warns for invalid useClientSideId type', () => { }); it('filters to base options', () => { - const logger: LDLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }; + const logger = createMockLogger(); const opts: ElectronOptions = { debug: false, @@ -149,6 +144,7 @@ it('filters to base options', () => { enableIPC: true, plugins: [], useClientSideId: true, + namespace: 'test-ns', }; const baseOpts = filterToBaseOptions(opts); diff --git a/packages/sdk/electron/__tests__/renderer/ElectronRendererClient.test.ts b/packages/sdk/electron/__tests__/renderer/ElectronRendererClient.test.ts index e8b7439869..58824b8f0c 100644 --- a/packages/sdk/electron/__tests__/renderer/ElectronRendererClient.test.ts +++ b/packages/sdk/electron/__tests__/renderer/ElectronRendererClient.test.ts @@ -1,10 +1,7 @@ -import type { - LDContext, - LDEvaluationDetail, - LDEvaluationDetailTyped, -} from '@launchdarkly/js-client-sdk-common'; +import type { LDContext } from '@launchdarkly/js-client-sdk-common'; import type { LDClientBridge } from '../../src/bridge/LDClientBridge'; +import { deriveNamespace } from '../../src/ElectronIPC'; import { ElectronRendererClient } from '../../src/renderer/ElectronRendererClient'; const ldClientBridge: LDClientBridge = { @@ -47,7 +44,22 @@ it('initializes with client side id', () => { // @ts-ignore expect(globalThis.window.ldClientBridge).toHaveBeenCalledTimes(1); // @ts-ignore - expect(globalThis.window.ldClientBridge).toHaveBeenNthCalledWith(1, clientSideId); + expect(globalThis.window.ldClientBridge).toHaveBeenNthCalledWith( + 1, + deriveNamespace(clientSideId), + ); + expect(client).toBeDefined(); +}); + +it('initializes with client side id and namespace', () => { + const client = new ElectronRendererClient(clientSideId, 'my-ns'); + // @ts-ignore + expect(globalThis.window.ldClientBridge).toHaveBeenCalledTimes(1); + // @ts-ignore + expect(globalThis.window.ldClientBridge).toHaveBeenNthCalledWith( + 1, + deriveNamespace(clientSideId, 'my-ns'), + ); expect(client).toBeDefined(); }); @@ -70,30 +82,29 @@ it('passes allFlags() call through to bridge', () => { expect(result).toEqual({ flag1: true }); }); -it('passes boolVariation() call through to bridge', () => { - (ldClientBridge.boolVariation as jest.Mock).mockReturnValueOnce(true); +it.each([ + ['boolVariation', true, ['flag1', false]], + ['boolVariationDetail', { value: true, reason: { kind: 'RULE_MATCH' } }, ['flag1', false]], + ['numberVariation', 1234.5, ['flag1', 0]], + ['numberVariationDetail', { value: 1234.5, reason: { kind: 'RULE_MATCH' } }, ['flag1', 0]], + ['stringVariation', 'value', ['flag1', '']], + ['stringVariationDetail', { value: 'value', reason: { kind: 'RULE_MATCH' } }, ['flag1', '']], + ['jsonVariation', { key1: 'value1', key2: true }, ['flag1', {}]], + [ + 'jsonVariationDetail', + { value: { key1: 'value1', key2: true }, reason: { kind: 'RULE_MATCH' } }, + ['flag1', {}], + ], + ['variation', true, ['flag1', false]], + ['variationDetail', { value: true, reason: { kind: 'RULE_MATCH' } }, ['flag1', false]], +])('passes %s() call through to bridge', (method, expected, args) => { + (ldClientBridge[method as keyof LDClientBridge] as jest.Mock).mockReturnValueOnce(expected); const client = new ElectronRendererClient(clientSideId); - const result = client.boolVariation('flag1', false); + const result = (client as any)[method](...args); - expect(ldClientBridge.boolVariation).toHaveBeenCalledTimes(1); - expect(ldClientBridge.boolVariation).toHaveBeenNthCalledWith(1, 'flag1', false); - expect(result).toEqual(true); -}); - -it('passes boolVariationDetail() call through to bridge', () => { - const expected: LDEvaluationDetailTyped = { - value: true, - reason: { kind: 'RULE_MATCH' }, - }; - - (ldClientBridge.boolVariationDetail as jest.Mock).mockReturnValueOnce(expected); - - const client = new ElectronRendererClient(clientSideId); - const result = client.boolVariationDetail('flag1', false); - - expect(ldClientBridge.boolVariationDetail).toHaveBeenCalledTimes(1); - expect(ldClientBridge.boolVariationDetail).toHaveBeenNthCalledWith(1, 'flag1', false); + expect(ldClientBridge[method as keyof LDClientBridge]).toHaveBeenCalledTimes(1); + expect(ldClientBridge[method as keyof LDClientBridge]).toHaveBeenNthCalledWith(1, ...args); expect(result).toEqual(expected); }); @@ -131,89 +142,6 @@ it('passes identify() call through to bridge', async () => { }); }); -it('passes jsonVariation() call through to bridge', () => { - const expected = { key1: 'value1', key2: true }; - - (ldClientBridge.jsonVariation as jest.Mock).mockReturnValueOnce(expected); - - const client = new ElectronRendererClient(clientSideId); - const result = client.jsonVariation('flag1', {}); - - expect(ldClientBridge.jsonVariation).toHaveBeenCalledTimes(1); - expect(ldClientBridge.jsonVariation).toHaveBeenNthCalledWith(1, 'flag1', {}); - expect(result).toEqual(expected); -}); - -it('passes jsonVariationDetail() call through to bridge', () => { - const expected: LDEvaluationDetailTyped = { - value: { key1: 'value1', key2: true }, - reason: { kind: 'RULE_MATCH' }, - }; - - (ldClientBridge.jsonVariationDetail as jest.Mock).mockReturnValueOnce(expected); - - const client = new ElectronRendererClient(clientSideId); - const result = client.jsonVariationDetail('flag1', {}); - - expect(ldClientBridge.jsonVariationDetail).toHaveBeenCalledTimes(1); - expect(ldClientBridge.jsonVariationDetail).toHaveBeenNthCalledWith(1, 'flag1', {}); - expect(result).toEqual(expected); -}); - -it('passes numberVariation() call through to bridge', () => { - (ldClientBridge.numberVariation as jest.Mock).mockReturnValueOnce(1234.5); - - const client = new ElectronRendererClient(clientSideId); - const result = client.numberVariation('flag1', 0); - - expect(ldClientBridge.numberVariation).toHaveBeenCalledTimes(1); - expect(ldClientBridge.numberVariation).toHaveBeenNthCalledWith(1, 'flag1', 0); - expect(result).toEqual(1234.5); -}); - -it('passes numberVariationDetail() call through to bridge', () => { - const expected: LDEvaluationDetailTyped = { - value: 1234.5, - reason: { kind: 'RULE_MATCH' }, - }; - - (ldClientBridge.numberVariationDetail as jest.Mock).mockReturnValueOnce(expected); - - const client = new ElectronRendererClient(clientSideId); - const result = client.numberVariationDetail('flag1', 0); - - expect(ldClientBridge.numberVariationDetail).toHaveBeenCalledTimes(1); - expect(ldClientBridge.numberVariationDetail).toHaveBeenNthCalledWith(1, 'flag1', 0); - expect(result).toEqual(expected); -}); - -it('passes stringVariation() call through to bridge', () => { - (ldClientBridge.stringVariation as jest.Mock).mockReturnValueOnce('value'); - - const client = new ElectronRendererClient(clientSideId); - const result = client.stringVariation('flag1', ''); - - expect(ldClientBridge.stringVariation).toHaveBeenCalledTimes(1); - expect(ldClientBridge.stringVariation).toHaveBeenNthCalledWith(1, 'flag1', ''); - expect(result).toEqual('value'); -}); - -it('passes stringVariationDetail() call through to bridge', () => { - const expected: LDEvaluationDetailTyped = { - value: 'value', - reason: { kind: 'RULE_MATCH' }, - }; - - (ldClientBridge.stringVariationDetail as jest.Mock).mockReturnValueOnce(expected); - - const client = new ElectronRendererClient(clientSideId); - const result = client.stringVariationDetail('flag1', ''); - - expect(ldClientBridge.stringVariationDetail).toHaveBeenCalledTimes(1); - expect(ldClientBridge.stringVariationDetail).toHaveBeenNthCalledWith(1, 'flag1', ''); - expect(result).toEqual(expected); -}); - it('passes track() call through to bridge', () => { const client = new ElectronRendererClient(clientSideId); client.track('event1', { key1: 'value1' }, 1234.5); @@ -222,33 +150,6 @@ it('passes track() call through to bridge', () => { expect(ldClientBridge.track).toHaveBeenNthCalledWith(1, 'event1', { key1: 'value1' }, 1234.5); }); -it('passes variation() call through to bridge', () => { - (ldClientBridge.variation as jest.Mock).mockReturnValueOnce(true); - - const client = new ElectronRendererClient(clientSideId); - const result = client.variation('flag1', false); - - expect(ldClientBridge.variation).toHaveBeenCalledTimes(1); - expect(ldClientBridge.variation).toHaveBeenNthCalledWith(1, 'flag1', false); - expect(result).toEqual(true); -}); - -it('passes variationDetail() call through to bridge', () => { - const expected: LDEvaluationDetail = { - value: true, - reason: { kind: 'RULE_MATCH' }, - }; - - (ldClientBridge.variationDetail as jest.Mock).mockReturnValueOnce(expected); - - const client = new ElectronRendererClient(clientSideId); - const result = client.variationDetail('flag1', false); - - expect(ldClientBridge.variationDetail).toHaveBeenCalledTimes(1); - expect(ldClientBridge.variationDetail).toHaveBeenNthCalledWith(1, 'flag1', false); - expect(result).toEqual(expected); -}); - it('passes setConnectionMode() call through to bridge', async () => { const client = new ElectronRendererClient(clientSideId); await client.setConnectionMode('streaming'); diff --git a/packages/sdk/electron/__tests__/testHelpers.ts b/packages/sdk/electron/__tests__/testHelpers.ts new file mode 100644 index 0000000000..5c2a38051f --- /dev/null +++ b/packages/sdk/electron/__tests__/testHelpers.ts @@ -0,0 +1,5 @@ +import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +export function createMockLogger(): LDLogger { + return { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} diff --git a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts index 0b5d4ffe48..657301ae71 100644 --- a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts @@ -119,11 +119,6 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { // } // } - // NOTE: we may want need to discuss this at some point. Right now, we are - // running this suite of tests because the way we register our IPC bridge listern - // using the client side id. The problem with this is that we cannot be registering - // the same listener multiple times. In order to support registering multiple clients, - // we will need to change the way we hash the listener name. cf.enableIPC = false; // TODO: we might need this diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index 5b3baf91e3..4cebb13eb5 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -30,6 +30,7 @@ import ElectronDataManager from './ElectronDataManager'; import { AllAsyncChannels, AllSyncChannels, + deriveNamespace, getIPCChannelName, IpcEventCallback, IpcEventSubscription, @@ -96,6 +97,7 @@ export class ElectronClient extends LDClientImpl { }; const platform = new ElectronPlatform(logger, options); + const derivedNs = deriveNamespace(credential, validatedElectronOptions.namespace); const endpoints = useClientSideId ? browserFdv1Endpoints(credential) : mobileFdv1Endpoints(); super( @@ -130,7 +132,7 @@ export class ElectronClient extends LDClientImpl { this.setEventSendingEnabled(!this.isOffline(), false); if (validatedElectronOptions.enableIPC) { - this._openIPCChannels(credential); + this._openIPCChannels(derivedNs); } } @@ -235,14 +237,14 @@ export class ElectronClient extends LDClientImpl { return dataManager.getConnectionMode() === 'offline'; } - private _openIPCChannels(credential: string): void { - this._ipcNamespace = credential; + private _openIPCChannels(namespace: string): void { + this._ipcNamespace = namespace; this._ipcEventSubscriptions = new Map(); this._ipcCallbackIdToEventName = new Map(); this._ipcSubscriptionQueue = []; ipcMain.on( - getIPCChannelName(credential, 'addEventHandler'), + getIPCChannelName(namespace, 'addEventHandler'), (event: IpcMainEvent, messageData: IpcEventCallback) => { this._ipcSubscriptionQueue!.push({ type: 'add', event, messageData }); this._processSubscriptionQueue(); @@ -250,7 +252,7 @@ export class ElectronClient extends LDClientImpl { ); ipcMain.on( - getIPCChannelName(credential, 'removeEventHandler'), + getIPCChannelName(namespace, 'removeEventHandler'), (event: IpcMainEvent, callbackId: string) => { this._ipcSubscriptionQueue!.push({ type: 'remove', event, callbackId }); this._processSubscriptionQueue(); @@ -258,104 +260,104 @@ export class ElectronClient extends LDClientImpl { ); ipcMain.handle( - getIPCChannelName(credential, 'waitForInitialization'), + getIPCChannelName(namespace, 'waitForInitialization'), (_event, options?: LDWaitForInitializationOptions): Promise => this.waitForInitialization(options), ); - ipcMain.on(getIPCChannelName(credential, 'allFlags'), (event) => { + ipcMain.on(getIPCChannelName(namespace, 'allFlags'), (event) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.allFlags(); }); - ipcMain.on(getIPCChannelName(credential, 'boolVariation'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'boolVariation'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.boolVariation(key, defaultValue); }); - ipcMain.on(getIPCChannelName(credential, 'boolVariationDetail'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'boolVariationDetail'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.boolVariationDetail(key, defaultValue); }); - ipcMain.handle(getIPCChannelName(credential, 'flush'), (_event) => this.flush()); + ipcMain.handle(getIPCChannelName(namespace, 'flush'), (_event) => this.flush()); - ipcMain.on(getIPCChannelName(credential, 'getContext'), (event) => { + ipcMain.on(getIPCChannelName(namespace, 'getContext'), (event) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.getContext(); }); - ipcMain.handle(getIPCChannelName(credential, 'identify'), (_event, context, identifyOptions) => + ipcMain.handle(getIPCChannelName(namespace, 'identify'), (_event, context, identifyOptions) => this.identifyResult(context, identifyOptions), ); - ipcMain.on(getIPCChannelName(credential, 'log'), (_event, level: string, message: string) => { + ipcMain.on(getIPCChannelName(namespace, 'log'), (_event, level: string, message: string) => { if (VALID_LOG_LEVELS.has(level)) { this.logger[level as keyof LDLogger](message); } }); - ipcMain.on(getIPCChannelName(credential, 'jsonVariation'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'jsonVariation'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.jsonVariation(key, defaultValue); }); - ipcMain.on(getIPCChannelName(credential, 'jsonVariationDetail'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'jsonVariationDetail'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.jsonVariationDetail(key, defaultValue); }); - ipcMain.on(getIPCChannelName(credential, 'numberVariation'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'numberVariation'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.numberVariation(key, defaultValue); }); ipcMain.on( - getIPCChannelName(credential, 'numberVariationDetail'), + getIPCChannelName(namespace, 'numberVariationDetail'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.numberVariationDetail(key, defaultValue); }, ); - ipcMain.on(getIPCChannelName(credential, 'stringVariation'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'stringVariation'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.stringVariation(key, defaultValue); }); ipcMain.on( - getIPCChannelName(credential, 'stringVariationDetail'), + getIPCChannelName(namespace, 'stringVariationDetail'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.stringVariationDetail(key, defaultValue); }, ); - ipcMain.on(getIPCChannelName(credential, 'track'), (event, key, data, metricValue) => { + ipcMain.on(getIPCChannelName(namespace, 'track'), (event, key, data, metricValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.track(key, data, metricValue); }); - ipcMain.on(getIPCChannelName(credential, 'variation'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'variation'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.variation(key, defaultValue); }); - ipcMain.on(getIPCChannelName(credential, 'variationDetail'), (event, key, defaultValue) => { + ipcMain.on(getIPCChannelName(namespace, 'variationDetail'), (event, key, defaultValue) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.variationDetail(key, defaultValue); }); - ipcMain.handle(getIPCChannelName(credential, 'setConnectionMode'), (_event, mode) => + ipcMain.handle(getIPCChannelName(namespace, 'setConnectionMode'), (_event, mode) => this.setConnectionMode(mode), ); - ipcMain.on(getIPCChannelName(credential, 'getConnectionMode'), (event) => { + ipcMain.on(getIPCChannelName(namespace, 'getConnectionMode'), (event) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.getConnectionMode(); }); - ipcMain.on(getIPCChannelName(credential, 'isOffline'), (event) => { + ipcMain.on(getIPCChannelName(namespace, 'isOffline'), (event) => { // eslint-disable-next-line no-param-reassign event.returnValue = this.isOffline(); }); diff --git a/packages/sdk/electron/src/ElectronIPC.ts b/packages/sdk/electron/src/ElectronIPC.ts index 03fa3f50e8..ad0fd2392b 100644 --- a/packages/sdk/electron/src/ElectronIPC.ts +++ b/packages/sdk/electron/src/ElectronIPC.ts @@ -1,3 +1,5 @@ +import type { MessagePortMain } from 'electron'; + import { LDEmitterEventName } from '@launchdarkly/js-client-sdk-common'; /** @@ -70,7 +72,7 @@ export type IPCChannel = IPCSyncChannel | IPCAsyncChannel; */ export interface IpcEventSubscription { broadcastCallback: (...args: any[]) => void; - ports: Map; + ports: Map; } export interface IpcEventCallback { @@ -84,3 +86,10 @@ export interface IpcEventCallback { export function getIPCChannelName(namespace: string, channel: IPCChannel): string { return `ld:${namespace}:${channel}`; } + +/** + * Derives an IPC namespace from a credential and an optional user-provided namespace. + */ +export function deriveNamespace(credential: string, customNamespace?: string): string { + return customNamespace ? `${customNamespace}_${credential}` : credential; +} diff --git a/packages/sdk/electron/src/ElectronOptions.ts b/packages/sdk/electron/src/ElectronOptions.ts index 9b4c471bd9..316be0f8d3 100644 --- a/packages/sdk/electron/src/ElectronOptions.ts +++ b/packages/sdk/electron/src/ElectronOptions.ts @@ -118,4 +118,10 @@ export interface ElectronOptions extends LDOptionsBase { * in future versions of this sdk. Please use mobile key instead. */ useClientSideId?: boolean; + + /** + * An optional namespace to isolate this client's IPC channels + * from other clients using the same credential in the same process. + */ + namespace?: string; } diff --git a/packages/sdk/electron/src/options.ts b/packages/sdk/electron/src/options.ts index e753ea0dc2..1da697b26a 100644 --- a/packages/sdk/electron/src/options.ts +++ b/packages/sdk/electron/src/options.ts @@ -27,6 +27,7 @@ export interface ValidatedOptions { plugins: LDPlugin[]; enableIPC: boolean; useClientSideId: boolean; + namespace?: string; } const optDefaults: ValidatedOptions = { @@ -37,6 +38,7 @@ const optDefaults: ValidatedOptions = { plugins: [], enableIPC: true, useClientSideId: false, + namespace: undefined, }; const validators: { [Property in keyof ElectronOptions]: TypeValidator | undefined } = { @@ -47,6 +49,7 @@ const validators: { [Property in keyof ElectronOptions]: TypeValidator | undefin plugins: TypeValidators.createTypeArray('LDPlugin[]', {}), enableIPC: TypeValidators.Boolean, useClientSideId: TypeValidators.Boolean, + namespace: TypeValidators.String, }; export function filterToBaseOptions(opts: ElectronOptions): LDOptionsBase { diff --git a/packages/sdk/electron/src/renderer/ElectronRendererClient.ts b/packages/sdk/electron/src/renderer/ElectronRendererClient.ts index 973f51137f..a08293d130 100644 --- a/packages/sdk/electron/src/renderer/ElectronRendererClient.ts +++ b/packages/sdk/electron/src/renderer/ElectronRendererClient.ts @@ -13,6 +13,7 @@ import type { } from '@launchdarkly/js-client-sdk-common'; import type { LDClientBridge } from '../bridge/LDClientBridge'; +import { deriveNamespace } from '../ElectronIPC'; import type { LDRendererClient } from './LDRendererClient'; export class ElectronRendererClient implements LDRendererClient { @@ -21,9 +22,10 @@ export class ElectronRendererClient implements LDRendererClient { // Keep a set of callback handles to support closing this client. private readonly _callbacks: Set = new Set(); - constructor(clientSideId: string) { + constructor(clientSideId: string, namespace?: string) { + const derivedNs = deriveNamespace(clientSideId, namespace); this._ldClientBridge = (globalThis.window as any)?.ldClientBridge?.( - clientSideId, + derivedNs, ) as LDClientBridge; if (!this._ldClientBridge) { throw new Error( diff --git a/packages/sdk/electron/src/renderer/index.ts b/packages/sdk/electron/src/renderer/index.ts index 5d21e5a550..9b38102baf 100644 --- a/packages/sdk/electron/src/renderer/index.ts +++ b/packages/sdk/electron/src/renderer/index.ts @@ -13,6 +13,6 @@ export type { export type { LDRendererClient }; -export function createRendererClient(clientSideId: string): LDRendererClient { - return new ElectronRendererClient(clientSideId); +export function createRendererClient(clientSideId: string, namespace?: string): LDRendererClient { + return new ElectronRendererClient(clientSideId, namespace); } diff --git a/packages/sdk/electron/temp_docs/MIGRATION.md b/packages/sdk/electron/temp_docs/MIGRATION.md index d24bf53e3c..89dff4bdd1 100644 --- a/packages/sdk/electron/temp_docs/MIGRATION.md +++ b/packages/sdk/electron/temp_docs/MIGRATION.md @@ -68,4 +68,26 @@ This SDK now uses the **mobile key** by default instead of the client-side ID. I - **Enable flags for mobile SDKs:** By default, flags are only available to server-side SDKs. For the Electron SDK (using the mobile key) to evaluate a flag, you must make that flag available to **SDKs using Mobile Key** in the LaunchDarkly UI. When creating a new flag, check the appropriate box in the "Create flag" dialog; for existing flags, use the **Advanced controls** section in the flag’s right sidebar. See [Make flags available to client-side and mobile SDKs](https://launchdarkly.com/docs/home/flags/new#make-flags-available-to-client-side-and-mobile-sdks) in the LaunchDarkly docs. -- **Secure Mode:** Mobile key–based SDKs do not support [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode). If your application depends on Secure Mode (for example, to verify flag values in a trusted backend), you must use the client-side ID with `useClientSideId: true` instead of the mobile key. \ No newline at end of file +- **Secure Mode:** Mobile key–based SDKs do not support [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode). If your application depends on Secure Mode (for example, to verify flag values in a trusted backend), you must use the client-side ID with `useClientSideId: true` instead of the mobile key. + +## Multiple environments (namespace option) + +This SDK supports running multiple client instances in the same Electron app. Each client can target a different LaunchDarkly environment by using a different credential (mobile key or client-side ID). + +If you need multiple clients with the **same** credential (e.g., separate flag state for different windows), you must provide a unique `namespace` for each: + +```typescript +const clientA = createClient(mobileKey, contextA, { namespace: 'window-main' }); +const clientB = createClient(mobileKey, contextB, { namespace: 'window-settings' }); + +await clientA.start(); +await clientB.start(); +``` + +The namespace isolates IPC channels between clients. Without a namespace, two clients sharing the same credential will collide on IPC handlers. + +Renderer clients must also pass the matching namespace: + +```typescript +const rendererClient = createRendererClient(mobileKey, 'window-main'); +```