From 4b41655e9f2ee376f7b9dd2627e03f05997f248f Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 13 Feb 2026 14:59:57 -0600 Subject: [PATCH 1/3] chore: adding the implementation for main process client --- packages/sdk/electron/README.md | 2 +- .../ElectronClient.mainProcess.test.ts | 339 ++++++++ .../electron/__tests__/ElectronClient.test.ts | 802 ++++++++++++++++++ .../__tests__/ElectronDataManager.test.ts | 588 +++++++++++++ .../ElectronLDMainClient.plugin.test.ts | 368 ++++++++ .../sdk/electron/__tests__/bootstrap.test.ts | 86 ++ .../sdk/electron/__tests__/options.test.ts | 163 ++++ .../electron/__tests__/testBootstrapData.ts | 57 ++ packages/sdk/electron/src/ElectronClient.ts | 314 +++++++ .../sdk/electron/src/ElectronDataManager.ts | 212 +++++ packages/sdk/electron/src/bootstrap.ts | 47 + packages/sdk/electron/src/index.ts | 21 + packages/sdk/electron/src/options.ts | 80 ++ packages/sdk/electron/temp_docs/MIGRATION.md | 71 ++ packages/sdk/electron/temp_docs/README.md | 5 + packages/sdk/electron/temp_docs/ipc-bridge.md | 53 ++ 16 files changed, 3207 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts create mode 100644 packages/sdk/electron/__tests__/ElectronClient.test.ts create mode 100644 packages/sdk/electron/__tests__/ElectronDataManager.test.ts create mode 100644 packages/sdk/electron/__tests__/ElectronLDMainClient.plugin.test.ts create mode 100644 packages/sdk/electron/__tests__/bootstrap.test.ts create mode 100644 packages/sdk/electron/__tests__/options.test.ts create mode 100644 packages/sdk/electron/__tests__/testBootstrapData.ts create mode 100644 packages/sdk/electron/src/ElectronClient.ts create mode 100644 packages/sdk/electron/src/ElectronDataManager.ts create mode 100644 packages/sdk/electron/src/bootstrap.ts create mode 100644 packages/sdk/electron/src/options.ts create mode 100644 packages/sdk/electron/temp_docs/MIGRATION.md create mode 100644 packages/sdk/electron/temp_docs/README.md create mode 100644 packages/sdk/electron/temp_docs/ipc-bridge.md diff --git a/packages/sdk/electron/README.md b/packages/sdk/electron/README.md index 51d55ef33b..19b89a2508 100644 --- a/packages/sdk/electron/README.md +++ b/packages/sdk/electron/README.md @@ -47,4 +47,4 @@ NOTE: this is just copied from shopify sdk for now [ci-link]: https://github.com/launchdarkly/js-core/actions/workflows/shopify-oxygen.yml [ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 [ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/shopify-oxygen/docs/ ---> \ No newline at end of file +--> diff --git a/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts b/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts new file mode 100644 index 0000000000..1023958982 --- /dev/null +++ b/packages/sdk/electron/__tests__/ElectronClient.mainProcess.test.ts @@ -0,0 +1,339 @@ +import type { + LDContext, + LDIdentifyOptions, + LDLogger, + Response, +} from '@launchdarkly/js-client-sdk-common'; + +import { ElectronClient } from '../src/ElectronClient'; +import { createClient } from '../src/index'; +import ElectronCrypto from '../src/platform/ElectronCrypto'; +import ElectronEncoding from '../src/platform/ElectronEncoding'; +import ElectronInfo from '../src/platform/ElectronInfo'; +import ElectronPlatform from '../src/platform/ElectronPlatform'; +import { goodBootstrapData, remoteFlagsMockData } from './testBootstrapData'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +const createMockEventSource = (streamUri: string = '', options: Record = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), +}); + +/** Mock event source that delivers a put event with the given flag data so streaming identify completes. */ +function createMockEventSourceThatDeliversPut(putData: object) { + const putPayload = JSON.stringify(putData); + return (streamUri: string = '', options: Record = {}) => ({ + ...createMockEventSource(streamUri, options), + addEventListener: jest.fn((eventName: string, callback: (e: { data?: string }) => void) => { + if (eventName === 'put') { + Promise.resolve().then(() => callback({ data: putPayload })); + } + }), + }); +} + +const handlers = new Map(); +const mockOn = jest.fn((eventName: string, handler: Function) => { + handlers.set(eventName, handler); +}); +const mockHandle = jest.fn((eventName: string, handler: Function) => { + handlers.set(eventName, handler); +}); + +jest.mock('electron', () => ({ + ipcMain: { + on: (eventName: string, handler: Function) => mockOn(eventName, handler), + handle: (eventName: string, handler: Function) => mockHandle(eventName, handler), + getHandler: (eventName: string) => handlers.get(eventName), + removeAllListeners: (channel: string) => handlers.delete(channel), + removeHandler: (channel: string) => handlers.delete(channel), + }, +})); + +jest.mock('../src/platform/ElectronPlatform', () => ({ + __esModule: true, + default: jest.fn(() => ({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + })), +})); + +const clientSideId = 'client-side-id'; +const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'test-user' }; + +beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); +}); + +describe('given an initialized ElectronClient with enableIPC: false', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + }); + + beforeAll(async () => { + await client.start(); + }); + + it('does not register any IPC channels so ipcMain.on and ipcMain.handle are never called', () => { + expect(mockOn).not.toHaveBeenCalled(); + expect(mockHandle).not.toHaveBeenCalled(); + }); + + it('evaluates allFlags() when called directly on the client', () => { + const result = client.allFlags(); + expect(result).toEqual({}); + }); + + it('evaluates boolVariation() when called directly on the client', () => { + const result = client.boolVariation('flag1', false); + expect(result).toBe(false); + }); + + it('evaluates boolVariationDetail() when called directly on the client', () => { + const result = client.boolVariationDetail('flag1', false); + expect(result.value).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('evaluates flush() when called directly on the client', async () => { + const result = await client.flush(); + expect(result).toBeDefined(); + expect(result).toHaveProperty('result'); + }); + + it('evaluates getContext() when called directly on the client', () => { + const result = client.getContext(); + expect(result).toEqual(DEFAULT_INITIAL_CONTEXT); + }); + + it('evaluates identify() when called directly on the client', async () => { + const context: LDContext = { kind: 'user', key: 'test-user-id' }; + const options: LDIdentifyOptions = { waitForNetworkResults: true }; + const result = await client.identify(context, options); + expect(result.status).toBe('completed'); + expect(client.getContext()).toEqual(context); + }); + + it('evaluates variation, detail, connection mode, and track when called directly on the client', async () => { + expect(client.jsonVariation('flag1', {})).toEqual({}); + expect(client.jsonVariationDetail('flag1', {}).value).toEqual({}); + expect(client.jsonVariationDetail('flag1', {}).reason).toBeDefined(); + + expect(client.numberVariation('flag1', 0)).toBe(0); + expect(client.numberVariationDetail('flag1', 0).value).toBe(0); + expect(client.numberVariationDetail('flag1', 0).reason).toBeDefined(); + + expect(client.stringVariation('flag1', '')).toBe(''); + expect(client.stringVariationDetail('flag1', '').value).toBe(''); + expect(client.stringVariationDetail('flag1', '').reason).toBeDefined(); + + expect(() => client.track('event1', { key1: 'value1' }, 1234.5)).not.toThrow(); + + expect(client.variation('flag1', false)).toBe(false); + expect(client.variationDetail('flag1', false).value).toBe(false); + expect(client.variationDetail('flag1', false).reason).toBeDefined(); + + expect(client.getConnectionMode()).toBe('offline'); + expect(client.isOffline()).toBe(true); + await expect(client.setConnectionMode('offline')).resolves.not.toThrow(); + }); +}); + +describe('given an initialized ElectronClient with enableIPC: false and bootstrap data', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + it('evaluates flags from bootstrap when called directly on the client', async () => { + const client = new ElectronClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + }); + await client.start({ bootstrap: goodBootstrapData }); + + expect(client.variation('killswitch', false)).toBe(true); + expect(client.stringVariation('string-flag', '')).toBe('is bob'); + expect(client.boolVariation('cat', true)).toBe(false); + expect(client.allFlags()).toMatchObject({ + killswitch: true, + 'string-flag': 'is bob', + cat: false, + }); + }); +}); + +describe('enableIPC: false and close()', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + it('does not register channels so close() does not remove any handlers', async () => { + const client = new ElectronClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + }); + await client.start(); + + expect(mockOn).not.toHaveBeenCalled(); + expect(mockHandle).not.toHaveBeenCalled(); + + await client.close(); + + expect(mockOn).not.toHaveBeenCalled(); + expect(mockHandle).not.toHaveBeenCalled(); + }); +}); + +describe('given an initialized ElectronClient with enableIPC: false and polling', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + it('evaluates flags from polling response when called directly on the client', async () => { + const mockedFetch = mockFetch(JSON.stringify(remoteFlagsMockData), 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + + const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'polling', + enableIPC: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + }); + await client.start(); + + expect(client.boolVariation('on-off-flag', false)).toBe(true); + expect(client.stringVariation('string-flag', '')).toBe('from-remote'); + expect(client.numberVariation('number-flag', 0)).toBe(100); + expect(client.jsonVariation('json-flag', {})).toEqual({ key: 'value', count: 5 }); + expect(client.allFlags()).toMatchObject({ + 'on-off-flag': true, + 'string-flag': 'from-remote', + 'number-flag': 100, + 'json-flag': { key: 'value', count: 5 }, + }); + }); +}); + +describe('given an initialized ElectronClient with enableIPC: false and streaming', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + it('evaluates flags from streaming put when called directly on the client', async () => { + const mockedCreateEventSource = jest.fn( + createMockEventSourceThatDeliversPut(remoteFlagsMockData), + ); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: mockedCreateEventSource, + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + + const client = createClient(clientSideId, DEFAULT_INITIAL_CONTEXT, { + initialConnectionMode: 'streaming', + enableIPC: false, + logger, + diagnosticOptOut: true, + sendEvents: false, + }); + await client.start(); + + expect(client.boolVariation('on-off-flag', false)).toBe(true); + expect(client.stringVariation('string-flag', '')).toBe('from-remote'); + expect(client.numberVariation('number-flag', 0)).toBe(100); + expect(client.jsonVariation('json-flag', {})).toEqual({ key: 'value', count: 5 }); + expect(client.allFlags()).toMatchObject({ + 'on-off-flag': true, + 'string-flag': 'from-remote', + 'number-flag': 100, + 'json-flag': { key: 'value', count: 5 }, + }); + }); +}); diff --git a/packages/sdk/electron/__tests__/ElectronClient.test.ts b/packages/sdk/electron/__tests__/ElectronClient.test.ts new file mode 100644 index 0000000000..2d724dfc2b --- /dev/null +++ b/packages/sdk/electron/__tests__/ElectronClient.test.ts @@ -0,0 +1,802 @@ +import type { LDContext, LDLogger, Response } from '@launchdarkly/js-client-sdk-common'; + +import { ElectronClient } from '../src/ElectronClient'; +import { createClient } from '../src/index'; +import ElectronCrypto from '../src/platform/ElectronCrypto'; +import ElectronEncoding from '../src/platform/ElectronEncoding'; +import ElectronInfo from '../src/platform/ElectronInfo'; +import ElectronPlatform from '../src/platform/ElectronPlatform'; +import { goodBootstrapData } from './testBootstrapData'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +/** + * Mocks fetch. Returns the fetch jest.Mock object. + * @param remoteJson + * @param statusCode + */ +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +jest.mock('../src/platform/ElectronPlatform', () => ({ + __esModule: true, + default: jest.fn(() => ({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + })), +})); + +const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), +}); + +/** Mock event source that delivers a put event so streaming identify completes. */ +const createMockEventSourceThatDeliversPut = (streamUri: string = '', options: any = {}) => ({ + ...createMockEventSource(streamUri, options), + addEventListener: jest.fn((eventName: string, callback: (e: { data?: string }) => void) => { + if (eventName === 'put') { + Promise.resolve().then(() => callback({ data: '{}' })); + } + }), +}); + +const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'bob' }; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('uses correct default diagnostic url when using mobile key', () => { + const mockedFetch = jest.fn(); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + }); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/mobile/events/diagnostic', + expect.anything(), + ); + client.close(); +}); + +it('uses correct default analytics event url when using mobile key', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: createMockEventSource, + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'polling', + }); + await client.start(); + await client.flush(); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/mobile', + expect.anything(), + ); +}); + +it('uses correct default polling url when using mobile key', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'polling', + }); + await client.start(); + + const regex = /https:\/\/clientsdk\.launchdarkly\.com\/msdk\/evalx\/contexts\/.*/; + expect(mockedFetch).toHaveBeenCalledWith(expect.stringMatching(regex), expect.anything()); +}); + +it('uses correct default streaming url when using mobile key', async () => { + const mockedCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => + createMockEventSourceThatDeliversPut(streamUri, options), + ); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: mockedCreateEventSource, + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'streaming', + }); + + await client.start(); + + const regex = /https:\/\/clientstream\.launchdarkly\.com\/meval\/.*/; + expect(mockedCreateEventSource).toHaveBeenCalledWith( + expect.stringMatching(regex), + expect.anything(), + ); +}); + +it('includes authorization header for polling when using mobile key', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'polling', + }); + await client.start(); + + expect(mockedFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'mobile-key-123' }), + }), + ); +}); + +it('includes authorization header for streaming when using mobile key', async () => { + const mockedCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => + createMockEventSourceThatDeliversPut(streamUri, options), + ); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: mockedCreateEventSource, + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'streaming', + }); + + await client.start(); + + expect(mockedCreateEventSource).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'mobile-key-123' }), + }), + ); +}); + +it('includes authorization header for events when using mobile key', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'polling', + }); + await client.start(); + await client.flush(); + + expect(mockedFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'mobile-key-123' }), + }), + ); +}); + +it('uses client-side diagnostic url when useClientSideId is true', () => { + const mockedFetch = jest.fn(); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + }); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/events/diagnostic/client-side-id', + expect.anything(), + ); + client.close(); +}); + +it('uses client-side analytics event url when useClientSideId is true', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: createMockEventSource, + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + diagnosticOptOut: true, + initialConnectionMode: 'polling', + }); + await client.start(); + await client.flush(); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/events/bulk/client-side-id', + expect.anything(), + ); +}); + +it('uses client-side polling url when useClientSideId is true', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'polling', + }); + await client.start(); + + const regex = /https:\/\/clientsdk\.launchdarkly\.com\/sdk\/evalx\/client-side-id\/contexts\/.*/; + expect(mockedFetch).toHaveBeenCalledWith(expect.stringMatching(regex), expect.anything()); +}); + +it('uses client-side streaming url when useClientSideId is true', async () => { + const mockedCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => + createMockEventSourceThatDeliversPut(streamUri, options), + ); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: mockedCreateEventSource, + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'streaming', + }); + + await client.start(); + + const regex = /https:\/\/clientstream\.launchdarkly\.com\/eval\/client-side-id\/.*/; + expect(mockedCreateEventSource).toHaveBeenCalledWith( + expect.stringMatching(regex), + expect.anything(), + ); +}); + +it('includes authorization header for polling when useClientSideId is true', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'polling', + }); + await client.start(); + + expect(mockedFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'client-side-id' }), + }), + ); +}); + +it('includes authorization header for streaming when useClientSideId is true', async () => { + const mockedCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => + createMockEventSourceThatDeliversPut(streamUri, options), + ); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: mockedCreateEventSource, + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'streaming', + }); + + await client.start(); + + expect(mockedCreateEventSource).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'client-side-id' }), + }), + ); +}); + +it('includes authorization header for events when useClientSideId is true', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + useClientSideId: true, + diagnosticOptOut: true, + initialConnectionMode: 'polling', + }); + await client.start(); + await client.flush(); + + expect(mockedFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'client-side-id' }), + }), + ); +}); + +it('identify with too high of a timeout', async () => { + const logger: LDLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + await client.start(); + client.identify({ key: 'potato', kind: 'user' }, { timeout: 16 }); + expect(logger.warn).toHaveBeenCalledWith( + 'The identify function was called with a timeout greater than 15 seconds. We recommend a timeout of less than 15 seconds.', + ); +}); + +it('identify timeout equal to threshold', async () => { + const logger: LDLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + await client.start(); + client.identify({ key: 'potato', kind: 'user' }, { timeout: 15 }); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('returns error result when identify() is called before start()', async () => { + const client = createClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + initialConnectionMode: 'offline', + }); + const result = await client.identify({ kind: 'user', key: 'other' }); + expect(result).toBeDefined(); + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('Identify called before start'); + } +}); + +it('can identify a new context after start() is called', async () => { + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + initialConnectionMode: 'offline', + }); + await client.start(); + await client.identify({ kind: 'user', key: 'new-context-key' }); + expect(client.getContext()).toEqual({ kind: 'user', key: 'new-context-key' }); + expect(client.variation('some-flag', 'default')).toBe('default'); +}); + +it('identify() returns completed result when called after start()', async () => { + const client = createClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + initialConnectionMode: 'offline', + }); + await client.start(); + const result = await client.identify({ kind: 'user', key: 'new-key' }); + expect(result).toEqual({ status: 'completed' }); +}); + +it('can start with an anonymous context as the initial context', async () => { + const anonymousContext = { anonymous: true, kind: 'user' } as LDContext; + const client = new ElectronClient('mobile-key-123', anonymousContext, { + enableIPC: false, + initialConnectionMode: 'offline', + }); + await client.start(); + const ctx = client.getContext() as Record | undefined; + expect(ctx).toBeDefined(); + expect(ctx?.kind).toBe('user'); + expect(ctx?.anonymous).toBe(true); + expect(client.variation('some-flag', 'default')).toBe('default'); +}); + +it('can identify an anonymous context after start() is called', async () => { + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + initialConnectionMode: 'offline', + }); + await client.start(); + const anonymousContext = { anonymous: true, kind: 'user' } as LDContext; + await client.identify(anonymousContext); + const ctx = client.getContext() as Record | undefined; + expect(ctx).toBeDefined(); + expect(ctx?.kind).toBe('user'); + expect(ctx?.anonymous).toBe(true); + expect(client.variation('some-flag', 'default')).toBe('default'); +}); + +it('start() returns same promise when called twice', async () => { + const mockedFetch = mockFetch('{"flagA": true}', 200); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { clear: jest.fn(), get: jest.fn(), set: jest.fn() }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'polling', + }); + const p1 = client.start(); + const p2 = client.start(); + expect(p1).toBe(p2); + await p1; +}); + +it('can get connection mode', () => { + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'offline', + }); + + const mode = client.getConnectionMode(); + expect(mode).toEqual('offline'); +}); + +it('can detect if offline', () => { + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'offline', + }); + + expect(client.isOffline()).toEqual(true); +}); + +it('can detect if not offline', () => { + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'streaming', + }); + + expect(client.isOffline()).toEqual(false); +}); + +it('can set connection mode to offline', async () => { + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'streaming', + }); + + const setEventSendingEnabled = jest.spyOn(client as any, 'setEventSendingEnabled'); + const setConnectionMode = jest.spyOn((client as any).dataManager, 'setConnectionMode'); + + await client.setConnectionMode('offline'); + + expect(setEventSendingEnabled).toHaveBeenCalledTimes(1); + expect(setEventSendingEnabled).toHaveBeenNthCalledWith(1, false, true); + + expect(setConnectionMode).toHaveBeenCalledTimes(1); + expect(setConnectionMode).toHaveBeenNthCalledWith(1, 'offline'); +}); + +it('can set connection mode to not offline', async () => { + const client = new ElectronClient('client-side-id', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + initialConnectionMode: 'offline', + }); + + const setEventSendingEnabled = jest.spyOn(client as any, 'setEventSendingEnabled'); + const setConnectionMode = jest.spyOn((client as any).dataManager, 'setConnectionMode'); + + await client.setConnectionMode('streaming'); + + expect(setConnectionMode).toHaveBeenCalledTimes(1); + expect(setConnectionMode).toHaveBeenNthCalledWith(1, 'streaming'); + + expect(setEventSendingEnabled).toHaveBeenCalledTimes(1); + expect(setEventSendingEnabled).toHaveBeenNthCalledWith(1, true, false); +}); + +it('can use bootstrap data with identify', async () => { + const mockedCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => + createMockEventSource(streamUri, options), + ); + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: mockedCreateEventSource, + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'streaming', + }); + + await client.start({ bootstrap: goodBootstrapData }); + + expect(client.variation('killswitch', false)).toBe(true); + expect(client.variation('string-flag', '')).toBe('is bob'); + expect(client.boolVariation('cat', true)).toBe(false); + // After bootstrap we still set up streaming for live updates + expect(mockedCreateEventSource).toHaveBeenCalled(); +}); + +it('parses bootstrap data only once when identify is called with bootstrap', async () => { + const bootstrapModule = await import('../src/bootstrap'); + const readFlagsFromBootstrapSpy = jest.spyOn(bootstrapModule, 'readFlagsFromBootstrap'); + + (ElectronPlatform as jest.Mock).mockReturnValue({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => + createMockEventSource(streamUri, options), + ), + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }); + const client = new ElectronClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, { + enableIPC: false, + diagnosticOptOut: true, + sendEvents: false, + initialConnectionMode: 'streaming', + }); + + await client.start({ bootstrap: goodBootstrapData }); + + expect(readFlagsFromBootstrapSpy).toHaveBeenCalledTimes(1); + expect(readFlagsFromBootstrapSpy).toHaveBeenCalledWith(expect.anything(), goodBootstrapData); + readFlagsFromBootstrapSpy.mockRestore(); +}); diff --git a/packages/sdk/electron/__tests__/ElectronDataManager.test.ts b/packages/sdk/electron/__tests__/ElectronDataManager.test.ts new file mode 100644 index 0000000000..f2f69e1a2a --- /dev/null +++ b/packages/sdk/electron/__tests__/ElectronDataManager.test.ts @@ -0,0 +1,588 @@ +import { + ApplicationTags, + base64UrlEncode, + Configuration, + Context, + Encoding, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + Platform, + Response, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import ElectronDataManager from '../src/ElectronDataManager'; +import type { ElectronIdentifyOptions } from '../src/ElectronIdentifyOptions'; +import { ValidatedOptions } from '../src/options'; +import ElectronCrypto from '../src/platform/ElectronCrypto'; +import ElectronEncoding from '../src/platform/ElectronEncoding'; +import ElectronInfo from '../src/platform/ElectronInfo'; +import { goodBootstrapData } from './testBootstrapData'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +beforeAll(() => { + jest.useFakeTimers(); +}); + +describe('given an ElectronDataManager with mocked dependencies', () => { + let platform: jest.Mocked; + let flagManager: jest.Mocked; + let config: Configuration; + let elConfig: ValidatedOptions; + let baseHeaders: LDHeaders; + let emitter: jest.Mocked; + let diagnosticsManager: jest.Mocked; + let dataManager: ElectronDataManager; + let logger: LDLogger; + let eventSourceCloseMethod: jest.Mock; + + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + eventSourceCloseMethod = jest.fn(); + + config = { + logger, + maxCachedContexts: 5, + capacity: 100, + diagnosticRecordingInterval: 1000, + flushInterval: 1000, + streamInitialReconnectDelay: 1000, + allAttributesPrivate: false, + debug: true, + diagnosticOptOut: false, + sendEvents: false, + sendLDHeaders: true, + useReport: false, + withReasons: true, + privateAttributes: [], + tags: new ApplicationTags({}), + serviceEndpoints: new ServiceEndpoints('', ''), + pollInterval: 1000, + userAgentHeaderName: 'user-agent', + trackEventModifier: (event) => event, + hooks: [], + inspectors: [], + getImplementationHooks: () => [], + credentialType: 'clientSideId', + }; + const mockedFetch = mockFetch('{"flagA": true}', 200); + platform = { + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: eventSourceCloseMethod, + })), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(() => ({ + customMethod: true, + })), + }, + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + encoding: new ElectronEncoding(), + } as unknown as jest.Mocked; + + flagManager = { + loadCached: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + init: jest.fn(), + upsert: jest.fn(), + on: jest.fn(), + off: jest.fn(), + setBootstrap: jest.fn(), + } as unknown as jest.Mocked; + + elConfig = { initialConnectionMode: 'streaming' } as ValidatedOptions; + baseHeaders = {}; + emitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + diagnosticsManager = {} as unknown as jest.Mocked; + + dataManager = new ElectronDataManager( + platform, + flagManager, + 'test-credential', + config, + elConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/test-credential/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/sdk/evalx/test-credential/context'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + // Note: if you are seeing this error, it is a coding error. This DataSourcePaths implementation is for polling endpoints. /ping is not currently + // used in a polling situation. It is probably the case that this was called by streaming logic erroneously. + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/test-credential/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/eval/test-credential'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/ping/test-credential'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should initialize with the correct initial connection mode', () => { + expect(dataManager.getConnectionMode()).toBe('streaming'); + }); + + it('should set and get connection mode', async () => { + await dataManager.setConnectionMode('polling'); + expect(dataManager.getConnectionMode()).toBe('polling'); + + await dataManager.setConnectionMode('streaming'); + expect(dataManager.getConnectionMode()).toBe('streaming'); + + await dataManager.setConnectionMode('offline'); + expect(dataManager.getConnectionMode()).toBe('offline'); + }); + + it('uses data from bootstrap and then starts streaming for live updates', async () => { + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + + await dataManager.identify(identifyResolve, identifyReject, context, { + bootstrap: goodBootstrapData, + } as ElectronIdentifyOptions); + + expect(flagManager.setBootstrap).toHaveBeenCalledWith( + context, + expect.objectContaining({ + cat: expect.any(Object), + killswitch: expect.any(Object), + }), + ); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Identify - Initialization completed from bootstrap', + ); + // After bootstrap we fall through and set up the connection for live updates + expect(platform.requests.createEventSource).toHaveBeenCalled(); + }); + + it('should log when connection mode remains the same', async () => { + const initialMode = dataManager.getConnectionMode(); + await dataManager.setConnectionMode(initialMode); + expect(logger.debug).toHaveBeenCalledWith( + `[ElectronDataManager] setConnectionMode ignored. Mode is already '${initialMode}'.`, + ); + expect(dataManager.getConnectionMode()).toBe(initialMode); + }); + + it('uses streaming when the connection mode is streaming', async () => { + dataManager.setConnectionMode('streaming'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('uses polling when the connection mode is polling', async () => { + dataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).toHaveBeenCalled(); + }); + + it('makes no connection when offline', async () => { + dataManager.setConnectionMode('offline'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('makes no connection when closed', async () => { + dataManager.close(); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Identify called after data manager was closed.', + ); + }); + + it('should load cached flags and resolve the identify', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Identify completing with cached flags', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + }); + + it('should log that it loaded cached values, but is waiting for the network result', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: true }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).not.toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify without cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Offline identify - no cached flags, using defaults or already loaded flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify with cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(true); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Offline identify - using cached flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('closes the event source when the data manager is closed', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + expect(platform.requests.createEventSource).toHaveBeenCalled(); + + dataManager.close(); + expect(eventSourceCloseMethod).toHaveBeenCalled(); + + // Verify a subsequent identify doesn't create a new event source + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1); + + expect(logger.debug).toHaveBeenCalledWith( + '[ElectronDataManager] Identify called after data manager was closed.', + ); + }); + + it('uses REPORT method and includes context in body when useReport is true', async () => { + const useReportConfig = { ...config, useReport: true }; + dataManager = new ElectronDataManager( + platform, + flagManager, + 'test-credential', + useReportConfig, + elConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/test-credential/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/sdk/evalx/test-credential/context'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/test-credential/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/eval/test-credential'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/ping/test-credential'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'REPORT', + body: JSON.stringify(Context.toLDContext(context)), + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); + }); + + it('includes withReasons query parameter when withReasons is true', async () => { + const withReasonsConfig = { ...config, withReasons: true }; + dataManager = new ElectronDataManager( + platform, + flagManager, + 'test-credential', + withReasonsConfig, + elConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/test-credential/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/sdk/evalx/test-credential/context'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/test-credential/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/eval/test-credential'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/ping/test-credential'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalledWith( + expect.stringContaining('?withReasons=true'), + expect.anything(), + ); + }); + + it('uses GET method and does not include context in body when useReport is false', async () => { + const useReportConfig = { ...config, useReport: false }; + dataManager = new ElectronDataManager( + platform, + flagManager, + 'test-credential', + useReportConfig, + elConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/test-credential/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/sdk/evalx/test-credential/context'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/test-credential/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/eval/test-credential'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/ping/test-credential'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'content-type': 'application/json', + }), + }), + ); + expect((platform.requests.createEventSource as jest.Mock).mock.calls[0][1].method).not.toBe( + 'REPORT', + ); + }); + + it('does not include withReasons query parameter when withReasons is false', async () => { + const withReasonsConfig = { ...config, withReasons: false }; + dataManager = new ElectronDataManager( + platform, + flagManager, + 'test-credential', + withReasonsConfig, + elConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/test-credential/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/sdk/evalx/test-credential/context'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/test-credential/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/eval/test-credential'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/ping/test-credential'; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalledWith( + expect.not.stringContaining('withReasons=true'), + expect.anything(), + ); + }); +}); diff --git a/packages/sdk/electron/__tests__/ElectronLDMainClient.plugin.test.ts b/packages/sdk/electron/__tests__/ElectronLDMainClient.plugin.test.ts new file mode 100644 index 0000000000..c35f91cf1e --- /dev/null +++ b/packages/sdk/electron/__tests__/ElectronLDMainClient.plugin.test.ts @@ -0,0 +1,368 @@ +import { + Hook, + HookMetadata, + LDContext, + LDLogger, + LDOptions, +} from '@launchdarkly/js-client-sdk-common'; + +import { createClient } from '../src/index'; +import { LDPlugin } from '../src/LDPlugin'; +import ElectronCrypto from '../src/platform/ElectronCrypto'; +import ElectronEncoding from '../src/platform/ElectronEncoding'; +import ElectronInfo from '../src/platform/ElectronInfo'; + +jest.mock('../src/platform/ElectronPlatform', () => ({ + __esModule: true, + default: jest.fn(() => ({ + crypto: new ElectronCrypto(), + info: new ElectronInfo(), + requests: { + createEventSource: jest.fn(), + fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + encoding: new ElectronEncoding(), + storage: { + clear: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + })), +})); + +beforeAll(() => { + jest.useFakeTimers(); +}); + +it('registers plugins and executes hooks during initialization', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook: Hook = { + getMetadata(): HookMetadata { + return { + name: 'test-hook', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + beforeIdentify: jest.fn(() => ({})), + afterIdentify: jest.fn(() => ({})), + afterTrack: jest.fn(() => ({})), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: () => [mockHook], + }; + + const context: LDContext = { key: 'user-key', kind: 'user' }; + const client = createClient('client-side-id', context, { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + plugins: [mockPlugin], + }); + + // Verify the plugin was registered + expect(mockPlugin.register).toHaveBeenCalled(); + + // Now test that hooks work by calling start (which runs identify) and variation + await client.start(); + + expect(mockHook.beforeIdentify).toHaveBeenCalledWith({ context, timeout: undefined }, {}); + + expect(mockHook.afterIdentify).toHaveBeenCalledWith( + { context, timeout: undefined }, + {}, + { status: 'completed' }, + ); + + client.variation('flag-key', false); + + expect(mockHook.beforeEvaluation).toHaveBeenCalledWith( + { context, defaultValue: false, flagKey: 'flag-key' }, + {}, + ); + + expect(mockHook.afterEvaluation).toHaveBeenCalled(); + + client.track('event-key', { data: true }, 42); + + expect(mockHook.afterTrack).toHaveBeenCalledWith({ + context, + key: 'event-key', + data: { data: true }, + metricValue: 42, + }); +}); + +it('registers multiple plugins and executes all hooks', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook1: Hook = { + getMetadata(): HookMetadata { + return { + name: 'test-hook-1', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + afterTrack: jest.fn(() => ({})), + }; + + const mockHook2: Hook = { + getMetadata(): HookMetadata { + return { + name: 'test-hook-2', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + afterTrack: jest.fn(() => ({})), + }; + + const mockPlugin1: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin-1' }), + register: jest.fn(), + getHooks: () => [mockHook1], + }; + + const mockPlugin2: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin-2' }), + register: jest.fn(), + getHooks: () => [mockHook2], + }; + + const client = createClient( + 'client-side-id', + { kind: 'user', key: 'user-key' }, + { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + plugins: [mockPlugin1, mockPlugin2], + }, + ); + + // Verify plugins were registered + expect(mockPlugin1.register).toHaveBeenCalled(); + expect(mockPlugin2.register).toHaveBeenCalled(); + + // Test that both hooks work + await client.start(); + client.variation('flag-key', false); + client.track('event-key', { data: true }, 42); + + expect(mockHook1.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterEvaluation).toHaveBeenCalled(); + expect(mockHook2.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook2.afterEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterTrack).toHaveBeenCalled(); + expect(mockHook2.afterTrack).toHaveBeenCalled(); +}); + +it('passes correct environmentMetadata to plugin getHooks and register functions', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook: Hook = { + getMetadata(): HookMetadata { + return { + name: 'test-hook', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: jest.fn(() => [mockHook]), + }; + + const options: LDOptions = { + applicationInfo: { + id: 'test-app', + version: '3.0.0', + name: 'TestApp', + versionName: '3', + }, + }; + + createClient( + 'client-side-id', + { kind: 'user', key: 'user-key' }, + { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + plugins: [mockPlugin], + ...options, + }, + ); + + const envMeta = (mockPlugin.register as jest.Mock).mock.calls[0][1]; + expect(envMeta.sdk.name).toBeDefined(); + expect(envMeta.sdk.version).toBeDefined(); + + // Verify getHooks was called with correct environmentMetadata (mobile key by default) + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: envMeta.sdk.name, + version: envMeta.sdk.version, + }, + application: { + id: options.applicationInfo?.id, + version: options.applicationInfo?.version, + name: options.applicationInfo?.name, + versionName: options.applicationInfo?.versionName, + }, + mobileKey: 'client-side-id', + }); + + // Verify register was called with correct environmentMetadata (mobile key by default) + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: envMeta.sdk.name, + version: envMeta.sdk.version, + }, + application: { + id: options.applicationInfo?.id, + version: options.applicationInfo?.version, + name: options.applicationInfo?.name, + versionName: options.applicationInfo?.versionName, + }, + mobileKey: 'client-side-id', + }, + ); +}); + +it('passes correct environmentMetadata without optional fields', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockHook: Hook = { + getMetadata(): HookMetadata { + return { + name: 'test-hook', + }; + }, + beforeEvaluation: jest.fn(() => ({})), + afterEvaluation: jest.fn(() => ({})), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: jest.fn(() => [mockHook]), + }; + + createClient( + 'client-side-id', + { kind: 'user', key: 'user-key' }, + { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + plugins: [mockPlugin], + }, + ); + + const envMeta = (mockPlugin.register as jest.Mock).mock.calls[0][1]; + expect(envMeta.sdk.name).toBeDefined(); + expect(envMeta.sdk.version).toBeDefined(); + + // Verify getHooks was called with correct environmentMetadata (mobile key by default) + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: envMeta.sdk.name, + version: envMeta.sdk.version, + }, + mobileKey: 'client-side-id', + }); + + // Verify register was called with correct environmentMetadata (mobile key by default) + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: envMeta.sdk.name, + version: envMeta.sdk.version, + }, + mobileKey: 'client-side-id', + }, + ); +}); + +it('passes clientSideId in environmentMetadata when useClientSideId is true', async () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockPlugin: LDPlugin = { + getMetadata: () => ({ name: 'test-plugin' }), + register: jest.fn(), + getHooks: jest.fn(() => []), + }; + + createClient( + 'my-client-side-id', + { kind: 'user', key: 'user-key' }, + { + initialConnectionMode: 'offline', + enableIPC: false, + logger, + diagnosticOptOut: true, + plugins: [mockPlugin], + useClientSideId: true, + }, + ); + + const envMeta = (mockPlugin.register as jest.Mock).mock.calls[0][1]; + + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: envMeta.sdk.name, + version: envMeta.sdk.version, + }, + clientSideId: 'my-client-side-id', + }); + + expect(mockPlugin.register).toHaveBeenCalledWith(expect.any(Object), { + sdk: { + name: envMeta.sdk.name, + version: envMeta.sdk.version, + }, + clientSideId: 'my-client-side-id', + }); +}); diff --git a/packages/sdk/electron/__tests__/bootstrap.test.ts b/packages/sdk/electron/__tests__/bootstrap.test.ts new file mode 100644 index 0000000000..af647bc4bd --- /dev/null +++ b/packages/sdk/electron/__tests__/bootstrap.test.ts @@ -0,0 +1,86 @@ +import { readFlagsFromBootstrap } from '../src/bootstrap'; +import { goodBootstrapData, goodBootstrapDataWithReasons } from './testBootstrapData'; + +it('can read valid bootstrap data', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const readData = readFlagsFromBootstrap(logger, goodBootstrapData); + expect(readData).toEqual({ + cat: { version: 2, flag: { version: 2, variation: 1, value: false } }, + json: { version: 3, flag: { version: 3, variation: 1, value: ['a', 'b', 'c', 'd'] } }, + killswitch: { version: 5, flag: { version: 5, variation: 0, value: true } }, + 'my-boolean-flag': { version: 11, flag: { version: 11, variation: 1, value: false } }, + 'string-flag': { version: 3, flag: { version: 3, variation: 1, value: 'is bob' } }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can read valid bootstrap data with reasons', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const readData = readFlagsFromBootstrap(logger, goodBootstrapDataWithReasons); + expect(readData.cat).toEqual({ + version: 2, + flag: { + version: 2, + variation: 1, + value: false, + reason: { kind: 'OFF' }, + }, + }); + expect(readData.killswitch.flag.reason).toEqual({ kind: 'FALLTHROUGH' }); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +it('can read old bootstrap data without $flagsState', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const oldData: any = { ...goodBootstrapData }; + delete oldData.$flagsState; + + const readData = readFlagsFromBootstrap(logger, oldData); + expect(readData).toEqual({ + cat: { version: 0, flag: { version: 0, value: false } }, + json: { version: 0, flag: { version: 0, value: ['a', 'b', 'c', 'd'] } }, + killswitch: { version: 0, flag: { version: 0, value: true } }, + 'my-boolean-flag': { version: 0, flag: { version: 0, value: false } }, + 'string-flag': { version: 0, flag: { version: 0, value: 'is bob' } }, + }); + expect(logger.warn).toHaveBeenCalledWith( + 'LaunchDarkly client was initialized with bootstrap data that did not include flag' + + ' metadata. Events may not be sent correctly.', + ); +}); + +it('can handle invalid bootstrap data with $valid false', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const invalid: any = { $valid: false, $flagsState: {} }; + + const readData = readFlagsFromBootstrap(logger, invalid); + expect(readData).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith( + 'LaunchDarkly bootstrap data is not available because the back end could not read the flags.', + ); +}); diff --git a/packages/sdk/electron/__tests__/options.test.ts b/packages/sdk/electron/__tests__/options.test.ts new file mode 100644 index 0000000000..1eedf3bc20 --- /dev/null +++ b/packages/sdk/electron/__tests__/options.test.ts @@ -0,0 +1,163 @@ +import { LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import { ElectronOptions } from '../src/ElectronOptions'; +import validateOptions, { filterToBaseOptions } from '../src/options'; + +it('logs no warnings when all configuration is valid', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + validateOptions( + { + proxyOptions: {}, + tlsParams: {}, + enableEventCompression: true, + initialConnectionMode: 'streaming', + enableIPC: true, + plugins: [], + }, + logger, + ); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('warns for invalid configuration', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + validateOptions( + { + // @ts-ignore + proxyOptions: false, + // @ts-ignore + tlsParams: true, + // @ts-ignore + enableEventCompression: 'toast', + // @ts-ignore + initialConnectionMode: 42, + // @ts-ignore + plugins: 'potato', + // @ts-ignore + enableIPC: {}, + }, + logger, + ); + + expect(logger.warn).toHaveBeenCalledTimes(6); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "proxyOptions" should be of type object, got boolean, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "tlsParams" should be of type object, got boolean, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "enableEventCompression" should be of type boolean, got string, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "initialConnectionMode" should be of type ConnectionMode (offline | streaming | polling), got number, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "plugins" should be of type LDPlugin[], got string, using default value', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "enableIPC" should be of type boolean, got object, using default value', + ); +}); + +it('applies default options', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const opts = validateOptions({}, logger); + + expect(opts.proxyOptions).toBeUndefined(); + expect(opts.tlsParams).toBeUndefined(); + expect(opts.enableEventCompression).toBeUndefined(); + expect(opts.initialConnectionMode).toEqual('streaming'); + expect(opts.plugins).toEqual([]); + expect(opts.enableIPC).toEqual(true); + expect(opts.useClientSideId).toEqual(false); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('applies useClientSideId when set to true', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + 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(), + }; + + validateOptions( + { + // @ts-ignore + useClientSideId: 'true', + }, + logger, + ); + + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "useClientSideId" should be of type boolean, got string, using default value', + ); +}); + +it('filters to base options', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const opts: ElectronOptions = { + debug: false, + proxyOptions: {}, + tlsParams: {}, + enableEventCompression: true, + initialConnectionMode: 'streaming', + enableIPC: true, + plugins: [], + useClientSideId: true, + }; + + const baseOpts = filterToBaseOptions(opts); + expect(baseOpts.debug).toBe(false); + expect(Object.keys(baseOpts).length).toEqual(1); + expect((baseOpts as any).useClientSideId).toBeUndefined(); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/electron/__tests__/testBootstrapData.ts b/packages/sdk/electron/__tests__/testBootstrapData.ts new file mode 100644 index 0000000000..b710794669 --- /dev/null +++ b/packages/sdk/electron/__tests__/testBootstrapData.ts @@ -0,0 +1,57 @@ +export const goodBootstrapData = { + cat: false, + json: ['a', 'b', 'c', 'd'], + killswitch: true, + 'my-boolean-flag': false, + 'string-flag': 'is bob', + $flagsState: { + cat: { + variation: 1, + version: 2, + }, + json: { + variation: 1, + version: 3, + }, + killswitch: { + variation: 0, + version: 5, + }, + 'my-boolean-flag': { + variation: 1, + version: 11, + }, + 'string-flag': { + variation: 1, + version: 3, + }, + }, + $valid: true, +}; + +export const goodBootstrapDataWithReasons = { + cat: false, + json: ['a', 'b', 'c', 'd'], + killswitch: true, + 'my-boolean-flag': false, + 'string-flag': 'is bob', + $flagsState: { + cat: { variation: 1, version: 2, reason: { kind: 'OFF' } }, + json: { variation: 1, version: 3, reason: { kind: 'OFF' } }, + killswitch: { variation: 0, version: 5, reason: { kind: 'FALLTHROUGH' } }, + 'my-boolean-flag': { variation: 1, version: 11, reason: { kind: 'OFF' } }, + 'string-flag': { variation: 1, version: 3, reason: { kind: 'OFF' } }, + }, + $valid: true, +}; + +/** + * Mock flag data in the format expected by polling and streaming (put) responses. + * Used for tests that evaluate flags when connection is not offline. + */ +export const remoteFlagsMockData = { + 'on-off-flag': { version: 1, value: true, variation: 0 }, + 'string-flag': { version: 2, value: 'from-remote', variation: 1 }, + 'number-flag': { version: 1, value: 100, variation: 0 }, + 'json-flag': { version: 1, value: { key: 'value', count: 5 }, variation: 0 }, +}; diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts new file mode 100644 index 0000000000..6e0b923ff7 --- /dev/null +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -0,0 +1,314 @@ +import { + AutoEnvAttributes, + base64UrlEncode, + BasicLogger, + Configuration, + ConnectionMode, + Encoding, + FlagManager, + internal, + LDClientImpl, + LDClientInternalOptions, + LDContext, + LDEmitter, + LDEmitterEventName, + LDFlagValue, + LDHeaders, + LDIdentifyResult, + LDPluginEnvironmentMetadata, + LDWaitForInitializationResult, +} from '@launchdarkly/js-client-sdk-common'; + +import { readFlagsFromBootstrap } from './bootstrap'; +import ElectronDataManager from './ElectronDataManager'; +import type { ElectronIdentifyOptions } from './ElectronIdentifyOptions'; +import type { ElectronOptions, ElectronOptions as LDOptions } from './ElectronOptions'; +import type { LDClient, LDStartOptions } from './LDClient'; +import type { LDPlugin } from './LDPlugin'; +import validateOptions, { filterToBaseOptions } from './options'; +import ElectronPlatform from './platform/ElectronPlatform'; + +// NOTE: we can choose to validate events with a whitelist? However, this might be +// more for the implementers to do. + +// TODO: we will need to refactor the verbiage of client side id to mobile key in the future. +export class ElectronClient extends LDClientImpl { + private readonly _initialContext: LDContext; + + private _startPromise?: Promise; + + private readonly _plugins: LDPlugin[]; + + constructor(credential: string, initialContext: LDContext, options: LDOptions = {}) { + const { logger: customLogger, debug } = options; + const logger = + customLogger ?? + new BasicLogger({ + destination: { + // eslint-disable-next-line no-console + debug: console.debug, + // eslint-disable-next-line no-console + info: console.info, + // eslint-disable-next-line no-console + warn: console.warn, + // eslint-disable-next-line no-console + error: console.error, + }, + level: debug ? 'debug' : 'info', + }); + + const validatedElectronOptions = validateOptions(options, logger); + + const { useClientSideId } = validatedElectronOptions; + + const internalOptions: LDClientInternalOptions = { + analyticsEventPath: useClientSideId ? `/events/bulk/${credential}` : `/mobile`, + diagnosticEventPath: useClientSideId + ? `/events/diagnostic/${credential}` + : `/mobile/events/diagnostic`, + highTimeoutThreshold: 15, + getImplementationHooks: (_environmentMetadata: LDPluginEnvironmentMetadata) => + internal.safeGetHooks(logger, _environmentMetadata, validatedElectronOptions.plugins), + credentialType: useClientSideId ? 'clientSideId' : 'mobileKey', + }; + + const platform = new ElectronPlatform(logger, credential, options); + + super( + credential, + AutoEnvAttributes.Disabled, + platform, + { ...filterToBaseOptions(options), logger }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new ElectronDataManager( + platform, + flagManager, + credential, + configuration, + validatedElectronOptions, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return useClientSideId + ? `/sdk/evalx/${credential}/contexts/${base64UrlEncode(_plainContextString, encoding)}` + : `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return useClientSideId ? `/sdk/evalx/${credential}/context` : `/msdk/evalx/context`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + // Note: if you are seeing this error, it is a coding error. This DataSourcePaths implementation is for polling endpoints. /ping is not currently + // used in a polling situation. It is probably the case that this was called by streaming logic erroneously. + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return useClientSideId + ? `/eval/${credential}/${base64UrlEncode(_plainContextString, encoding)}` + : `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return useClientSideId ? `/eval/${credential}` : `/meval`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return useClientSideId ? `/ping/${credential}` : `/mping`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + internalOptions, + ); + + this._initialContext = initialContext; + this._plugins = validatedElectronOptions.plugins; + this.setEventSendingEnabled(!this.isOffline(), false); + + if (validatedElectronOptions.enableIPC) { + // Not implemented yet + this.logger.debug('Opening IPC channels'); + } + } + + /** + * Registers plugins with the given client. Called from makeClient with the facade + * so plugins receive the public API (single identify that returns LDIdentifyResult). + */ + registerPluginsWith(client: LDClient): void { + internal.safeRegisterPlugins(this.logger, this.environmentMetadata, client, this._plugins); + } + + start(options?: LDStartOptions): Promise { + if (this.initializeResult !== undefined) { + return Promise.resolve(this.initializeResult); + } + if (this._startPromise) { + return this._startPromise; + } + if (!this._initialContext) { + this.logger.error('Initial context not set'); + return Promise.resolve({ status: 'failed', error: new Error('Initial context not set') }); + } + + const identifyOptions: ElectronIdentifyOptions = { + ...(options?.identifyOptions ?? {}), + sheddable: false, + }; + + if ( + options?.bootstrap !== undefined && + options?.bootstrap !== null && + !identifyOptions.bootstrap + ) { + identifyOptions.bootstrap = options.bootstrap; + } + + if (identifyOptions.bootstrap) { + try { + if (!identifyOptions.bootstrapParsed) { + identifyOptions.bootstrapParsed = readFlagsFromBootstrap( + this.logger, + identifyOptions.bootstrap, + ); + } + this.presetFlags(identifyOptions.bootstrapParsed!); + } catch (error) { + this.logger.error('Failed to bootstrap data', error); + } + } + + if (!this.initializedPromise) { + this.initializedPromise = new Promise((resolve) => { + this.initResolve = resolve; + }); + } + + this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); + + this.identify(this._initialContext, identifyOptions); + return this._startPromise; + } + + override async identifyResult( + pristineContext: LDContext, + identifyOptions?: ElectronIdentifyOptions, + ): Promise { + if (!this._startPromise) { + this.logger.error( + 'Client must be started before it can identify a context, did you forget to call start()?', + ); + return { status: 'error', error: new Error('Identify called before start') }; + } + + const identifyOptionsWithUpdatedDefaults = { + ...identifyOptions, + }; + if (identifyOptions?.sheddable === undefined) { + identifyOptionsWithUpdatedDefaults.sheddable = true; + } + + const options = identifyOptionsWithUpdatedDefaults; + if (options.bootstrap) { + try { + if (!options.bootstrapParsed) { + (options as ElectronIdentifyOptions).bootstrapParsed = readFlagsFromBootstrap( + this.logger, + options.bootstrap, + ); + } + this.presetFlags(options.bootstrapParsed!); + } catch (error) { + this.logger.error('Failed to bootstrap data', error); + } + } + + return super.identifyResult(pristineContext, identifyOptionsWithUpdatedDefaults); + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (mode === 'offline') { + this.setEventSendingEnabled(false, true); + } + const dataManager = this.dataManager as ElectronDataManager; + await dataManager.setConnectionMode(mode); + if (mode !== 'offline') { + this.setEventSendingEnabled(true, false); + } + } + + getConnectionMode(): ConnectionMode { + const dataManager = this.dataManager as ElectronDataManager; + return dataManager.getConnectionMode(); + } + + isOffline(): boolean { + const dataManager = this.dataManager as ElectronDataManager; + return dataManager.getConnectionMode() === 'offline'; + } + + override async close(): Promise { + await super.close(); + } +} + +/** + * Builds the LaunchDarkly client facade (PIMPL). Exposes a single identify method that returns + * identify results. Plugins are registered with the facade. + */ +export function makeClient( + credential: string, + initialContext: LDContext, + options: ElectronOptions = {}, +): LDClient { + const impl = new ElectronClient(credential, initialContext, options); + + const client: LDClient = { + variation: (key: string, defaultValue?: LDFlagValue) => impl.variation(key, defaultValue), + variationDetail: (key: string, defaultValue?: LDFlagValue) => + impl.variationDetail(key, defaultValue), + boolVariation: (key: string, defaultValue: boolean) => impl.boolVariation(key, defaultValue), + boolVariationDetail: (key: string, defaultValue: boolean) => + impl.boolVariationDetail(key, defaultValue), + numberVariation: (key: string, defaultValue: number) => impl.numberVariation(key, defaultValue), + numberVariationDetail: (key: string, defaultValue: number) => + impl.numberVariationDetail(key, defaultValue), + stringVariation: (key: string, defaultValue: string) => impl.stringVariation(key, defaultValue), + stringVariationDetail: (key: string, defaultValue: string) => + impl.stringVariationDetail(key, defaultValue), + jsonVariation: (key: string, defaultValue: unknown) => impl.jsonVariation(key, defaultValue), + jsonVariationDetail: (key: string, defaultValue: unknown) => + impl.jsonVariationDetail(key, defaultValue), + track: (key: string, data?: unknown, metricValue?: number) => + impl.track(key, data, metricValue), + on: (key: string, callback: (...args: unknown[]) => void) => + impl.on(key as LDEmitterEventName, callback as (...args: unknown[]) => void), + off: (key: string, callback: (...args: unknown[]) => void) => + impl.off(key as LDEmitterEventName, callback as (...args: unknown[]) => void), + flush: () => impl.flush(), + identify: (ctx: LDContext, identifyOptions?: ElectronIdentifyOptions) => + impl.identifyResult(ctx, identifyOptions), + getContext: () => impl.getContext(), + close: () => impl.close(), + allFlags: () => impl.allFlags(), + addHook: (hook: Parameters[0]) => impl.addHook(hook), + waitForInitialization: (waitOptions?: Parameters[0]) => + impl.waitForInitialization(waitOptions), + logger: impl.logger, + start: (startOptions?: LDStartOptions) => impl.start(startOptions), + setConnectionMode: (mode: Parameters[0]) => + impl.setConnectionMode(mode), + getConnectionMode: () => impl.getConnectionMode(), + isOffline: () => impl.isOffline(), + }; + + impl.registerPluginsWith(client); + + return client; +} diff --git a/packages/sdk/electron/src/ElectronDataManager.ts b/packages/sdk/electron/src/ElectronDataManager.ts new file mode 100644 index 0000000000..5cf6af37b5 --- /dev/null +++ b/packages/sdk/electron/src/ElectronDataManager.ts @@ -0,0 +1,212 @@ +import { + BaseDataManager, + Configuration, + ConnectionMode, + Context, + DataSourcePaths, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + makeRequestor, + Platform, +} from '@launchdarkly/js-client-sdk-common'; + +import { readFlagsFromBootstrap } from './bootstrap'; +import type { ElectronIdentifyOptions } from './ElectronIdentifyOptions'; +import type { ValidatedOptions } from './options'; + +const logTag = '[ElectronDataManager]'; + +export default class ElectronDataManager extends BaseDataManager { + // Not implemented yet. + protected networkAvailable: boolean = true; + protected connectionMode: ConnectionMode = 'streaming'; + + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly _electronConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + this.connectionMode = _electronConfig.initialConnectionMode; + } + + private _debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + if (this.closed) { + this._debugLog('Identify called after data manager was closed.'); + return; + } + this.context = context; + + // When bootstrap is provided, we will resolve the identify immediately. Then we will fallthrough to connect + // to the configured connection mode. + const electronIdentifyOptions = identifyOptions as ElectronIdentifyOptions | undefined; + if (electronIdentifyOptions?.bootstrap) { + this._finishIdentifyFromBootstrap(context, electronIdentifyOptions, identifyResolve); + } + // Bootstrap path already called resolve so we use this to prevent duplicate resolve calls. + const resolvedFromBootstrap = !!electronIdentifyOptions?.bootstrap; + + const offline = this.connectionMode === 'offline'; + // In offline mode we do not support waiting for results. + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults && !resolvedFromBootstrap) { + this._debugLog('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this._debugLog( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + if (this.connectionMode === 'offline') { + if (loadedFromCache) { + this._debugLog('Offline identify - using cached flags.'); + } else { + this._debugLog( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + if (!resolvedFromBootstrap) { + identifyResolve(); + } + } + } else { + // Context has been validated in LDClientImpl.identify + this._setupConnection(context, identifyResolve, identifyReject); + } + } + + private _finishIdentifyFromBootstrap( + context: Context, + electronIdentifyOptions: ElectronIdentifyOptions, + identifyResolve: () => void, + ): void { + let { bootstrapParsed } = electronIdentifyOptions; + if (!bootstrapParsed) { + bootstrapParsed = readFlagsFromBootstrap(this.logger, electronIdentifyOptions.bootstrap); + } + this.flagManager.setBootstrap(context, bootstrapParsed); + this._debugLog('Identify - Initialization completed from bootstrap'); + + identifyResolve(); + } + + private _setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const requestor = makeRequestor( + plainContextString, + this.config.serviceEndpoints, + this.getPollingPaths(), + this.platform.requests, + this.platform.encoding!, + this.baseHeaders, + [], + this.config.withReasons, + this.config.useReport, + ); + + this.updateProcessor?.close(); + switch (this.connectionMode) { + case 'streaming': + this.createStreamingProcessor( + rawContext, + context, + requestor, + identifyResolve, + identifyReject, + ); + break; + case 'polling': + this.createPollingProcessor( + rawContext, + context, + requestor, + identifyResolve, + identifyReject, + ); + break; + default: + break; + } + this.updateProcessor!.start(); + } + + setNetworkAvailability(available: boolean): void { + this.networkAvailable = available; + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (this.closed) { + this._debugLog('setting connection mode after data manager was closed'); + return; + } + + if (this.connectionMode === mode) { + this._debugLog(`setConnectionMode ignored. Mode is already '${mode}'.`); + return; + } + + this.connectionMode = mode; + this._debugLog(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + this.updateProcessor?.close(); + break; + case 'polling': + case 'streaming': + if (this.context) { + // identify will start the update processor + this._setupConnection(this.context); + } + + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + } + + getConnectionMode(): ConnectionMode { + return this.connectionMode; + } +} diff --git a/packages/sdk/electron/src/bootstrap.ts b/packages/sdk/electron/src/bootstrap.ts new file mode 100644 index 0000000000..0099863641 --- /dev/null +++ b/packages/sdk/electron/src/bootstrap.ts @@ -0,0 +1,47 @@ +import { Flag, ItemDescriptor, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +export function readFlagsFromBootstrap( + logger: LDLogger, + data: any, +): { [key: string]: ItemDescriptor } { + // If the bootstrap data came from an older server-side SDK, we'll have just a map of keys to values. + // Newer SDKs that have an allFlagsState method will provide an extra "$flagsState" key that contains + // the rest of the metadata we want. We do it this way for backward compatibility with older JS SDKs. + const keys = Object.keys(data); + const metadataKey = '$flagsState'; + const validKey = '$valid'; + const metadata = data[metadataKey]; + if (!metadata && keys.length) { + logger.warn( + 'LaunchDarkly client was initialized with bootstrap data that did not include flag' + + ' metadata. Events may not be sent correctly.', + ); + } + if (data[validKey] === false) { + logger.warn( + 'LaunchDarkly bootstrap data is not available because the back end could not read the flags.', + ); + } + const ret: { [key: string]: ItemDescriptor } = {}; + keys.forEach((key) => { + if (key !== metadataKey && key !== validKey) { + let flag: Flag; + if (metadata && metadata[key]) { + flag = { + value: data[key], + ...metadata[key], + }; + } else { + flag = { + value: data[key], + version: 0, + }; + } + ret[key] = { + version: flag.version, + flag, + }; + } + }); + return ret; +} diff --git a/packages/sdk/electron/src/index.ts b/packages/sdk/electron/src/index.ts index a1e40101ce..8bba83067a 100644 --- a/packages/sdk/electron/src/index.ts +++ b/packages/sdk/electron/src/index.ts @@ -1,3 +1,6 @@ +import type { LDContext } from '@launchdarkly/js-client-sdk-common'; + +import { makeClient } from './ElectronClient'; import type { ElectronIdentifyOptions } from './ElectronIdentifyOptions'; import type { ElectronOptions, LDProxyOptions, LDTLSOptions } from './ElectronOptions'; import type { LDClient, LDStartOptions } from './LDClient'; @@ -14,3 +17,21 @@ export type { LDStartOptions, LDTLSOptions, }; + +/** + * Creates the LaunchDarkly client in the Electron main process. The client is not ready until + * {@link LDClient.start} is called. + * + * @param credential The LaunchDarkly mobile key, or client-side ID when options.useClientSideId is true. + * @param initialContext The initial context used for the first identify when start() is called. + * @param options Optional configuration. + * @returns The client instance. Call client.start() before using variations or identify() for context changes. + * The returned client's identify() resolves to an {@link LDIdentifyResult} and does not throw. + */ +export function createClient( + credential: string, + initialContext: LDContext, + options: ElectronOptions = {}, +): LDClient { + return makeClient(credential, initialContext, options); +} diff --git a/packages/sdk/electron/src/options.ts b/packages/sdk/electron/src/options.ts new file mode 100644 index 0000000000..e753ea0dc2 --- /dev/null +++ b/packages/sdk/electron/src/options.ts @@ -0,0 +1,80 @@ +import { + ConnectionMode, + LDLogger, + LDOptions as LDOptionsBase, + OptionMessages, + TypeValidator, + TypeValidators, +} from '@launchdarkly/js-client-sdk-common'; + +import type { ElectronOptions, LDProxyOptions, LDTLSOptions } from './ElectronOptions'; +import type { LDPlugin } from './LDPlugin'; + +class ConnectionModeValidator implements TypeValidator { + is(u: unknown): u is ConnectionMode { + return u === 'offline' || u === 'streaming' || u === 'polling'; + } + getType(): string { + return 'ConnectionMode (offline | streaming | polling)'; + } +} + +export interface ValidatedOptions { + proxyOptions?: LDProxyOptions; + tlsParams?: LDTLSOptions; + enableEventCompression?: boolean; + initialConnectionMode: ConnectionMode; + plugins: LDPlugin[]; + enableIPC: boolean; + useClientSideId: boolean; +} + +const optDefaults: ValidatedOptions = { + proxyOptions: undefined, + tlsParams: undefined, + enableEventCompression: undefined, + initialConnectionMode: 'streaming', + plugins: [], + enableIPC: true, + useClientSideId: false, +}; + +const validators: { [Property in keyof ElectronOptions]: TypeValidator | undefined } = { + proxyOptions: TypeValidators.Object, + tlsParams: TypeValidators.Object, + enableEventCompression: TypeValidators.Boolean, + initialConnectionMode: new ConnectionModeValidator(), + plugins: TypeValidators.createTypeArray('LDPlugin[]', {}), + enableIPC: TypeValidators.Boolean, + useClientSideId: TypeValidators.Boolean, +}; + +export function filterToBaseOptions(opts: ElectronOptions): LDOptionsBase { + const baseOptions: LDOptionsBase = { ...opts }; + + // Remove any Electron specific configuration keys so we don't get warnings from + // the base implementation for unknown configuration. + Object.keys(optDefaults).forEach((key) => { + delete (baseOptions as any)[key]; + }); + return baseOptions; +} + +export default function validateOptions(opts: ElectronOptions, logger: LDLogger): ValidatedOptions { + const output: ValidatedOptions = { ...optDefaults }; + + Object.entries(validators).forEach((entry) => { + const [key, validator] = entry as [keyof ElectronOptions, TypeValidator]; + const value = opts[key]; + if (value !== undefined) { + if (validator.is(value)) { + // @ts-ignore The type inference has some problems here. + output[key as keyof ValidatedOptions] = value as any; + } else { + logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); + } + } + }); + + return output; +} diff --git a/packages/sdk/electron/temp_docs/MIGRATION.md b/packages/sdk/electron/temp_docs/MIGRATION.md new file mode 100644 index 0000000000..47d981ebe0 --- /dev/null +++ b/packages/sdk/electron/temp_docs/MIGRATION.md @@ -0,0 +1,71 @@ +# Migrating to this SDK + +Below are some breaking changes between this SDK major version +and the previous [Electron SDK](https://github.com/launchdarkly/electron-client-sdk) + +## SDK initialization (createClient and start) +> NOTE: LDClient **MUST** be ran in the main process. + +The main-process entry point is now **`createClient`** (replacing `initializeInMain`). Update any references accordingly. + +- **New signature:** `createClient(credential, initialContext, options)` — the **initial context is required** as the second argument, aligned with the browser SDK’s `createClient`. + +- **Must call `start()`:** The client is not ready until `start()` is called. After `createClient()`, the app must call `client.start()` (optionally with `LDStartOptions`: `timeout`, `bootstrap`, `identifyOptions`). The promise returned by `start()` resolves when the first identify completes (or times out or fails). + +- **No `identify()` before `start()`:** Calling `identify()` before `start()` is an error (logged and rejected). Use `identify()` only after `start()` has been called, for subsequent context changes. + +Example: + +```typescript +const client = createClient(launchDarklyMobileKey, launchDarklyUser, launchDarklyOptions); +await client.start(); +// Later, when changing context: +await client.identify(newUser); +``` + +## Identify flow (identify returns result, does not throw) + +`identify()` now returns a **promise that always resolves** to an `LDIdentifyResult` object. It does **not** throw; success or failure is indicated by the resolved value. + +- **Return type:** `Promise` +- **Result statuses:** + - `{ status: 'completed' }` — identification succeeded. + - `{ status: 'error', error: Error }` — identification failed (e.g. called before `start()`, invalid context, or network error). + - `{ status: 'timeout', timeout: number }` — identification did not complete within the configured timeout. + - `{ status: 'shed' }` — the identify was shed (e.g. when using `sheddable: true` and a newer identify superseded it). + +**Before (throwing):** + +```typescript +try { + await client.identify(newUser); + // success +} catch (err) { + // handle error or timeout +} +``` + +**After (result object):** + +```typescript +const result = await client.identify(newUser); +if (result.status === 'completed') { + // success +} else if (result.status === 'error') { + // result.error +} else if (result.status === 'timeout') { + // result.timeout (seconds) +} +``` + +You can still `await client.identify(context)` without inspecting the result if you do not need to handle errors or timeouts explicitly. + +## Use Mobile SDK key + +This SDK now uses the **mobile key** by default instead of the client-side ID. If you were previously passing a client-side ID to initialize the SDK, you should switch to your environment’s mobile key (from **Account settings** → **Projects** → your project → **Environments** → **Mobile key**). + +- **Continue using the client-side ID:** If you need to keep your existing behavior, pass `useClientSideId: true` in options when calling creating the SDK instance. This option is deprecated and may be removed in a future major version; prefer migrating to the mobile key when possible. + +- **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 diff --git a/packages/sdk/electron/temp_docs/README.md b/packages/sdk/electron/temp_docs/README.md new file mode 100644 index 0000000000..b9a6df3c45 --- /dev/null +++ b/packages/sdk/electron/temp_docs/README.md @@ -0,0 +1,5 @@ +# Temporary documentation + +This SDK version is not out of pre-production so it will not be documented in +our official documentation site. This is a temporary location for documentation +for this SDK. \ No newline at end of file diff --git a/packages/sdk/electron/temp_docs/ipc-bridge.md b/packages/sdk/electron/temp_docs/ipc-bridge.md new file mode 100644 index 0000000000..ac4b9b0d7c --- /dev/null +++ b/packages/sdk/electron/temp_docs/ipc-bridge.md @@ -0,0 +1,53 @@ +# IPC Bridge: Main Process and Renderer + +This document describes how the LaunchDarkly Electron SDK uses Inter-Process Communication (IPC) between the main process and browser windows so the renderer can interact with the LaunchDarkly client. It also explains why this architecture was chosen. + +## Overview and Architecture + +The real LaunchDarkly client (streaming connection, flag storage, identify, and events) lives **only in the main process**. The renderer never holds SDK state or talks to LaunchDarkly directly. All renderer interactions go through a bridge that forwards calls over IPC to the main process. + +The design has three layers: + +- **Main process**: [ElectronClient](src/ElectronClient.ts) is created via `initInMain(sdkKey, options)`. The first argument is the SDK key: by default the SDK uses a **mobile key**; for legacy client-side ID usage pass `useClientSideId: true` in options. When `enableIPC: true` (the default), it registers IPC handlers so the renderer can call through to the real client. + > NOTE: that when `enableIPC` is set to `false`, no IPC handlers will be registered in browser windows. This means + > that only the main process is able to use the LaunchDarkly SDK. + +- **Preload (bridge)**: [bridge/index.ts](src/bridge/index.ts) runs in the preload script. It builds an object that forwards each LD client method over IPC and exposes it via `contextBridge.exposeInMainWorld('ldClientBridge', ldClientBridge)`. +- **Renderer**: [ElectronRendererClient](src/renderer/ElectronRendererClient.ts) is created via `initInRenderer(sdkKey)` from `@launchdarkly/electron-client-sdk/renderer`. The value must match the key passed to `initInMain`. The renderer obtains the bridge from `window.ldClientBridge(sdkKey)` and delegates every call to the bridge (and thus to the main process over IPC). + +```mermaid +flowchart LR + subgraph Renderer [Renderer Process] + RClient[ElectronRendererClient] + end + subgraph Preload [Preload Script] + Bridge[ldClientBridge factory] + ContextBridge[contextBridge] + end + subgraph Main [Main Process] + MainClient[ElectronClient] + IPCHandlers[IPC Handlers] + end + RClient -->|"window.ldClientBridge(id)"| ContextBridge + ContextBridge --> Bridge + Bridge <-->|"ipcRenderer.sendSync / invoke / postMessage"| IPCHandlers + IPCHandlers --> MainClient +``` + +> See [electron docs](https://www.electronjs.org/docs/latest/tutorial/ipc) for more information. + +## IPC Channel Naming and Registration + +**Channel pattern**: All channels use the form `ld:${sdkKey}:${methodName}`. Examples: `ld:my-mobile-key:variation`, `ld:my-mobile-key:identify`. This namespaces by the SDK key so multiple LD clients could coexist (e.g. different environments). + +**Where handlers are registered**: Handlers are registered only in the main process, inside `ElectronClient._registerInMain()`, and only when the client is created with `enableIPC: true`. The preload script does not register any handlers; it only sends or invokes on these channels. + +**Event handlers (addEventHandler / removeEventHandler)**: Renderer `client.on()` and `client.off()` are implemented via the bridge’s `addEventHandler` and `removeEventHandler`. For `addEventHandler`, the renderer creates a `MessageChannel`, transfers one port to the main process via `postMessage`, and the main process registers a listener on the real client that forwards event args over that port back to the renderer. For `removeEventHandler`, the renderer sends `eventName` and `callbackId` synchronously; the main process unregisters the listener, closes the port, and returns success or failure. + +**Bridge registration**: The bridge is exposed as a **side effect on import**. The application’s preload script imports `@launchdarkly/electron-client-sdk/bridge`. That module, when loaded, calls `contextBridge.exposeInMainWorld('ldClientBridge', ldClientBridge)`. There is no explicit “register” call. To enable the bridge, the app must (1) import the bridge in the preload script and (2) set that preload script in `webPreferences.preload` when creating the `BrowserWindow`. + +## Security and Context Isolation + +**contextBridge**: The renderer never receives a reference to `ipcRenderer` or other Node/Electron APIs. Only the explicitly exposed `ldClientBridge(sdkKey)` function is available in the renderer, and that function returns an object that exposes only the LD client API (variations, identify, events, etc.). There is no `require`, no `process`, and no raw IPC in the renderer. + +**Preload as the only bridge**: All IPC is initiated from the preload script. The bridge module is the single place that translates between renderer calls and IPC; this keeps the allowed surface small and auditable. From 80818b9fde8ca66f689dc03c4457470f05a7f54f Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 13 Feb 2026 15:48:43 -0600 Subject: [PATCH 2/3] chore: addressing bot comment --- packages/sdk/electron/src/ElectronClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/electron/src/ElectronClient.ts b/packages/sdk/electron/src/ElectronClient.ts index 6e0b923ff7..65471120c7 100644 --- a/packages/sdk/electron/src/ElectronClient.ts +++ b/packages/sdk/electron/src/ElectronClient.ts @@ -192,7 +192,7 @@ export class ElectronClient extends LDClientImpl { this._startPromise = this.promiseWithTimeout(this.initializedPromise!, options?.timeout ?? 5); - this.identify(this._initialContext, identifyOptions); + this.identifyResult(this._initialContext, identifyOptions); return this._startPromise; } From 50b7675fa01d5ec7ed8df4378b4632239be669fa Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 13 Feb 2026 16:33:57 -0600 Subject: [PATCH 3/3] test: renaming test --- ...nLDMainClient.plugin.test.ts => ElectronClient.plugin.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/sdk/electron/__tests__/{ElectronLDMainClient.plugin.test.ts => ElectronClient.plugin.test.ts} (100%) diff --git a/packages/sdk/electron/__tests__/ElectronLDMainClient.plugin.test.ts b/packages/sdk/electron/__tests__/ElectronClient.plugin.test.ts similarity index 100% rename from packages/sdk/electron/__tests__/ElectronLDMainClient.plugin.test.ts rename to packages/sdk/electron/__tests__/ElectronClient.plugin.test.ts