From 2c7ac96f02c9ce74acc2531af603bff37f0c7452 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 24 Jan 2024 22:23:25 -0800 Subject: [PATCH 01/33] feat: Implement common support for auto environment attributes. --- .../shared/common/src/api/platform/AutoEnv.ts | 37 +++++++++++++++++++ .../shared/common/src/api/platform/Info.ts | 14 +++++++ .../shared/common/src/api/platform/index.ts | 5 ++- .../stream/StreamingProcessor.test.ts | 15 +++++--- packages/shared/common/src/utils/clone.ts | 4 ++ 5 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 packages/shared/common/src/api/platform/AutoEnv.ts diff --git a/packages/shared/common/src/api/platform/AutoEnv.ts b/packages/shared/common/src/api/platform/AutoEnv.ts new file mode 100644 index 000000000..536374d14 --- /dev/null +++ b/packages/shared/common/src/api/platform/AutoEnv.ts @@ -0,0 +1,37 @@ +interface AutoEnvCommon { + /** + * Unique key for the context kind. + */ + key: string; + + /** + * Version of the environment attributes schema being used. + */ + envAttributesVersion: string; +} + +export interface LDApplication extends AutoEnvCommon { + /** + * Unique identifier of the application. + */ + id?: string; + name?: string; + version?: string; + versionName?: string; + locale?: string; +} + +export interface LDDevice extends AutoEnvCommon { + manufacturer?: string; + model?: string; + storageBytes?: string; + memoryBytes?: string; + os?: { + /** + * The family of operating system. + */ + family: string; + name: string; + version: string; + }; +} diff --git a/packages/shared/common/src/api/platform/Info.ts b/packages/shared/common/src/api/platform/Info.ts index 9b0bf40f6..9a9895ff5 100644 --- a/packages/shared/common/src/api/platform/Info.ts +++ b/packages/shared/common/src/api/platform/Info.ts @@ -1,3 +1,5 @@ +import type { LDApplication, LDDevice } from './AutoEnv'; + /** * Information about the platform of the SDK and the environment it is executing. */ @@ -31,6 +33,18 @@ export interface PlatformData { * Any additional attributes associated with the platform. */ additional?: Record; + + /** + * Additional information about the executing environment. Should be populated + * when available. Not all platforms will have this data. + */ + ld_application?: LDApplication; + + /** + * Device hardware information. Should be populated when available. Not all + * platforms will have this data. + */ + ld_device?: LDDevice; } export interface SdkData { diff --git a/packages/shared/common/src/api/platform/index.ts b/packages/shared/common/src/api/platform/index.ts index ff46f3af4..bf787b595 100644 --- a/packages/shared/common/src/api/platform/index.ts +++ b/packages/shared/common/src/api/platform/index.ts @@ -1,8 +1,9 @@ -export * from './Encoding'; +export * from './AutoEnv'; export * from './Crypto'; +export * from './Encoding'; +export * from './EventSource'; export * from './Filesystem'; export * from './Info'; export * from './Platform'; export * from './Requests'; -export * from './EventSource'; export * from './Storage'; diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts index cf2e397eb..9f53f99b9 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts @@ -1,18 +1,15 @@ import { basicPlatform, clientContext, logger } from '@launchdarkly/private-js-mocks'; -import { EventName, ProcessStreamResponse } from '../../api'; +import { EventName, Info, ProcessStreamResponse } from '../../api'; import { LDStreamProcessor } from '../../api/subsystem'; import { LDStreamingError } from '../../errors'; +import { ApplicationTags, ServiceEndpoints } from '../../options'; import { defaultHeaders } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; import StreamingProcessor from './StreamingProcessor'; const dateNowString = '2023-08-10'; const sdkKey = 'my-sdk-key'; -const { - basicConfiguration: { serviceEndpoints, tags }, - platform: { info }, -} = clientContext; const event = { data: { flags: { @@ -33,6 +30,9 @@ const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ }); describe('given a stream processor with mock event source', () => { + let serviceEndpoints: ServiceEndpoints; + let tags: ApplicationTags; + let info: Info; let streamingProcessor: LDStreamProcessor; let diagnosticsManager: DiagnosticsManager; let listeners: Map; @@ -53,8 +53,11 @@ describe('given a stream processor with mock event source', () => { beforeEach(() => { mockErrorHandler = jest.fn(); + ({ + basicConfiguration: { serviceEndpoints, tags }, + platform: { info }, + } = clientContext); clientContext.basicConfiguration.logger = logger; - basicPlatform.requests = { createEventSource: jest.fn((streamUri: string, options: any) => { mockEventSource = createMockEventSource(streamUri, options); diff --git a/packages/shared/common/src/utils/clone.ts b/packages/shared/common/src/utils/clone.ts index 14e19d8e3..a52cbac15 100644 --- a/packages/shared/common/src/utils/clone.ts +++ b/packages/shared/common/src/utils/clone.ts @@ -1,3 +1,7 @@ export default function clone(obj: any) { + if (obj === undefined || obj === null) { + return obj; + } + return JSON.parse(JSON.stringify(obj)) as T; } From 53d4358a600ead0b5020f8a4a9a42a430f49dd58 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 24 Jan 2024 23:35:45 -0800 Subject: [PATCH 02/33] feat: Implement common client side support for auto environment attributes. --- packages/shared/mocks/README.md | 106 ++++++++- packages/shared/mocks/package.json | 6 + packages/shared/mocks/src/clientContext.ts | 32 +-- packages/shared/mocks/src/crypto.ts | 23 ++ packages/shared/mocks/src/index.ts | 9 +- packages/shared/mocks/src/mockFetch.ts | 2 +- packages/shared/mocks/src/platform.ts | 53 +++-- packages/shared/mocks/src/setupMocks.ts | 12 + packages/shared/sdk-client/jest.config.json | 2 +- .../src/LDClientImpl.storage.test.ts | 53 ++++- .../sdk-client/src/LDClientImpl.test.ts | 66 +++++- .../shared/sdk-client/src/LDClientImpl.ts | 10 +- .../shared/sdk-client/src/api/LDOptions.ts | 13 +- .../src/configuration/Configuration.test.ts | 1 + .../src/configuration/Configuration.ts | 3 + .../src/configuration/validators.ts | 1 + .../src/evaluation/fetchFlags.test.ts | 9 +- .../sdk-client/src/utils/addAutoEnv.test.ts | 208 ++++++++++++++++++ .../shared/sdk-client/src/utils/addAutoEnv.ts | 87 ++++++++ .../sdk-client/src/utils/ensureKey.test.ts | 18 +- .../shared/sdk-client/src/utils/ensureKey.ts | 23 +- .../src/utils/getOrGenerateKey.test.ts | 38 ++++ .../sdk-client/src/utils/getOrGenerateKey.ts | 15 ++ packages/shared/sdk-client/src/utils/index.ts | 3 +- 24 files changed, 711 insertions(+), 82 deletions(-) create mode 100644 packages/shared/mocks/src/crypto.ts create mode 100644 packages/shared/mocks/src/setupMocks.ts create mode 100644 packages/shared/sdk-client/src/utils/addAutoEnv.test.ts create mode 100644 packages/shared/sdk-client/src/utils/addAutoEnv.ts create mode 100644 packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts create mode 100644 packages/shared/sdk-client/src/utils/getOrGenerateKey.ts diff --git a/packages/shared/mocks/README.md b/packages/shared/mocks/README.md index 730bb4fee..8c502a112 100644 --- a/packages/shared/mocks/README.md +++ b/packages/shared/mocks/README.md @@ -2,9 +2,111 @@ [![Actions Status][mocks-ci-badge]][mocks-ci] -**Internal use only.** +> [!CAUTION] +> Internal use only. +> This project contains JavaScript mocks that are consumed in unit tests in client-side and server-side JavaScript SDKs. -This project contains JavaScript mocks that are consumed in unit tests in client-side and server-side JavaScript SDKs. +## Installation + +This package is not published publicly. To use it internally, add the following line to your project's package.json +devDependencies. yarn workspace has been setup to recognize this package so this dependency should automatically work: + +```bash + "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", + ... +``` + +Then in your jest config add `@launchdarkly/private-js-mocks/setup` to setupFilesAfterEnv: + +```js +// jest.config.js or jest.config.json +module.exports = { + setupFilesAfterEnv: ['@launchdarkly/private-js-mocks/setup'], + ... +} +``` + +## Usage + +> [!IMPORTANT] +> basicPlatform and clientContext must be used inside a test because it's setup before each test. + +- `basicPlatform`: a concrete but basic implementation of [Platform](https://github.com/launchdarkly/js-core/blob/main/packages/shared/common/src/api/platform/Platform.ts). This is setup beforeEach so it must be used inside a test. + +- `clientContext`: ClientContext object including `basicPlatform` above. This is setup beforeEach so it must be used inside a test as well. + +- `hasher`: a Hasher object returned by `Crypto.createHash`. All functions in this object are jest mocks. This is exported + separately as a top level export because `Crypto` does not expose this publicly and we want to respect that. + +## Example + +```tsx +import { basicPlatform, clientContext, hasher } from '@launchdarkly/private-js-mocks'; + +// DOES NOT WORK: crypto is undefined because basicPlatform must be inside a test +// because it's setup by the package in beforeEach. +const { crypto } = basicPlatform; // DON'T DO THIS HERE + +// DOES NOT WORK: clientContext must be used inside a test. Otherwise all properties +// of it will be undefined. +const { + basicConfiguration: { serviceEndpoints, tags }, + platform: { info }, +} = clientContext; // DON'T DO THIS HERE + +describe('button', () => { + // DOES NOT WORK: again must be inside an actual test. At the test suite, + // level, beforeEach has not been run. + const { crypto } = basicPlatform; // DON'T DO THIS HERE + + // DO THIS + let crypto: Crypto; + let info: Info; + let serviceEndpoints: ServiceEndpoints; + let tags: ApplicationTags; + + beforeEach(() => { + // WORKS: basicPlatform and clientContext have been setup by the package. + ({ crypto, info } = basicPlatform); + + // WORKS + ({ + basicConfiguration: { serviceEndpoints, tags }, + platform: { info }, + } = clientContext); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('hashes the correct string', () => { + // arrange + const bucketer = new Bucketer(crypto); + + // act + const [bucket, hadContext] = bucketer.bucket(); + + // assert + // WORKS + expect(crypto.createHash).toHaveBeenCalled(); + + // WORKS: alternatively you can just use the full path to access the properties + // of basicPlatform + expect(basicPlatform.crypto.createHash).toHaveBeenCalled(); + + // GOTCHA: hasher is a separte import from crypto to respect + // the public Crypto interface. + expect(hasher.update).toHaveBeenCalledWith(expected); + expect(hasher.digest).toHaveBeenCalledWith('hex'); + }); +}); +``` + +## Developing this package + +If you make changes to this package, you'll need to run `yarn build` in the `mocks` directory for changes to take effect. ## Contributing diff --git a/packages/shared/mocks/package.json b/packages/shared/mocks/package.json index aa8ceb63f..4981ad1a6 100644 --- a/packages/shared/mocks/package.json +++ b/packages/shared/mocks/package.json @@ -5,6 +5,12 @@ "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./setup": { + "default": "./dist/setupMocks.js" + } + }, "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/common", "repository": { "type": "git", diff --git a/packages/shared/mocks/src/clientContext.ts b/packages/shared/mocks/src/clientContext.ts index 90baaefe7..75cf2e649 100644 --- a/packages/shared/mocks/src/clientContext.ts +++ b/packages/shared/mocks/src/clientContext.ts @@ -1,20 +1,22 @@ import type { ClientContext } from '@common'; -import basicPlatform from './platform'; +import { basicPlatform } from './platform'; -const clientContext: ClientContext = { - basicConfiguration: { - sdkKey: 'testSdkKey', - serviceEndpoints: { - events: '', - polling: '', - streaming: 'https://mockstream.ld.com', - diagnosticEventPath: '/diagnostic', - analyticsEventPath: '/bulk', - includeAuthorizationHeader: true, +// eslint-disable-next-line import/no-mutable-exports +export let clientContext: ClientContext; +export const setupClientContext = () => { + clientContext = { + basicConfiguration: { + sdkKey: 'testSdkKey', + serviceEndpoints: { + events: '', + polling: '', + streaming: 'https://mockstream.ld.com', + diagnosticEventPath: '/diagnostic', + analyticsEventPath: '/bulk', + includeAuthorizationHeader: true, + }, }, - }, - platform: basicPlatform, + platform: basicPlatform, + }; }; - -export default clientContext; diff --git a/packages/shared/mocks/src/crypto.ts b/packages/shared/mocks/src/crypto.ts new file mode 100644 index 000000000..22e800adc --- /dev/null +++ b/packages/shared/mocks/src/crypto.ts @@ -0,0 +1,23 @@ +import type { Hasher } from '@common'; + +// eslint-disable-next-line import/no-mutable-exports +export let hasher: Hasher; + +export const setupCrypto = () => { + let counter = 0; + hasher = { + update: jest.fn(), + digest: jest.fn(() => '1234567890123456'), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +}; diff --git a/packages/shared/mocks/src/index.ts b/packages/shared/mocks/src/index.ts index beceb6f47..8c6927ba7 100644 --- a/packages/shared/mocks/src/index.ts +++ b/packages/shared/mocks/src/index.ts @@ -1,18 +1,17 @@ -import clientContext from './clientContext'; +import { clientContext } from './clientContext'; import ContextDeduplicator from './contextDeduplicator'; -import { crypto, hasher } from './hasher'; +import { hasher } from './crypto'; import logger from './logger'; import mockFetch from './mockFetch'; -import basicPlatform from './platform'; +import { basicPlatform } from './platform'; import { MockStreamingProcessor, setupMockStreamingProcessor } from './streamingProcessor'; export { basicPlatform, clientContext, + hasher, mockFetch, - crypto, logger, - hasher, ContextDeduplicator, MockStreamingProcessor, setupMockStreamingProcessor, diff --git a/packages/shared/mocks/src/mockFetch.ts b/packages/shared/mocks/src/mockFetch.ts index 0ae07f804..aa0013f14 100644 --- a/packages/shared/mocks/src/mockFetch.ts +++ b/packages/shared/mocks/src/mockFetch.ts @@ -1,6 +1,6 @@ import { Response } from '@common'; -import basicPlatform from './platform'; +import { basicPlatform } from './platform'; const createMockResponse = (remoteJson: any, statusCode: number) => { const response: Response = { diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/mocks/src/platform.ts index ed08a80c9..24bcc0016 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -1,14 +1,14 @@ import type { Encoding, Info, Platform, PlatformData, Requests, SdkData, Storage } from '@common'; -import { crypto } from './hasher'; +import { setupCrypto } from './crypto'; const encoding: Encoding = { btoa: (s: string) => Buffer.from(s).toString('base64'), }; -const info: Info = { - platformData(): PlatformData { - return { +const setupInfo = () => ({ + platformData: jest.fn( + (): PlatformData => ({ os: { name: 'An OS', version: '1.0.1', @@ -18,18 +18,31 @@ const info: Info = { additional: { nodeVersion: '42', }, - }; - }, - sdkData(): SdkData { - return { + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: { name: 'Another OS', version: '99', family: 'orange' }, + manufacturer: 'coconut', + }, + }), + ), + sdkData: jest.fn( + (): SdkData => ({ name: 'An SDK', version: '2.0.2', userAgentBase: 'TestUserAgent', wrapperName: 'Rapper', wrapperVersion: '1.2.3', - }; - }, -}; + }), + ), +}); const requests: Requests = { fetch: jest.fn(), @@ -42,12 +55,14 @@ const storage: Storage = { clear: jest.fn(), }; -const basicPlatform: Platform = { - encoding, - info, - crypto, - requests, - storage, +// eslint-disable-next-line import/no-mutable-exports +export let basicPlatform: Platform; +export const setupBasicPlatform = () => { + basicPlatform = { + encoding, + info: setupInfo(), + crypto: setupCrypto(), + requests, + storage, + }; }; - -export default basicPlatform; diff --git a/packages/shared/mocks/src/setupMocks.ts b/packages/shared/mocks/src/setupMocks.ts new file mode 100644 index 000000000..9a663f5fc --- /dev/null +++ b/packages/shared/mocks/src/setupMocks.ts @@ -0,0 +1,12 @@ +import { setupClientContext } from './clientContext'; +import { setupBasicPlatform } from './platform'; + +beforeAll(() => { + setupBasicPlatform(); + setupClientContext(); +}); + +beforeEach(() => { + setupBasicPlatform(); + setupClientContext(); +}); diff --git a/packages/shared/sdk-client/jest.config.json b/packages/shared/sdk-client/jest.config.json index 65ddc27dd..c9d0d3b3d 100644 --- a/packages/shared/sdk-client/jest.config.json +++ b/packages/shared/sdk-client/jest.config.json @@ -6,5 +6,5 @@ "testEnvironment": "jsdom", "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], "collectCoverageFrom": ["src/**/*.ts"], - "setupFilesAfterEnv": ["./jest-setupFilesAfterEnv.ts"] + "setupFilesAfterEnv": ["@launchdarkly/private-js-mocks/setup"] } diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 13b295d3b..563db1cad 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -5,6 +5,7 @@ import LDEmitter from './api/LDEmitter'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; import { DeleteFlag, Flag, Flags, PatchFlag } from './types'; +import { toMulti } from './utils/addAutoEnv'; jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); @@ -76,7 +77,11 @@ describe('sdk-client storage', () => { .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); - ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); + ldc = new LDClientImpl(testSdkKey, basicPlatform, { + autoEnvAttributes: false, + logger, + sendEvents: false, + }); // @ts-ignore emitter = ldc.emitter; @@ -115,6 +120,52 @@ describe('sdk-client storage', () => { }); }); + test('initialize from storage succeeds with auto env', async () => { + ldc = new LDClientImpl(testSdkKey, basicPlatform, { + logger, + sendEvents: false, + }); + // @ts-ignore + emitter = ldc.emitter; + jest.spyOn(emitter as LDEmitter, 'emit'); + + const allFlags = await identifyGetAllFlags(true, defaultPutResponse); + + expect(basicPlatform.storage.get).toHaveBeenLastCalledWith( + expect.stringMatching(/org:Testy Pizza$/), + ); + + // 'change' should not have been emitted + expect(emitter.emit).toHaveBeenCalledTimes(3); + expect(emitter.emit).toHaveBeenNthCalledWith( + 1, + 'identifying', + expect.objectContaining(toMulti(context)), + ); + expect(emitter.emit).toHaveBeenNthCalledWith( + 2, + 'change', + expect.objectContaining(toMulti(context)), + defaultFlagKeys, + ); + expect(emitter.emit).toHaveBeenNthCalledWith( + 3, + 'error', + expect.objectContaining(toMulti(context)), + expect.objectContaining({ message: 'test-error' }), + ); + expect(allFlags).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + test('no storage, cold start from streamer', async () => { // fake previously cached flags even though there's no storage for this context // @ts-ignore diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 57524bf9c..1859997ce 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -1,5 +1,10 @@ import { clone, LDContext } from '@launchdarkly/js-sdk-common'; -import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; +import { + basicPlatform, + hasher, + logger, + setupMockStreamingProcessor, +} from '@launchdarkly/private-js-mocks'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; @@ -19,9 +24,23 @@ jest.mock('@launchdarkly/js-sdk-common', () => { }; }); -const { crypto } = basicPlatform; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; +const autoEnv = { + ld_application: { + key: 'digested1', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: 'random1', + envAttributesVersion: '1.0', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, +}; let ldc: LDClientImpl; let defaultPutResponse: Flags; @@ -29,7 +48,8 @@ describe('sdk-client object', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); setupMockStreamingProcessor(false, defaultPutResponse); - crypto.randomUUID.mockReturnValueOnce('random1'); + basicPlatform.crypto.randomUUID.mockReturnValue('random1'); + hasher.digest.mockReturnValue('digested1'); ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); jest @@ -129,7 +149,30 @@ describe('sdk-client object', () => { const c = ldc.getContext(); const all = ldc.allFlags(); - expect(carContext).toEqual(c); + expect(c).toEqual({ + kind: 'multi', + car: { key: 'mazda-cx7' }, + ...autoEnv, + }); + expect(all).toMatchObject({ + 'dev-test-flag': false, + }); + }); + + test('identify success without auto env', async () => { + defaultPutResponse['dev-test-flag'].value = false; + const carContext: LDContext = { kind: 'car', key: 'mazda-cx7' }; + ldc = new LDClientImpl(testSdkKey, basicPlatform, { + autoEnvAttributes: false, + logger, + sendEvents: false, + }); + + await ldc.identify(carContext); + const c = ldc.getContext(); + const all = ldc.allFlags(); + + expect(c).toEqual(carContext); expect(all).toMatchObject({ 'dev-test-flag': false, }); @@ -143,18 +186,21 @@ describe('sdk-client object', () => { const c = ldc.getContext(); const all = ldc.allFlags(); - expect(c!.key).toEqual('random1'); + expect(c).toEqual({ + kind: 'multi', + car: { anonymous: true, key: 'random1' }, + ...autoEnv, + }); expect(all).toMatchObject({ 'dev-test-flag': false, }); }); test('identify error invalid context', async () => { - // @ts-ignore - const carContext: LDContext = { kind: 'car', key: undefined }; + const carContext: LDContext = { kind: 'car', key: '' }; - await expect(ldc.identify(carContext)).rejects.toThrowError(/no key/); - expect(logger.error).toBeCalledTimes(1); + await expect(ldc.identify(carContext)).rejects.toThrow(/no key/); + expect(logger.error).toHaveBeenCalledTimes(1); expect(ldc.getContext()).toBeUndefined(); }); @@ -166,7 +212,7 @@ describe('sdk-client object', () => { code: 401, message: 'test-error', }); - expect(logger.error).toBeCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); expect(ldc.getContext()).toBeUndefined(); }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 7f76ab946..3c06ac079 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -24,7 +24,7 @@ import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; import { DeleteFlag, Flags, PatchFlag } from './types'; -import { calculateFlagChanges, ensureKey } from './utils'; +import { addAutoEnv, calculateFlagChanges, ensureKey } from './utils'; const { createErrorEvaluationDetail, createSuccessEvaluationDetail, ClientMessages, ErrorKinds } = internal; @@ -193,7 +193,7 @@ export default class LDClientImpl implements LDClient { */ protected createStreamUriPath(_context: LDContext): string { throw new Error( - 'createStreamUriPath not implemented. client sdks must implement createStreamUriPath for streamer to work', + 'createStreamUriPath not implemented. Client sdks must implement createStreamUriPath for streamer to work', ); } @@ -232,7 +232,11 @@ export default class LDClientImpl implements LDClient { // TODO: implement secure mode async identify(pristineContext: LDContext, _hash?: string): Promise { - const context = await ensureKey(pristineContext, this.platform); + let context = await ensureKey(pristineContext, this.platform); + + if (this.config.autoEnvAttributes) { + context = await addAutoEnv(context, this.platform, this.config); + } const checkedContext = Context.fromLDContext(context); if (!checkedContext.valid) { diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 06484397c..9d85323e8 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -24,7 +24,6 @@ export interface LDOptions { * Example: `authentication-service` */ id?: string; - /** * A unique identifier representing the version of the application where the LaunchDarkly SDK is running. * @@ -36,6 +35,18 @@ export interface LDOptions { version?: string; }; + /** + * Enable / disable Auto environment attributes. When enabled, the SDK will automatically + * provide data about the mobile environment where the application is running. This data makes it simpler to target + * your mobile customers based on application name or version, or on device characteristics including manufacturer, + * model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. To learn more, + * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). + * for more documentation. + * + * The default is true. + */ + autoEnvAttributes?: boolean; + /** * The base uri for the LaunchDarkly server. * diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts index 6a569b296..3305f6395 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -12,6 +12,7 @@ describe('Configuration', () => { expect(config).toMatchObject({ allAttributesPrivate: false, + autoEnvAttributes: true, baseUri: 'https://sdk.launchdarkly.com', capacity: 100, diagnosticOptOut: false, diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 52fe58799..e50f37f5a 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -28,6 +28,7 @@ export default class Configuration { public readonly flushInterval = 2; public readonly streamInitialReconnectDelay = 1; + public readonly autoEnvAttributes = true; public readonly allAttributesPrivate = false; public readonly diagnosticOptOut = false; public readonly withReasons = false; @@ -41,6 +42,8 @@ export default class Configuration { public readonly tags: ApplicationTags; public readonly application?: { id?: string; version?: string }; public readonly bootstrap?: 'localStorage' | LDFlagSet; + + // TODO: implement requestHeaderTransform public readonly requestHeaderTransform?: (headers: Map) => Map; public readonly stream?: boolean; public readonly hash?: string; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index a48f9c5cf..9688e99c8 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -25,6 +25,7 @@ const validators: Record = { flushInterval: TypeValidators.numberWithMin(2), streamInitialReconnectDelay: TypeValidators.numberWithMin(0), + autoEnvAttributes: TypeValidators.Boolean, allAttributesPrivate: TypeValidators.Boolean, diagnosticOptOut: TypeValidators.Boolean, withReasons: TypeValidators.Boolean, diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts index 2f6bbc9a9..2b3c3df8b 100644 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.test.ts @@ -16,9 +16,10 @@ describe('fetchFeatures', () => { }; let config: Configuration; - const platformFetch = basicPlatform.requests.fetch as jest.Mock; + let platformFetch: jest.Mock; beforeEach(() => { + platformFetch = basicPlatform.requests.fetch as jest.Mock; mockFetch(mockResponse); config = new Configuration(); }); @@ -30,7 +31,7 @@ describe('fetchFeatures', () => { test('get', async () => { const json = await fetchFlags(sdkKey, context, config, basicPlatform); - expect(platformFetch).toBeCalledWith( + expect(platformFetch).toHaveBeenCalledWith( 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9', { method: 'GET', @@ -45,7 +46,7 @@ describe('fetchFeatures', () => { config = new Configuration({ withReasons: true }); const json = await fetchFlags(sdkKey, context, config, basicPlatform); - expect(platformFetch).toBeCalledWith( + expect(platformFetch).toHaveBeenCalledWith( 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9?withReasons=true', { method: 'GET', @@ -59,7 +60,7 @@ describe('fetchFeatures', () => { config = new Configuration({ hash: 'test-hash', withReasons: false }); const json = await fetchFlags(sdkKey, context, config, basicPlatform); - expect(platformFetch).toBeCalledWith( + expect(platformFetch).toHaveBeenCalledWith( 'https://sdk.launchdarkly.com/sdk/evalx/testSdkKey1/contexts/eyJraW5kIjoidXNlciIsImtleSI6InRlc3QtdXNlci1rZXktMSJ9?h=test-hash', { method: 'GET', diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts new file mode 100644 index 000000000..fec9407dc --- /dev/null +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts @@ -0,0 +1,208 @@ +import { Info, type LDContext, LDUser } from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + +import Configuration from '../configuration'; +import { addApplicationInfo, addAutoEnv, addDeviceInfo, toMulti } from './addAutoEnv'; + +describe('addAutoEnv', () => { + let crypto: Crypto; + let info: Info; + + beforeEach(() => { + ({ crypto, info } = basicPlatform); + (crypto.randomUUID as jest.Mock).mockResolvedValue('test-device-key-1'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('toMulti', () => { + const singleContext = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; + const multi = toMulti(singleContext); + + expect(multi).toEqual({ kind: 'multi', user: { key: 'test-user-key-1', name: 'bob' } }); + }); + + test('LDUser is unsupported', async () => { + const config = new Configuration(); + // const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; + const user: LDUser = { key: 'legacy-user-key', name: 'bob' }; + const result = await addAutoEnv(user, basicPlatform, config); + + expect(result).toEqual(user); + }); + + test('single kind should be converted to multi', async () => { + const config = new Configuration(); + const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; + + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, + user: { key: 'test-user-key-1', name: 'bob' }, + }); + }); + + test('multi kind', async () => { + const config = new Configuration(); + const context: LDContext = { + kind: 'multi', + user: { key: 'test-user-key-1', name: 'bob' }, + org: { key: 'test-org-key-1', name: 'Best company' }, + }; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + user: { key: 'test-user-key-1', name: 'bob' }, + org: { key: 'test-org-key-1', name: 'Best company' }, + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, + }); + }); + + test('addApplicationInfo with config application id, version', () => { + const config = new Configuration({ + application: { id: 'com.from-config.ld', version: '2.2.2' }, + }); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toEqual({ + envAttributesVersion: '1.0', + id: 'com.from-config.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '2.2.2', + }); + }); + + test('addApplicationInfo with auto env application id, name, version', () => { + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toEqual({ + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }); + }); + + test('addApplicationInfo with sdk data name, version', () => { + const platformData = info.platformData(); + delete platformData.ld_application; + delete platformData.ld_device; + info.platformData = jest.fn().mockReturnValueOnce(platformData); + info.sdkData = jest.fn().mockReturnValueOnce({ + name: 'Name from sdk data', + version: '3.3.3', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '9.9.9', + }); + + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toEqual({ + envAttributesVersion: '1.0', + id: 'Name from sdk data', + key: '1234567890123456', + name: 'Name from sdk data', + version: '3.3.3', + }); + }); + + test('addApplicationInfo with sdkData wrapperName, wrapperVersion', () => { + const platformData = info.platformData(); + delete platformData.ld_application; + delete platformData.ld_device; + info.platformData = jest.fn().mockReturnValueOnce(platformData); + info.sdkData = jest.fn().mockReturnValueOnce({ + name: '', + version: '', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '9.9.9', + }); + + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toEqual({ + envAttributesVersion: '1.0', + id: 'Rapper', + key: '1234567890123456', + name: 'Rapper', + version: '9.9.9', + }); + }); + + test('addDeviceInfo with platformData os name, version', async () => { + const ldDevice = await addDeviceInfo(basicPlatform); + + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }); + }); + + test('addDeviceInfo with auto env os name, version', async () => { + const platformData = info.platformData(); + delete platformData.os; + info.platformData = jest.fn().mockReturnValueOnce(platformData); + + const ldDevice = await addDeviceInfo(basicPlatform); + + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'Another OS', version: '99', family: 'orange' }, + }); + }); + + test('addDeviceInfo with auto env os name, version when platform data are empty strings', async () => { + const platformData = info.platformData(); + platformData.os = { name: '', version: '' }; + info.platformData = jest.fn().mockReturnValueOnce(platformData); + + const ldDevice = await addDeviceInfo(basicPlatform); + + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'Another OS', version: '99', family: 'orange' }, + }); + }); +}); diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.ts new file mode 100644 index 000000000..65d1e128b --- /dev/null +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + clone, + internal, + LDApplication, + LDContext, + LDDevice, + LDMultiKindContext, + LDSingleKindContext, + LDUser, + Platform, +} from '@launchdarkly/js-sdk-common'; + +import Configuration from '../configuration'; +import { getOrGenerateKey } from './getOrGenerateKey'; + +const { isLegacyUser, isSingleKind } = internal; +const defaultAutoEnvSchemaVersion = '1.0'; + +export const toMulti = (c: LDSingleKindContext) => { + const { kind, ...contextCommon } = c; + + return { + kind: 'multi', + [kind]: contextCommon, + }; +}; + +/** + * Clones the LDApplication object and populates the key, id and version fields. + * + * @param crypto + * @param info + * @param config + * @return An LDApplication object with populated key, id and version. + */ +export const addApplicationInfo = ({ crypto, info }: Platform, config: Configuration) => { + const { name, version, wrapperName, wrapperVersion } = info.sdkData(); + const { ld_application } = info.platformData(); + + const ldApplication = clone(ld_application) ?? {}; + ldApplication.id = config.application?.id || ldApplication?.id || name || wrapperName; + ldApplication.name = ldApplication?.name || name || wrapperName; + ldApplication.version = + config.application?.version || ldApplication.version || version || wrapperVersion; + + const hasher = crypto.createHash('sha256'); + hasher.update(ldApplication.id!); + ldApplication.key = hasher.digest('base64'); + ldApplication.envAttributesVersion = + ldApplication.envAttributesVersion || defaultAutoEnvSchemaVersion; + + return ldApplication; +}; + +/** + * Clones the LDDevice object and populates the key field. + * + * @param platform + * @return An LDDevice object with populated key. + */ +export const addDeviceInfo = async (platform: Platform) => { + const { ld_device, os } = platform.info.platformData(); + const ldDevice = clone(ld_device); + + ldDevice.os.name = os?.name || ldDevice.os.name; + ldDevice.os.version = os?.version || ldDevice.os.version; + ldDevice.key = await getOrGenerateKey('ld_device', platform); + ldDevice.envAttributesVersion = ldDevice.envAttributesVersion || defaultAutoEnvSchemaVersion; + + return ldDevice; +}; + +export const addAutoEnv = async (context: LDContext, platform: Platform, config: Configuration) => { + // LDUser is not supported for auto env reporting + if (isLegacyUser(context)) { + return context as LDUser; + } + + const multi = isSingleKind(context) ? toMulti(context) : context; + + return { + ...multi, + ld_application: addApplicationInfo(platform, config), + ld_device: await addDeviceInfo(platform), + } as LDMultiKindContext; +}; diff --git a/packages/shared/sdk-client/src/utils/ensureKey.test.ts b/packages/shared/sdk-client/src/utils/ensureKey.test.ts index 141bd5a51..bee38193f 100644 --- a/packages/shared/sdk-client/src/utils/ensureKey.test.ts +++ b/packages/shared/sdk-client/src/utils/ensureKey.test.ts @@ -1,17 +1,25 @@ import type { + Crypto, LDContext, LDContextCommon, LDMultiKindContext, LDUser, + Storage, } from '@launchdarkly/js-sdk-common'; import { basicPlatform } from '@launchdarkly/private-js-mocks'; -import ensureKey, { addNamespace, getOrGenerateKey } from './ensureKey'; +import ensureKey from './ensureKey'; +import { addNamespace, getOrGenerateKey } from './getOrGenerateKey'; -const { crypto, storage } = basicPlatform; describe('ensureKey', () => { + let crypto: Crypto; + let storage: Storage; + beforeEach(() => { - crypto.randomUUID.mockReturnValueOnce('random1').mockReturnValueOnce('random2'); + crypto = basicPlatform.crypto; + storage = basicPlatform.storage; + + (crypto.randomUUID as jest.Mock).mockReturnValueOnce('random1').mockReturnValueOnce('random2'); }); afterEach(() => { @@ -33,8 +41,8 @@ describe('ensureKey', () => { }); test('getOrGenerateKey existing key', async () => { - storage.get.mockImplementation((nsKind: string) => - nsKind === 'LaunchDarkly_AnonKeys_org' ? 'random1' : undefined, + (storage.get as jest.Mock).mockImplementation((namespacedKind: string) => + namespacedKind === 'LaunchDarkly_AnonKeys_org' ? 'random1' : undefined, ); const key = await getOrGenerateKey('org', basicPlatform); diff --git a/packages/shared/sdk-client/src/utils/ensureKey.ts b/packages/shared/sdk-client/src/utils/ensureKey.ts index c293b42b5..e5314df58 100644 --- a/packages/shared/sdk-client/src/utils/ensureKey.ts +++ b/packages/shared/sdk-client/src/utils/ensureKey.ts @@ -9,21 +9,9 @@ import { Platform, } from '@launchdarkly/js-sdk-common'; -const { isLegacyUser, isMultiKind, isSingleKind } = internal; - -export const addNamespace = (s: string) => `LaunchDarkly_AnonKeys_${s}`; - -export const getOrGenerateKey = async (kind: string, { crypto, storage }: Platform) => { - const nsKind = addNamespace(kind); - let contextKey = await storage?.get(nsKind); - - if (!contextKey) { - contextKey = crypto.randomUUID(); - await storage?.set(nsKind, contextKey); - } +import { getOrGenerateKey } from './getOrGenerateKey'; - return contextKey; -}; +const { isLegacyUser, isMultiKind, isSingleKind } = internal; /** * This is the root ensureKey function. All other ensureKey functions reduce to this. @@ -66,6 +54,13 @@ const ensureKeyLegacy = async (c: LDUser, platform: Platform) => { await ensureKeyCommon('user', c, platform); }; +/** + * Ensure a key is always present in anonymous contexts. Non-anonymous contexts + * are not processed and will just be returned as is. + * + * @param context + * @param platform + */ const ensureKey = async (context: LDContext, platform: Platform) => { const cloned = clone(context); diff --git a/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts b/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts new file mode 100644 index 000000000..7b6e587a9 --- /dev/null +++ b/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts @@ -0,0 +1,38 @@ +import { Crypto, Storage } from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + +import { getOrGenerateKey } from './getOrGenerateKey'; + +describe('getOrGenerateKey', () => { + let crypto: Crypto; + let storage: Storage; + + beforeEach(() => { + crypto = basicPlatform.crypto; + storage = basicPlatform.storage; + + (crypto.randomUUID as jest.Mock).mockResolvedValue('test-org-key-1'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('key does not exist in cache so it must be generated', async () => { + (storage.get as jest.Mock).mockResolvedValue(undefined); + const k = await getOrGenerateKey('org', basicPlatform); + + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(storage.set).toHaveBeenCalled(); + expect(k).toEqual('test-org-key-1'); + }); + + test('key exists in cache so not generated', async () => { + (storage.get as jest.Mock).mockResolvedValue('test-org-key-2'); + const k = await getOrGenerateKey('org', basicPlatform); + + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(storage.set).not.toHaveBeenCalled(); + expect(k).toEqual('test-org-key-2'); + }); +}); diff --git a/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts b/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts new file mode 100644 index 000000000..e4d3c4211 --- /dev/null +++ b/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts @@ -0,0 +1,15 @@ +import { Platform } from '@launchdarkly/js-sdk-common'; + +export const addNamespace = (s: string) => `LaunchDarkly_AnonKeys_${s}`; + +export const getOrGenerateKey = async (kind: string, { crypto, storage }: Platform) => { + const namespacedKind = addNamespace(kind); + let contextKey = await storage?.get(namespacedKind); + + if (!contextKey) { + contextKey = crypto.randomUUID(); + await storage?.set(namespacedKind, contextKey); + } + + return contextKey; +}; diff --git a/packages/shared/sdk-client/src/utils/index.ts b/packages/shared/sdk-client/src/utils/index.ts index f203d0985..c16f6e1b1 100644 --- a/packages/shared/sdk-client/src/utils/index.ts +++ b/packages/shared/sdk-client/src/utils/index.ts @@ -1,4 +1,5 @@ +import { addAutoEnv } from './addAutoEnv'; import calculateFlagChanges from './calculateFlagChanges'; import ensureKey from './ensureKey'; -export { calculateFlagChanges, ensureKey }; +export { calculateFlagChanges, ensureKey, addAutoEnv }; From dc3f3afc7a25b2983598d342e25744f1dd6f8938 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 24 Jan 2024 23:36:28 -0800 Subject: [PATCH 03/33] chore: Fixed server sdk tests due to mock api changes for auto env. --- .../shared/sdk-server-edge/jest.config.json | 3 +- .../__tests__/evaluation/Bucketer.test.ts | 210 ++--- .../evaluation/Evaluator.bucketing.test.ts | 12 +- .../evaluation/Evaluator.clause.test.ts | 791 +++++++++--------- .../evaluation/Evaluator.rules.test.ts | 309 +++---- .../__tests__/evaluation/Evaluator.test.ts | 264 +++--- packages/shared/sdk-server/jest.config.js | 1 + 7 files changed, 809 insertions(+), 781 deletions(-) diff --git a/packages/shared/sdk-server-edge/jest.config.json b/packages/shared/sdk-server-edge/jest.config.json index 617480774..ccaa70d72 100644 --- a/packages/shared/sdk-server-edge/jest.config.json +++ b/packages/shared/sdk-server-edge/jest.config.json @@ -5,5 +5,6 @@ "modulePathIgnorePatterns": ["dist"], "testEnvironment": "node", "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], - "collectCoverageFrom": ["src/**/*.ts"] + "collectCoverageFrom": ["src/**/*.ts"], + "setupFilesAfterEnv": ["@launchdarkly/private-js-mocks/setup"] } diff --git a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts index c2bc82276..8928868f1 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts @@ -2,124 +2,128 @@ // We cannot fully validate bucketing in the common tests. Platform implementations // should contain a consistency test. // Testing here can only validate we are providing correct inputs to the hashing algorithm. -import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; -import * as mocks from '@launchdarkly/private-js-mocks'; +import { AttributeReference, Context, Crypto, LDContext } from '@launchdarkly/js-sdk-common'; +import { basicPlatform, hasher } from '@launchdarkly/private-js-mocks'; import Bucketer from '../../src/evaluation/Bucketer'; -describe.each< - [ - context: LDContext, - key: string, - attr: string, - salt: string, - kindForRollout: string | undefined, - seed: number | undefined, - expected: string, - ] ->([ - [{ key: 'is-key' }, 'flag-key', 'key', 'salty', undefined, undefined, 'flag-key.salty.is-key'], - // No specified kind, and user, are equivalent. - [{ key: 'is-key' }, 'flag-key', 'key', 'salty', 'user', undefined, 'flag-key.salty.is-key'], - [{ key: 'is-key' }, 'flag-key', 'key', 'salty', undefined, undefined, 'flag-key.salty.is-key'], +describe('Bucketer.test', () => { + let crypto: Crypto; - [{ key: 'is-key' }, 'flag-key', 'key', 'salty', undefined, 82, '82.is-key'], - [ - { key: 'is-key', kind: 'org' }, - 'flag-key', - 'key', - 'salty', - 'org', - undefined, - 'flag-key.salty.is-key', - ], - [ - { key: 'is-key', kind: 'org', integer: 17 }, - 'flag-key', - 'integer', - 'salty', - 'org', - undefined, - 'flag-key.salty.17', - ], - [ - { kind: 'multi', user: { key: 'user-key' }, org: { key: 'org-key' } }, - 'flag-key', - 'key', - 'salty', - undefined, - undefined, - 'flag-key.salty.user-key', - ], - [ - { kind: 'multi', user: { key: 'user-key' }, org: { key: 'org-key' } }, - 'flag-key', - 'key', - 'salty', - 'org', - undefined, - 'flag-key.salty.org-key', - ], -])('given bucketing parameters', (context, key, attr, salt, kindForRollout, seed, expected) => { - it('hashes the correct string', () => { - const validatedContext = Context.fromLDContext(context); - const attrRef = new AttributeReference(attr); - - const bucketer = new Bucketer(mocks.crypto); - const [bucket, hadContext] = bucketer.bucket( - validatedContext!, - key, - attrRef, - salt, - kindForRollout, - seed, - ); - - // The mocks.hasher always returns the same value. This just checks that it converts it to a number - // in the expected way. - expect(bucket).toBeCloseTo(0.07111111110140983, 5); - expect(hadContext).toBeTruthy(); - expect(mocks.hasher.update).toHaveBeenCalledWith(expected); - expect(mocks.hasher.digest).toHaveBeenCalledWith('hex'); + beforeEach(() => { + crypto = basicPlatform.crypto; }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); -}); -describe.each([ - ['org', 'object'], - ['org', 'array'], - ['org', 'null'], - ['bad', 'key'], -])('when given a non string or integer reference', (kind, attr) => { - it('buckets to 0 when given bad data', () => { - const validatedContext = Context.fromLDContext({ - key: 'context-key', - kind, - object: {}, - array: [], - null: null, - }); - const attrRef = new AttributeReference(attr); + describe.each< + [ + context: LDContext, + key: string, + attr: string, + salt: string, + kindForRollout: string | undefined, + seed: number | undefined, + expected: string, + ] + >([ + [{ key: 'is-key' }, 'flag-key', 'key', 'salty', undefined, undefined, 'flag-key.salty.is-key'], + // No specified kind, and user, are equivalent. + [{ key: 'is-key' }, 'flag-key', 'key', 'salty', 'user', undefined, 'flag-key.salty.is-key'], + [{ key: 'is-key' }, 'flag-key', 'key', 'salty', undefined, undefined, 'flag-key.salty.is-key'], - const bucketer = new Bucketer(mocks.crypto); - const [bucket, hadContext] = bucketer.bucket( - validatedContext!, + [{ key: 'is-key' }, 'flag-key', 'key', 'salty', undefined, 82, '82.is-key'], + [ + { key: 'is-key', kind: 'org' }, + 'flag-key', 'key', - attrRef, 'salty', 'org', undefined, - ); - expect(bucket).toEqual(0); - expect(hadContext).toEqual(kind === 'org'); - expect(mocks.hasher.update).toBeCalledTimes(0); - expect(mocks.hasher.digest).toBeCalledTimes(0); + 'flag-key.salty.is-key', + ], + [ + { key: 'is-key', kind: 'org', integer: 17 }, + 'flag-key', + 'integer', + 'salty', + 'org', + undefined, + 'flag-key.salty.17', + ], + [ + { kind: 'multi', user: { key: 'user-key' }, org: { key: 'org-key' } }, + 'flag-key', + 'key', + 'salty', + undefined, + undefined, + 'flag-key.salty.user-key', + ], + [ + { kind: 'multi', user: { key: 'user-key' }, org: { key: 'org-key' } }, + 'flag-key', + 'key', + 'salty', + 'org', + undefined, + 'flag-key.salty.org-key', + ], + ])('given bucketing parameters', (context, key, attr, salt, kindForRollout, seed, expected) => { + it('hashes the correct string', () => { + const validatedContext = Context.fromLDContext(context); + const attrRef = new AttributeReference(attr); + + const bucketer = new Bucketer(crypto); + const [bucket, hadContext] = bucketer.bucket( + validatedContext!, + key, + attrRef, + salt, + kindForRollout, + seed, + ); + + // The mocks.hasher always returns the same value. This just checks that it converts it to a number + // in the expected way. + expect(bucket).toBeCloseTo(0.07111111110140983, 5); + expect(hadContext).toBeTruthy(); + expect(hasher.update).toHaveBeenCalledWith(expected); + expect(hasher.digest).toHaveBeenCalledWith('hex'); + }); }); - afterEach(() => { - jest.clearAllMocks(); + describe.each([ + ['org', 'object'], + ['org', 'array'], + ['org', 'null'], + ['bad', 'key'], + ])('when given a non string or integer reference', (kind, attr) => { + it('buckets to 0 when given bad data', () => { + const validatedContext = Context.fromLDContext({ + key: 'context-key', + kind, + object: {}, + array: [], + null: null, + }); + const attrRef = new AttributeReference(attr); + + const bucketer = new Bucketer(crypto); + const [bucket, hadContext] = bucketer.bucket( + validatedContext!, + 'key', + attrRef, + 'salty', + 'org', + undefined, + ); + expect(bucket).toEqual(0); + expect(hadContext).toEqual(kind === 'org'); + expect(hasher.update).toHaveBeenCalledTimes(0); + expect(hasher.digest).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts index 51acff48d..6ec82a86c 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts @@ -6,9 +6,17 @@ import { Rollout } from '../../src/evaluation/data/Rollout'; import Evaluator from '../../src/evaluation/Evaluator'; import noQueries from './mocks/noQueries'; -const evaluator = new Evaluator(mocks.basicPlatform, noQueries); - describe('given a flag with a rollout', () => { + let evaluator: Evaluator; + + beforeEach(() => { + evaluator = new Evaluator(mocks.basicPlatform, noQueries); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + const seed = 61; const flagKey = 'flagkey'; const salt = 'salt'; diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts index 9a2e0c9aa..c5fa9463e 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts @@ -12,460 +12,465 @@ import { } from './flags'; import noQueries from './mocks/noQueries'; -const evaluator = new Evaluator(mocks.basicPlatform, noQueries); - -// Either a legacy user, or context with equivalent user. -describe('given user clauses and contexts', () => { - it.each([ - { key: 'x', name: 'Bob' }, - { kind: 'user', key: 'x', name: 'Bob' }, - { kind: 'multi', user: { key: 'x', name: 'Bob' } }, - ])('can match built-in attribute', async (user) => { - const clause: Clause = { - attribute: 'name', - op: 'in', - values: ['Bob'], - attributeReference: new AttributeReference('name'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); +describe('Evaluator.clause', () => { + let evaluator: Evaluator; + beforeEach(() => { + evaluator = new Evaluator(mocks.basicPlatform, noQueries); }); - it.each([ - { key: 'x', name: 'Bob', custom: { legs: 4 } }, - { - kind: 'user', - key: 'x', - name: 'Bob', - legs: 4, - }, - { kind: 'multi', user: { key: 'x', name: 'Bob', legs: 4 } }, - ])('can match custom attribute', async (user) => { - const clause: Clause = { - attribute: 'legs', - op: 'in', - values: [4], - attributeReference: new AttributeReference('legs'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); - }); + // Either a legacy user, or context with equivalent user. + describe('given user clauses and contexts', () => { + it.each([ + { key: 'x', name: 'Bob' }, + { kind: 'user', key: 'x', name: 'Bob' }, + { kind: 'multi', user: { key: 'x', name: 'Bob' } }, + ])('can match built-in attribute', async (user) => { + const clause: Clause = { + attribute: 'name', + op: 'in', + values: ['Bob'], + attributeReference: new AttributeReference('name'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); + }); - it.each<[LDContext, string]>([ - [{ key: 'x', name: 'Bob', custom: { '//': 4 } }, '//'], - [ - { - kind: 'user', - key: 'x', - name: 'Bob', - '//': 4, - }, - '//', - ], - [{ kind: 'multi', user: { key: 'x', name: 'Bob', '//': 4 } }, '//'], - [{ key: 'x', name: 'Bob', custom: { '/~~': 4 } }, '/~~'], - [ + it.each([ + { key: 'x', name: 'Bob', custom: { legs: 4 } }, { kind: 'user', key: 'x', name: 'Bob', - '/~~': 4, + legs: 4, }, - '/~~', - ], - [{ kind: 'multi', user: { key: 'x', name: 'Bob', '/~~': 4 } }, '/~~'], - ])( - 'can match attributes which would have be invalid references, but are valid literals', - async (user, attribute) => { + { kind: 'multi', user: { key: 'x', name: 'Bob', legs: 4 } }, + ])('can match custom attribute', async (user) => { const clause: Clause = { - attribute, + attribute: 'legs', op: 'in', values: [4], - attributeReference: new AttributeReference(attribute, true), + attributeReference: new AttributeReference('legs'), }; const flag = makeBooleanFlagWithOneClause(clause); const context = Context.fromLDContext(user); const res = await evaluator.evaluate(flag, context!); expect(res.detail.value).toBe(true); - }, - ); + }); - it.each([ - { key: 'x', name: 'Bob' }, - { kind: 'user', key: 'x', name: 'Bob' }, - { kind: 'multi', user: { key: 'x', name: 'Bob' } }, - ])('does not match missing attribute', async (user) => { - const clause: Clause = { - attribute: 'legs', - op: 'in', - values: [4], - attributeReference: new AttributeReference('legs'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); - }); + it.each<[LDContext, string]>([ + [{ key: 'x', name: 'Bob', custom: { '//': 4 } }, '//'], + [ + { + kind: 'user', + key: 'x', + name: 'Bob', + '//': 4, + }, + '//', + ], + [{ kind: 'multi', user: { key: 'x', name: 'Bob', '//': 4 } }, '//'], + [{ key: 'x', name: 'Bob', custom: { '/~~': 4 } }, '/~~'], + [ + { + kind: 'user', + key: 'x', + name: 'Bob', + '/~~': 4, + }, + '/~~', + ], + [{ kind: 'multi', user: { key: 'x', name: 'Bob', '/~~': 4 } }, '/~~'], + ])( + 'can match attributes which would have be invalid references, but are valid literals', + async (user, attribute) => { + const clause: Clause = { + attribute, + op: 'in', + values: [4], + attributeReference: new AttributeReference(attribute, true), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); + }, + ); - it.each([ - { key: 'x', name: 'Bob' }, - { kind: 'user', key: 'x', name: 'Bob' }, - { kind: 'multi', user: { key: 'x', name: 'Bob' } }, - ])('can have a negated clause', async (user) => { - const clause: Clause = { - attribute: 'name', - op: 'in', - values: ['Bob'], - negate: true, - attributeReference: new AttributeReference('name'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); - }); + it.each([ + { key: 'x', name: 'Bob' }, + { kind: 'user', key: 'x', name: 'Bob' }, + { kind: 'multi', user: { key: 'x', name: 'Bob' } }, + ])('does not match missing attribute', async (user) => { + const clause: Clause = { + attribute: 'legs', + op: 'in', + values: [4], + attributeReference: new AttributeReference('legs'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); + }); - // An equivalent test existed in the previous suite. I see no reason that the - // current code could encounter such a situation, but this will help ensure - // it never does. - it('does not overflow the call stack when evaluating a huge number of clauses', async () => { - const user = { key: 'user' }; - const clauseCount = 5000; - const flag: Flag = { - key: 'flag', - targets: [], - on: true, - variations: [false, true], - fallthrough: { variation: 0 }, - version: 1, - }; - // Note, for this test to be meaningful, the clauses must all match the user, since we - // stop evaluating clauses on the first non-match. - const clause = makeClauseThatMatchesUser(user); - const clauses = []; - for (let i = 0; i < clauseCount; i += 1) { - clauses.push(clause); - } - const rule: FlagRule = { id: '1234', clauses, variation: 1 }; - flag.rules = [rule]; - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); - }); + it.each([ + { key: 'x', name: 'Bob' }, + { kind: 'user', key: 'x', name: 'Bob' }, + { kind: 'multi', user: { key: 'x', name: 'Bob' } }, + ])('can have a negated clause', async (user) => { + const clause: Clause = { + attribute: 'name', + op: 'in', + values: ['Bob'], + negate: true, + attributeReference: new AttributeReference('name'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); + }); - it('matches kind of implicit user', async () => { - const user = { key: 'x', name: 'Bob' }; - const clause: Clause = { - attribute: 'kind', - op: 'in', - values: ['user'], - attributeReference: new AttributeReference('kind'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); - }); + // An equivalent test existed in the previous suite. I see no reason that the + // current code could encounter such a situation, but this will help ensure + // it never does. + it('does not overflow the call stack when evaluating a huge number of clauses', async () => { + const user = { key: 'user' }; + const clauseCount = 5000; + const flag: Flag = { + key: 'flag', + targets: [], + on: true, + variations: [false, true], + fallthrough: { variation: 0 }, + version: 1, + }; + // Note, for this test to be meaningful, the clauses must all match the user, since we + // stop evaluating clauses on the first non-match. + const clause = makeClauseThatMatchesUser(user); + const clauses = []; + for (let i = 0; i < clauseCount; i += 1) { + clauses.push(clause); + } + const rule: FlagRule = { id: '1234', clauses, variation: 1 }; + flag.rules = [rule]; + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); + }); - it('implicit user kind does not match rules for non-user kinds', async () => { - const user = { key: 'x', name: 'Bob' }; - const clause: Clause = { - attribute: 'key', - op: 'in', - values: ['userkey'], - contextKind: 'org', - attributeReference: new AttributeReference('key'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext(user); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); - }); -}); + it('matches kind of implicit user', async () => { + const user = { key: 'x', name: 'Bob' }; + const clause: Clause = { + attribute: 'kind', + op: 'in', + values: ['user'], + attributeReference: new AttributeReference('kind'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); + }); -describe('given non-user single-kind contexts', () => { - it('does not match implicit user clauses to non-user contexts', async () => { - const clause: Clause = { - attribute: 'name', - op: 'in', - values: ['Bob'], - attributeReference: new AttributeReference('name'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext({ kind: 'org', name: 'Bob', key: 'bobkey' }); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); + it('implicit user kind does not match rules for non-user kinds', async () => { + const user = { key: 'x', name: 'Bob' }; + const clause: Clause = { + attribute: 'key', + op: 'in', + values: ['userkey'], + contextKind: 'org', + attributeReference: new AttributeReference('key'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext(user); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); + }); }); - it('cannot use an object attribute for a match.', async () => { - const clause: Clause = { - attribute: 'complex', - op: 'in', - values: [{ thing: true }], - contextKind: 'org', - attributeReference: new AttributeReference('complex'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext({ - kind: 'org', - name: 'Bob', - key: 'bobkey', - complex: { thing: true }, + describe('given non-user single-kind contexts', () => { + it('does not match implicit user clauses to non-user contexts', async () => { + const clause: Clause = { + attribute: 'name', + op: 'in', + values: ['Bob'], + attributeReference: new AttributeReference('name'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext({ kind: 'org', name: 'Bob', key: 'bobkey' }); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); }); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); - }); - it('does match clauses for the correct context kind', async () => { - const clause: Clause = { - attribute: 'name', - op: 'in', - values: ['Bob'], - contextKind: 'org', - attributeReference: new AttributeReference('name'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext({ kind: 'org', name: 'Bob', key: 'bobkey' }); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); - }); + it('cannot use an object attribute for a match.', async () => { + const clause: Clause = { + attribute: 'complex', + op: 'in', + values: [{ thing: true }], + contextKind: 'org', + attributeReference: new AttributeReference('complex'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext({ + kind: 'org', + name: 'Bob', + key: 'bobkey', + complex: { thing: true }, + }); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); + }); - it('matches clauses for the kind attribute', async () => { - // The context kind here should not matter, but the 'kind' attribute should. - const clause: Clause = { - attribute: 'kind', - op: 'in', - values: ['org'], - contextKind: 'potato', - attributeReference: new AttributeReference('kind'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext({ kind: 'org', name: 'Bob', key: 'bobkey' }); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); - }); + it('does match clauses for the correct context kind', async () => { + const clause: Clause = { + attribute: 'name', + op: 'in', + values: ['Bob'], + contextKind: 'org', + attributeReference: new AttributeReference('name'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext({ kind: 'org', name: 'Bob', key: 'bobkey' }); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); + }); - it('does not match clauses for the kind attribute if the kind does not match', async () => { - // The context kind here should not matter, but the 'kind' attribute should. - const clause: Clause = { - attribute: 'kind', - op: 'in', - values: ['org'], - contextKind: 'potato', - attributeReference: new AttributeReference('kind'), - }; - const flag = makeBooleanFlagWithOneClause(clause); - const context = Context.fromLDContext({ kind: 'party', name: 'Bob', key: 'bobkey' }); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); + it('matches clauses for the kind attribute', async () => { + // The context kind here should not matter, but the 'kind' attribute should. + const clause: Clause = { + attribute: 'kind', + op: 'in', + values: ['org'], + contextKind: 'potato', + attributeReference: new AttributeReference('kind'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext({ kind: 'org', name: 'Bob', key: 'bobkey' }); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); + }); + + it('does not match clauses for the kind attribute if the kind does not match', async () => { + // The context kind here should not matter, but the 'kind' attribute should. + const clause: Clause = { + attribute: 'kind', + op: 'in', + values: ['org'], + contextKind: 'potato', + attributeReference: new AttributeReference('kind'), + }; + const flag = makeBooleanFlagWithOneClause(clause); + const context = Context.fromLDContext({ kind: 'party', name: 'Bob', key: 'bobkey' }); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); + }); }); -}); -describe('given multi-kind contexts', () => { - it('does match clauses correctly with multiple contexts', async () => { - const clause1: Clause = { - attribute: 'region', - op: 'in', - values: ['north'], - contextKind: 'park', - attributeReference: new AttributeReference('region'), - }; - const clause2: Clause = { - attribute: 'count', - op: 'in', - values: [5], - contextKind: 'party', - attributeReference: new AttributeReference('count'), - }; + describe('given multi-kind contexts', () => { + it('does match clauses correctly with multiple contexts', async () => { + const clause1: Clause = { + attribute: 'region', + op: 'in', + values: ['north'], + contextKind: 'park', + attributeReference: new AttributeReference('region'), + }; + const clause2: Clause = { + attribute: 'count', + op: 'in', + values: [5], + contextKind: 'party', + attributeReference: new AttributeReference('count'), + }; - const context = Context.fromLDContext({ - kind: 'multi', - park: { - key: 'park', - region: 'north', - }, - party: { - key: 'party', - count: 5, - }, + const context = Context.fromLDContext({ + kind: 'multi', + park: { + key: 'park', + region: 'north', + }, + party: { + key: 'party', + count: 5, + }, + }); + + const flag = makeBooleanFlagWithRules([ + { id: '1234', clauses: [clause1, clause2], variation: 1 }, + ]); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); }); - const flag = makeBooleanFlagWithRules([ - { id: '1234', clauses: [clause1, clause2], variation: 1 }, - ]); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); - }); - - it('does not match the values from the wrong contexts', async () => { - const clause1: Clause = { - attribute: 'region', - op: 'in', - values: ['north'], - contextKind: 'park', - attributeReference: new AttributeReference('region'), - }; - const clause2: Clause = { - attribute: 'count', - op: 'in', - values: [5], - contextKind: 'party', - attributeReference: new AttributeReference('count'), - }; + it('does not match the values from the wrong contexts', async () => { + const clause1: Clause = { + attribute: 'region', + op: 'in', + values: ['north'], + contextKind: 'park', + attributeReference: new AttributeReference('region'), + }; + const clause2: Clause = { + attribute: 'count', + op: 'in', + values: [5], + contextKind: 'party', + attributeReference: new AttributeReference('count'), + }; - const context = Context.fromLDContext({ - kind: 'multi', - park: { - key: 'park', - count: 5, - }, - party: { - key: 'party', - region: 'north', - }, + const context = Context.fromLDContext({ + kind: 'multi', + park: { + key: 'park', + count: 5, + }, + party: { + key: 'party', + region: 'north', + }, + }); + + const flag = makeBooleanFlagWithRules([ + { id: '1234', clauses: [clause1, clause2], variation: 1 }, + ]); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); }); - const flag = makeBooleanFlagWithRules([ - { id: '1234', clauses: [clause1, clause2], variation: 1 }, - ]); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); - }); + it('can check for the presence of contexts', async () => { + const clause: Clause = { + attribute: 'kind', + op: 'in', + values: ['party'], + attributeReference: new AttributeReference('kind'), + }; - it('can check for the presence of contexts', async () => { - const clause: Clause = { - attribute: 'kind', - op: 'in', - values: ['party'], - attributeReference: new AttributeReference('kind'), - }; + const context = Context.fromLDContext({ + kind: 'multi', + park: { + key: 'park', + count: 5, + }, + party: { + key: 'party', + region: 'north', + }, + }); - const context = Context.fromLDContext({ - kind: 'multi', - park: { - key: 'park', - count: 5, - }, - party: { - key: 'party', - region: 'north', - }, + const flag = makeBooleanFlagWithOneClause(clause); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(true); }); - const flag = makeBooleanFlagWithOneClause(clause); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(true); + it('does not match if the kind is not in the context', async () => { + const clause: Clause = { + attribute: 'kind', + op: 'in', + values: ['zoo'], + attributeReference: new AttributeReference('kind'), + }; + + const context = Context.fromLDContext({ + kind: 'multi', + park: { + key: 'park', + count: 5, + }, + party: { + key: 'party', + region: 'north', + }, + }); + + const flag = makeBooleanFlagWithOneClause(clause); + const res = await evaluator.evaluate(flag, context!); + expect(res.detail.value).toBe(false); + }); }); - it('does not match if the kind is not in the context', async () => { + it('handles clauses with malformed attribute references', async () => { const clause: Clause = { - attribute: 'kind', + attribute: '//region', op: 'in', - values: ['zoo'], - attributeReference: new AttributeReference('kind'), + values: ['north'], + contextKind: 'park', + attributeReference: new AttributeReference('//region'), }; const context = Context.fromLDContext({ kind: 'multi', park: { key: 'park', - count: 5, + region: 'north', }, party: { key: 'party', - region: 'north', + count: 5, }, }); - const flag = makeBooleanFlagWithOneClause(clause); + const flag = makeBooleanFlagWithRules([{ id: '1234', clauses: [clause], variation: 1 }]); const res = await evaluator.evaluate(flag, context!); - expect(res.detail.value).toBe(false); - }); -}); - -it('handles clauses with malformed attribute references', async () => { - const clause: Clause = { - attribute: '//region', - op: 'in', - values: ['north'], - contextKind: 'park', - attributeReference: new AttributeReference('//region'), - }; - - const context = Context.fromLDContext({ - kind: 'multi', - park: { - key: 'park', - region: 'north', - }, - party: { - key: 'party', - count: 5, - }, + expect(res.detail.reason).toEqual({ kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }); + expect(res.detail.value).toBe(null); }); - const flag = makeBooleanFlagWithRules([{ id: '1234', clauses: [clause], variation: 1 }]); - const res = await evaluator.evaluate(flag, context!); - expect(res.detail.reason).toEqual({ kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }); - expect(res.detail.value).toBe(null); -}); + describe.each([ + ['lessThan', 99, 99.0001], + ['lessThanOrEqual', 99, 99.0001], + ['greaterThan', 99.0001, 99], + ['greaterThanOrEqual', 99.0001, 99], -describe.each([ - ['lessThan', 99, 99.0001], - ['lessThanOrEqual', 99, 99.0001], - ['greaterThan', 99.0001, 99], - ['greaterThanOrEqual', 99.0001, 99], - - // string comparisons - ['startsWith', 'xyz', 'x'], - ['endsWith', 'xyz', 'z'], - ['contains', 'xyz', 'y'], - - // regex - ['matches', 'hello world', 'hello.*rld'], - - // dates - ['before', 0, 1], - ['after', '1970-01-01T00:00:02.500Z', 1000], - - // semver - ['semVerLessThan', '2.0.0', '2.0.1'], - ['semVerGreaterThan', '2.0.1', '2.0.0'], -])( - 'executes operations with the clause value and context value correctly', - (operator, contextValue, clauseValue) => { - const clause: Clause = { - attribute: 'value', - // @ts-ignore - op: operator, - values: [clauseValue], - contextKind: 'potato', - attributeReference: new AttributeReference('value'), - }; + // string comparisons + ['startsWith', 'xyz', 'x'], + ['endsWith', 'xyz', 'z'], + ['contains', 'xyz', 'y'], - const context = Context.fromLDContext({ - kind: 'potato', - key: 'potato', - value: contextValue, - }); + // regex + ['matches', 'hello world', 'hello.*rld'], - const contextWArray = Context.fromLDContext({ - kind: 'potato', - key: 'potato', - value: [contextValue], - }); + // dates + ['before', 0, 1], + ['after', '1970-01-01T00:00:02.500Z', 1000], - it(`Operator ${operator} with ${contextValue} and ${clauseValue} should be true`, async () => { - const flag = makeBooleanFlagWithOneClause(clause); - const res = await evaluator.evaluate(flag, context); - expect(res.detail.value).toBe(true); + // semver + ['semVerLessThan', '2.0.0', '2.0.1'], + ['semVerGreaterThan', '2.0.1', '2.0.0'], + ])( + 'executes operations with the clause value and context value correctly', + (operator, contextValue, clauseValue) => { + const clause: Clause = { + attribute: 'value', + // @ts-ignore + op: operator, + values: [clauseValue], + contextKind: 'potato', + attributeReference: new AttributeReference('value'), + }; - const res2 = await evaluator.evaluate(flag, contextWArray); - expect(res2.detail.value).toBe(true); - }); - }, -); + const context = Context.fromLDContext({ + kind: 'potato', + key: 'potato', + value: contextValue, + }); + + const contextWArray = Context.fromLDContext({ + kind: 'potato', + key: 'potato', + value: [contextValue], + }); + + it(`Operator ${operator} with ${contextValue} and ${clauseValue} should be true`, async () => { + const flag = makeBooleanFlagWithOneClause(clause); + const res = await evaluator.evaluate(flag, context); + expect(res.detail.value).toBe(true); + + const res2 = await evaluator.evaluate(flag, contextWArray); + expect(res2.detail.value).toBe(true); + }); + }, + ); +}); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts index 60de3b117..853d4051a 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts @@ -18,166 +18,171 @@ const basicUser: LDContext = { key: 'userkey' }; const basicSingleKindUser: LDContext = { kind: 'user', key: 'userkey' }; const basicMultiKindUser: LDContext = { kind: 'multi', user: { key: 'userkey' } }; -const evaluator = new Evaluator(mocks.basicPlatform, noQueries); +describe('Evaluator.rules', () => { + let evaluator: Evaluator; + beforeEach(() => { + evaluator = new Evaluator(mocks.basicPlatform, noQueries); + }); -describe('when evaluating user equivalent contexts', () => { - const matchClause = makeClauseThatMatchesUser(basicUser); - const noMatchClause = makeClauseThatDoesNotMatchUser(basicUser); + describe('when evaluating user equivalent contexts', () => { + const matchClause = makeClauseThatMatchesUser(basicUser); + const noMatchClause = makeClauseThatDoesNotMatchUser(basicUser); - it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( - 'matches user from rules', - async (userToTest) => { - const rule0: FlagRule = { - id: 'id0', - clauses: [noMatchClause], - variation: 1, - }; - const rule1: FlagRule = { - id: 'id1', - clauses: [matchClause], - variation: 2, - }; - const flag = makeFlagWithRules([rule0, rule1]); - const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); - expect(res.detail).toMatchObject({ - value: 'c', - variationIndex: 2, - reason: { kind: 'RULE_MATCH', ruleIndex: 1, ruleId: 'id1' }, - }); - }, - ); + it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( + 'matches user from rules', + async (userToTest) => { + const rule0: FlagRule = { + id: 'id0', + clauses: [noMatchClause], + variation: 1, + }; + const rule1: FlagRule = { + id: 'id1', + clauses: [matchClause], + variation: 2, + }; + const flag = makeFlagWithRules([rule0, rule1]); + const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); + expect(res.detail).toMatchObject({ + value: 'c', + variationIndex: 2, + reason: { kind: 'RULE_MATCH', ruleIndex: 1, ruleId: 'id1' }, + }); + }, + ); - it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( - 'returns error if rule variation is too high', - async (userToTest) => { - const rule: FlagRule = { id: 'id', clauses: [matchClause], variation: 99 }; - const flag = makeFlagWithRules([rule]); - const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); - expect(res.isError).toBeTruthy(); - expect(res.message).toEqual('Invalid variation index in flag'); - expect(res.detail).toMatchObject({ - value: null, - variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, - }); - }, - ); + it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( + 'returns error if rule variation is too high', + async (userToTest) => { + const rule: FlagRule = { id: 'id', clauses: [matchClause], variation: 99 }; + const flag = makeFlagWithRules([rule]); + const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); + expect(res.isError).toBeTruthy(); + expect(res.message).toEqual('Invalid variation index in flag'); + expect(res.detail).toMatchObject({ + value: null, + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, + }); + }, + ); - it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( - 'returns error if rule variation is negative', - async (userToTest) => { - const rule: FlagRule = { id: 'id', clauses: [matchClause], variation: -1 }; - const flag = makeFlagWithRules([rule]); - const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); - expect(res.isError).toBeTruthy(); - expect(res.message).toEqual('Invalid variation index in flag'); - expect(res.detail).toMatchObject({ - value: null, - variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, - }); - }, - ); + it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( + 'returns error if rule variation is negative', + async (userToTest) => { + const rule: FlagRule = { id: 'id', clauses: [matchClause], variation: -1 }; + const flag = makeFlagWithRules([rule]); + const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); + expect(res.isError).toBeTruthy(); + expect(res.message).toEqual('Invalid variation index in flag'); + expect(res.detail).toMatchObject({ + value: null, + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, + }); + }, + ); - it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( - 'returns error if rule has no variation or rollout', - async (userToTest) => { - const rule: FlagRule = { id: 'id', clauses: [matchClause] }; - const flag = makeFlagWithRules([rule]); - const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); - expect(res.isError).toBeTruthy(); - expect(res.message).toEqual('Variation/rollout object with no variation or rollout'); - expect(res.detail).toMatchObject({ - value: null, - variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, - }); - }, - ); + it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( + 'returns error if rule has no variation or rollout', + async (userToTest) => { + const rule: FlagRule = { id: 'id', clauses: [matchClause] }; + const flag = makeFlagWithRules([rule]); + const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); + expect(res.isError).toBeTruthy(); + expect(res.message).toEqual('Variation/rollout object with no variation or rollout'); + expect(res.detail).toMatchObject({ + value: null, + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, + }); + }, + ); - it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( - 'returns error if rule has rollout with no variations', - async (userToTest) => { - const rule: FlagRule = { id: 'id', clauses: [matchClause], rollout: { variations: [] } }; - const flag = makeFlagWithRules([rule]); - const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); - expect(res.isError).toBeTruthy(); - expect(res.message).toEqual('Variation/rollout object with no variation or rollout'); - expect(res.detail).toMatchObject({ - value: null, - variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, - }); - }, - ); + it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( + 'returns error if rule has rollout with no variations', + async (userToTest) => { + const rule: FlagRule = { id: 'id', clauses: [matchClause], rollout: { variations: [] } }; + const flag = makeFlagWithRules([rule]); + const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); + expect(res.isError).toBeTruthy(); + expect(res.message).toEqual('Variation/rollout object with no variation or rollout'); + expect(res.detail).toMatchObject({ + value: null, + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'MALFORMED_FLAG' }, + }); + }, + ); - it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( - 'does not overflow the call stack when evaluating a huge number of rules', - async (userToTest) => { - const ruleCount = 5000; - const flag: Flag = { - key: 'flag', - targets: [], - on: true, - variations: [false, true], - fallthrough: { variation: 0 }, - version: 1, - }; - // Note, for this test to be meaningful, the rules must *not* match the user, since we - // stop evaluating rules on the first match. - const rules: FlagRule[] = []; - for (let i = 0; i < ruleCount; i += 1) { - rules.push({ id: '1234', clauses: [noMatchClause], variation: 1 }); - } - flag.rules = rules; - const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); - expect(res.isError).toBeFalsy(); - expect(res.detail.value).toEqual(false); - }, - ); -}); + it.each([basicUser, basicSingleKindUser, basicMultiKindUser])( + 'does not overflow the call stack when evaluating a huge number of rules', + async (userToTest) => { + const ruleCount = 5000; + const flag: Flag = { + key: 'flag', + targets: [], + on: true, + variations: [false, true], + fallthrough: { variation: 0 }, + version: 1, + }; + // Note, for this test to be meaningful, the rules must *not* match the user, since we + // stop evaluating rules on the first match. + const rules: FlagRule[] = []; + for (let i = 0; i < ruleCount; i += 1) { + rules.push({ id: '1234', clauses: [noMatchClause], variation: 1 }); + } + flag.rules = rules; + const res = await evaluator.evaluate(flag, Context.fromLDContext(userToTest)); + expect(res.isError).toBeFalsy(); + expect(res.detail.value).toEqual(false); + }, + ); + }); -describe('when evaluating non-user contexts', () => { - const targetKey = 'targetKey'; - const targetContextKind = 'org'; - const matchClause: Clause = { - attribute: 'key', - op: 'in', - values: [targetKey], - contextKind: targetContextKind, - attributeReference: new AttributeReference('key'), - }; - const noMatchClause: Clause = { - attribute: 'key', - op: 'in', - values: [`not-${targetKey}`], - contextKind: targetContextKind, - attributeReference: new AttributeReference('key'), - }; + describe('when evaluating non-user contexts', () => { + const targetKey = 'targetKey'; + const targetContextKind = 'org'; + const matchClause: Clause = { + attribute: 'key', + op: 'in', + values: [targetKey], + contextKind: targetContextKind, + attributeReference: new AttributeReference('key'), + }; + const noMatchClause: Clause = { + attribute: 'key', + op: 'in', + values: [`not-${targetKey}`], + contextKind: targetContextKind, + attributeReference: new AttributeReference('key'), + }; - const singleKindContext: LDContext = { - kind: targetContextKind, - key: targetKey, - }; - const multiKindContext: LDContext = { - kind: 'multi', - }; - multiKindContext[targetContextKind] = { - key: targetKey, - }; + const singleKindContext: LDContext = { + kind: targetContextKind, + key: targetKey, + }; + const multiKindContext: LDContext = { + kind: 'multi', + }; + multiKindContext[targetContextKind] = { + key: targetKey, + }; - it.each([singleKindContext, multiKindContext])( - 'matches user from rules', - async (contextToTest) => { - const rule0: FlagRule = { id: 'id0', clauses: [noMatchClause], variation: 1 }; - const rule1: FlagRule = { id: 'id1', clauses: [matchClause], variation: 2 }; - const flag = makeFlagWithRules([rule0, rule1]); - const res = await evaluator.evaluate(flag, Context.fromLDContext(contextToTest)); - expect(res.detail).toMatchObject({ - value: 'c', - variationIndex: 2, - reason: { kind: 'RULE_MATCH', ruleIndex: 1, ruleId: 'id1' }, - }); - }, - ); + it.each([singleKindContext, multiKindContext])( + 'matches user from rules', + async (contextToTest) => { + const rule0: FlagRule = { id: 'id0', clauses: [noMatchClause], variation: 1 }; + const rule1: FlagRule = { id: 'id1', clauses: [matchClause], variation: 2 }; + const flag = makeFlagWithRules([rule0, rule1]); + const res = await evaluator.evaluate(flag, Context.fromLDContext(contextToTest)); + expect(res.detail).toMatchObject({ + value: 'c', + variationIndex: 2, + reason: { kind: 'RULE_MATCH', ruleIndex: 1, ruleId: 'id1' }, + }); + }, + ); + }); }); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index a51cd35e2..9c12dccc6 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -15,139 +15,143 @@ const offBaseFlag = { variations: ['zero', 'one', 'two'], }; -describe.each<[Flag, LDContext, EvalResult | undefined]>([ - [ - { - ...offBaseFlag, - }, - { key: 'user-key' }, - EvalResult.forSuccess(null, Reasons.Off, undefined), - ], - [ - { - ...offBaseFlag, - offVariation: 2, - }, - { key: 'user-key' }, - EvalResult.forSuccess('two', Reasons.Off, 2), - ], -])('Given off flags and an evaluator', (flag, context, expected) => { - const evaluator = new Evaluator(mocks.basicPlatform, noQueries); +describe('Evaluator.test', () => { + let evaluator: Evaluator; + beforeEach(() => { + evaluator = new Evaluator(mocks.basicPlatform, noQueries); + }); - it(`produces the expected evaluation result for context: ${context.key} ${ - // @ts-ignore - context.kind - } targets: ${flag.targets?.map( - (t) => `${t.values}, ${t.variation}`, - )} context targets: ${flag.contextTargets?.map( - (t) => `${t.contextKind}: ${t.values}, ${t.variation}`, - )}`, async () => { - const result = await evaluator.evaluate(flag, Context.fromLDContext(context)); - expect(result?.isError).toEqual(expected?.isError); - expect(result?.detail).toStrictEqual(expected?.detail); - expect(result?.message).toEqual(expected?.message); + describe.each<[Flag, LDContext, EvalResult | undefined]>([ + [ + { + ...offBaseFlag, + }, + { key: 'user-key' }, + EvalResult.forSuccess(null, Reasons.Off, undefined), + ], + [ + { + ...offBaseFlag, + offVariation: 2, + }, + { key: 'user-key' }, + EvalResult.forSuccess('two', Reasons.Off, 2), + ], + ])('Given off flags and an evaluator', (flag, context, expected) => { + it(`produces the expected evaluation result for context: ${context.key} ${ + // @ts-ignore + context.kind + } targets: ${flag.targets?.map( + (t) => `${t.values}, ${t.variation}`, + )} context targets: ${flag.contextTargets?.map( + (t) => `${t.contextKind}: ${t.values}, ${t.variation}`, + )}`, async () => { + const result = await evaluator.evaluate(flag, Context.fromLDContext(context)); + expect(result?.isError).toEqual(expected?.isError); + expect(result?.detail).toStrictEqual(expected?.detail); + expect(result?.message).toEqual(expected?.message); + }); }); -}); -const targetBaseFlag = { - key: 'feature0', - version: 1, - on: true, - fallthrough: { variation: 1 }, - variations: ['zero', 'one', 'two'], -}; + const targetBaseFlag = { + key: 'feature0', + version: 1, + on: true, + fallthrough: { variation: 1 }, + variations: ['zero', 'one', 'two'], + }; -describe.each<[Flag, LDContext, EvalResult | undefined]>([ - [ - { - ...targetBaseFlag, - targets: [ - { - values: ['user-key'], - variation: 0, - }, - ], - }, - { key: 'user-key' }, - EvalResult.forSuccess('zero', Reasons.TargetMatch, 0), - ], - [ - { - ...targetBaseFlag, - targets: [ - { - values: ['user-key'], - variation: 0, - }, - { - values: ['user-key2'], - variation: 2, - }, - ], - }, - { key: 'user-key2' }, - EvalResult.forSuccess('two', Reasons.TargetMatch, 2), - ], - [ - { - ...targetBaseFlag, - targets: [ - { - values: ['user-key'], - variation: 0, - }, - { - values: ['user-key2'], - variation: 2, - }, - ], - contextTargets: [ - { - values: [], - variation: 2, - }, - ], - }, - { key: 'user-key2' }, - EvalResult.forSuccess('two', Reasons.TargetMatch, 2), - ], - [ - { - ...targetBaseFlag, - targets: [ - { - values: ['user-key'], - variation: 0, - }, - { - values: ['user-key2'], - variation: 2, - }, - ], - contextTargets: [ - { - contextKind: 'org', - values: ['org-key'], - variation: 1, - }, - ], - }, - { kind: 'org', key: 'org-key' }, - EvalResult.forSuccess('one', Reasons.TargetMatch, 1), - ], -])('given flag configurations with different targets that match', (flag, context, expected) => { - const evaluator = new Evaluator(mocks.basicPlatform, noQueries); - it(`produces the expected evaluation result for context: ${context.key} ${ - // @ts-ignore - context.kind - } targets: ${flag.targets?.map( - (t) => `${t.values}, ${t.variation}`, - )} context targets: ${flag.contextTargets?.map( - (t) => `${t.contextKind}: ${t.values}, ${t.variation}`, - )}`, async () => { - const result = await evaluator.evaluate(flag, Context.fromLDContext(context)); - expect(result?.isError).toEqual(expected?.isError); - expect(result?.detail).toStrictEqual(expected?.detail); - expect(result?.message).toEqual(expected?.message); + describe.each<[Flag, LDContext, EvalResult | undefined]>([ + [ + { + ...targetBaseFlag, + targets: [ + { + values: ['user-key'], + variation: 0, + }, + ], + }, + { key: 'user-key' }, + EvalResult.forSuccess('zero', Reasons.TargetMatch, 0), + ], + [ + { + ...targetBaseFlag, + targets: [ + { + values: ['user-key'], + variation: 0, + }, + { + values: ['user-key2'], + variation: 2, + }, + ], + }, + { key: 'user-key2' }, + EvalResult.forSuccess('two', Reasons.TargetMatch, 2), + ], + [ + { + ...targetBaseFlag, + targets: [ + { + values: ['user-key'], + variation: 0, + }, + { + values: ['user-key2'], + variation: 2, + }, + ], + contextTargets: [ + { + values: [], + variation: 2, + }, + ], + }, + { key: 'user-key2' }, + EvalResult.forSuccess('two', Reasons.TargetMatch, 2), + ], + [ + { + ...targetBaseFlag, + targets: [ + { + values: ['user-key'], + variation: 0, + }, + { + values: ['user-key2'], + variation: 2, + }, + ], + contextTargets: [ + { + contextKind: 'org', + values: ['org-key'], + variation: 1, + }, + ], + }, + { kind: 'org', key: 'org-key' }, + EvalResult.forSuccess('one', Reasons.TargetMatch, 1), + ], + ])('given flag configurations with different targets that match', (flag, context, expected) => { + it(`produces the expected evaluation result for context: ${context.key} ${ + // @ts-ignore + context.kind + } targets: ${flag.targets?.map( + (t) => `${t.values}, ${t.variation}`, + )} context targets: ${flag.contextTargets?.map( + (t) => `${t.contextKind}: ${t.values}, ${t.variation}`, + )}`, async () => { + const result = await evaluator.evaluate(flag, Context.fromLDContext(context)); + expect(result?.isError).toEqual(expected?.isError); + expect(result?.detail).toStrictEqual(expected?.detail); + expect(result?.message).toEqual(expected?.message); + }); }); }); diff --git a/packages/shared/sdk-server/jest.config.js b/packages/shared/sdk-server/jest.config.js index 6753062cc..bcd6a8d01 100644 --- a/packages/shared/sdk-server/jest.config.js +++ b/packages/shared/sdk-server/jest.config.js @@ -4,4 +4,5 @@ module.exports = { testEnvironment: 'node', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: ['src/**/*.ts'], + setupFilesAfterEnv: ['@launchdarkly/private-js-mocks/setup'], }; From e552deff40f50b31cadde092adeddb37a44eb8cf Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 24 Jan 2024 23:51:03 -0800 Subject: [PATCH 04/33] feat: React-native support for auto-env attributes. --- .eslintrc.js | 2 +- packages/sdk/react-native/babel.config.js | 3 + .../sdk/react-native/example/package.json | 5 +- packages/sdk/react-native/example/yarn.lock | 1235 ++++++++++++++++- packages/sdk/react-native/jest.config.ts | 5 +- packages/sdk/react-native/jestSetupFile.ts | 19 + packages/sdk/react-native/package.json | 1 + .../src/fromExternal/js-sha256/LICENSE | 22 + .../src/fromExternal/js-sha256/index.d.ts | 149 ++ .../src/fromExternal/js-sha256/index.js | 633 +++++++++ .../react-native-sse/EventSource.ts | 331 +++++ .../src/fromExternal/react-native-sse/LICENSE | 21 + .../fromExternal/react-native-sse/index.ts | 3 + .../fromExternal/react-native-sse/types.ts | 73 + .../sdk/react-native/src/platform/autoEnv.ts | 40 + .../platform/crypto/PlatformHasher.test.ts | 62 + .../src/platform/crypto/PlatformHasher.ts | 37 + .../react-native/src/platform/crypto/index.ts | 40 + .../react-native/src/platform/crypto/types.ts | 2 + .../sdk/react-native/src/platform/index.ts | 37 +- .../sdk/react-native/src/platform/locale.ts | 12 + .../sdk/react-native/src/polyfills/btoa.ts | 6 +- .../sdk/react-native/src/polyfills/index.ts | 6 +- packages/sdk/react-native/tsconfig.json | 13 +- 24 files changed, 2687 insertions(+), 70 deletions(-) create mode 100644 packages/sdk/react-native/babel.config.js create mode 100644 packages/sdk/react-native/jestSetupFile.ts create mode 100644 packages/sdk/react-native/src/fromExternal/js-sha256/LICENSE create mode 100644 packages/sdk/react-native/src/fromExternal/js-sha256/index.d.ts create mode 100644 packages/sdk/react-native/src/fromExternal/js-sha256/index.js create mode 100644 packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts create mode 100644 packages/sdk/react-native/src/fromExternal/react-native-sse/LICENSE create mode 100644 packages/sdk/react-native/src/fromExternal/react-native-sse/index.ts create mode 100644 packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts create mode 100644 packages/sdk/react-native/src/platform/autoEnv.ts create mode 100644 packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts create mode 100644 packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts create mode 100644 packages/sdk/react-native/src/platform/crypto/index.ts create mode 100644 packages/sdk/react-native/src/platform/crypto/types.ts create mode 100644 packages/sdk/react-native/src/platform/locale.ts diff --git a/.eslintrc.js b/.eslintrc.js index 4a0af77b6..dbc1e1a4d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,7 +8,7 @@ module.exports = { project: './tsconfig.eslint.json', }, plugins: ['@typescript-eslint', 'prettier'], - ignorePatterns: ['**/dist/**', '**/vercel/examples/**'], + ignorePatterns: ['**/dist/**', '**/vercel/examples/**', '**/fromExternal/**'], rules: { '@typescript-eslint/lines-between-class-members': 'off', '@typescript-eslint/no-unused-vars': [ diff --git a/packages/sdk/react-native/babel.config.js b/packages/sdk/react-native/babel.config.js new file mode 100644 index 000000000..f842b77fc --- /dev/null +++ b/packages/sdk/react-native/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:metro-react-native-babel-preset'], +}; diff --git a/packages/sdk/react-native/example/package.json b/packages/sdk/react-native/example/package.json index df53d7ba1..5e6af48c4 100644 --- a/packages/sdk/react-native/example/package.json +++ b/packages/sdk/react-native/example/package.json @@ -27,7 +27,8 @@ "expo-splash-screen": "~0.20.5", "expo-status-bar": "~1.7.1", "react": "18.2.0", - "react-native": "0.72.6" + "react-native": "0.72.6", + "react-native-dotenv": "^3.4.9" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -37,7 +38,7 @@ "@types/react": "~18.2.14", "@types/react-native-dotenv": "^0.2.1", "detox": "^20.14.7", - "react-native-dotenv": "^3.4.9", + "jest": "^29.7.0", "ts-jest": "^29.1.1", "typescript": "^5.2.2" } diff --git a/packages/sdk/react-native/example/yarn.lock b/packages/sdk/react-native/example/yarn.lock index caafa8c67..80c8d4a39 100644 --- a/packages/sdk/react-native/example/yarn.lock +++ b/packages/sdk/react-native/example/yarn.lock @@ -46,6 +46,16 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/code-frame@npm:7.23.5" + dependencies: + "@babel/highlight": ^7.23.4 + chalk: ^2.4.2 + checksum: d90981fdf56a2824a9b14d19a4c0e8db93633fd488c772624b4e83e0ceac6039a27cd298a247c3214faa952bf803ba23696172ae7e7235f3b97f43ba278c569a + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.20.5, @babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.22.9, @babel/compat-data@npm:^7.23.3": version: 7.23.3 resolution: "@babel/compat-data@npm:7.23.3" @@ -53,6 +63,36 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/compat-data@npm:7.23.5" + checksum: 06ce244cda5763295a0ea924728c09bae57d35713b675175227278896946f922a63edf803c322f855a3878323d48d0255a2a3023409d2a123483c8a69ebb4744 + languageName: node + linkType: hard + +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3": + version: 7.23.7 + resolution: "@babel/core@npm:7.23.7" + dependencies: + "@ampproject/remapping": ^2.2.0 + "@babel/code-frame": ^7.23.5 + "@babel/generator": ^7.23.6 + "@babel/helper-compilation-targets": ^7.23.6 + "@babel/helper-module-transforms": ^7.23.3 + "@babel/helpers": ^7.23.7 + "@babel/parser": ^7.23.6 + "@babel/template": ^7.22.15 + "@babel/traverse": ^7.23.7 + "@babel/types": ^7.23.6 + convert-source-map: ^2.0.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.3 + semver: ^6.3.1 + checksum: 32d5bf73372a47429afaae9adb0af39e47bcea6a831c4b5dcbb4791380cda6949cb8cb1a2fea8b60bb1ebe189209c80e333903df1fa8e9dcb04798c0ce5bf59e + languageName: node + linkType: hard + "@babel/core@npm:^7.13.16, @babel/core@npm:^7.20.0": version: 7.23.3 resolution: "@babel/core@npm:7.23.3" @@ -88,6 +128,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.23.6, @babel/generator@npm:^7.7.2": + version: 7.23.6 + resolution: "@babel/generator@npm:7.23.6" + dependencies: + "@babel/types": ^7.23.6 + "@jridgewell/gen-mapping": ^0.3.2 + "@jridgewell/trace-mapping": ^0.3.17 + jsesc: ^2.5.1 + checksum: 1a1a1c4eac210f174cd108d479464d053930a812798e09fee069377de39a893422df5b5b146199ead7239ae6d3a04697b45fc9ac6e38e0f6b76374390f91fc6c + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-annotate-as-pure@npm:7.22.5" @@ -119,6 +171,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.23.6": + version: 7.23.6 + resolution: "@babel/helper-compilation-targets@npm:7.23.6" + dependencies: + "@babel/compat-data": ^7.23.5 + "@babel/helper-validator-option": ^7.23.5 + browserslist: ^4.22.2 + lru-cache: ^5.1.1 + semver: ^6.3.1 + checksum: c630b98d4527ac8fe2c58d9a06e785dfb2b73ec71b7c4f2ddf90f814b5f75b547f3c015f110a010fd31f76e3864daaf09f3adcd2f6acdbfb18a8de3a48717590 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.22.15": version: 7.22.15 resolution: "@babel/helper-create-class-features-plugin@npm:7.22.15" @@ -315,6 +380,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-option@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/helper-validator-option@npm:7.23.5" + checksum: 537cde2330a8aede223552510e8a13e9c1c8798afee3757995a7d4acae564124fe2bf7e7c3d90d62d3657434a74340a274b3b3b1c6f17e9a2be1f48af29cb09e + languageName: node + linkType: hard + "@babel/helper-wrap-function@npm:^7.22.20": version: 7.22.20 resolution: "@babel/helper-wrap-function@npm:7.22.20" @@ -337,6 +409,17 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.23.7": + version: 7.23.8 + resolution: "@babel/helpers@npm:7.23.8" + dependencies: + "@babel/template": ^7.22.15 + "@babel/traverse": ^7.23.7 + "@babel/types": ^7.23.6 + checksum: 8b522d527921f8df45a983dc7b8e790c021250addf81ba7900ba016e165442a527348f6f877aa55e1debb3eef9e860a334b4e8d834e6c9b438ed61a63d9a7ad4 + languageName: node + linkType: hard + "@babel/highlight@npm:^7.10.4, @babel/highlight@npm:^7.23.4": version: 7.23.4 resolution: "@babel/highlight@npm:7.23.4" @@ -348,6 +431,15 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.6": + version: 7.23.6 + resolution: "@babel/parser@npm:7.23.6" + bin: + parser: ./bin/babel-parser.js + checksum: 140801c43731a6c41fd193f5c02bc71fd647a0360ca616b23d2db8be4b9739b9f951a03fc7c2db4f9b9214f4b27c1074db0f18bc3fa653783082d5af7c8860d5 + languageName: node + linkType: hard + "@babel/parser@npm:^7.13.16, @babel/parser@npm:^7.20.0, @babel/parser@npm:^7.22.15, @babel/parser@npm:^7.23.3, @babel/parser@npm:^7.23.4": version: 7.23.4 resolution: "@babel/parser@npm:7.23.4" @@ -542,7 +634,18 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-class-properties@npm:^7.0.0, @babel/plugin-syntax-class-properties@npm:^7.12.13": +"@babel/plugin-syntax-bigint@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-bigint@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": ^7.8.0 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 3a10849d83e47aec50f367a9e56a6b22d662ddce643334b087f9828f4c3dd73bdc5909aaeabe123fed78515767f9ca43498a0e621c438d1cd2802d7fae3c9648 + languageName: node + linkType: hard + +"@babel/plugin-syntax-class-properties@npm:^7.0.0, @babel/plugin-syntax-class-properties@npm:^7.12.13, @babel/plugin-syntax-class-properties@npm:^7.8.3": version: 7.12.13 resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" dependencies: @@ -641,7 +744,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-import-meta@npm:^7.10.4": +"@babel/plugin-syntax-import-meta@npm:^7.10.4, @babel/plugin-syntax-import-meta@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" dependencies: @@ -663,7 +766,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.0.0, @babel/plugin-syntax-jsx@npm:^7.23.3": +"@babel/plugin-syntax-jsx@npm:^7.0.0, @babel/plugin-syntax-jsx@npm:^7.23.3, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.23.3 resolution: "@babel/plugin-syntax-jsx@npm:7.23.3" dependencies: @@ -674,7 +777,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4": +"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4, @babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" dependencies: @@ -696,7 +799,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-numeric-separator@npm:^7.10.4": +"@babel/plugin-syntax-numeric-separator@npm:^7.10.4, @babel/plugin-syntax-numeric-separator@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4" dependencies: @@ -751,7 +854,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-top-level-await@npm:^7.14.5": +"@babel/plugin-syntax-top-level-await@npm:^7.14.5, @babel/plugin-syntax-top-level-await@npm:^7.8.3": version: 7.14.5 resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5" dependencies: @@ -762,7 +865,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.23.3": +"@babel/plugin-syntax-typescript@npm:^7.23.3, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.23.3 resolution: "@babel/plugin-syntax-typescript@npm:7.23.3" dependencies: @@ -1617,7 +1720,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.0.0, @babel/template@npm:^7.22.15": +"@babel/template@npm:^7.0.0, @babel/template@npm:^7.22.15, @babel/template@npm:^7.3.3": version: 7.22.15 resolution: "@babel/template@npm:7.22.15" dependencies: @@ -1646,6 +1749,35 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.23.7": + version: 7.23.7 + resolution: "@babel/traverse@npm:7.23.7" + dependencies: + "@babel/code-frame": ^7.23.5 + "@babel/generator": ^7.23.6 + "@babel/helper-environment-visitor": ^7.22.20 + "@babel/helper-function-name": ^7.23.0 + "@babel/helper-hoist-variables": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/parser": ^7.23.6 + "@babel/types": ^7.23.6 + debug: ^4.3.1 + globals: ^11.1.0 + checksum: d4a7afb922361f710efc97b1e25ec343fab8b2a4ddc81ca84f9a153f22d4482112cba8f263774be8d297918b6c4767c7a98988ab4e53ac73686c986711dd002e + languageName: node + linkType: hard + +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.6, @babel/types@npm:^7.3.3": + version: 7.23.6 + resolution: "@babel/types@npm:7.23.6" + dependencies: + "@babel/helper-string-parser": ^7.23.4 + "@babel/helper-validator-identifier": ^7.22.20 + to-fast-properties: ^2.0.0 + checksum: 68187dbec0d637f79bc96263ac95ec8b06d424396678e7e225492be866414ce28ebc918a75354d4c28659be6efe30020b4f0f6df81cc418a2d30645b690a8de0 + languageName: node + linkType: hard + "@babel/types@npm:^7.20.0, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.3, @babel/types@npm:^7.23.4, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3": version: 7.23.4 resolution: "@babel/types@npm:7.23.4" @@ -1657,6 +1789,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^0.2.3": + version: 0.2.3 + resolution: "@bcoe/v8-coverage@npm:0.2.3" + checksum: 850f9305536d0f2bd13e9e0881cb5f02e4f93fad1189f7b2d4bebf694e3206924eadee1068130d43c11b750efcc9405f88a8e42ef098b6d75239c0f047de1a27 + languageName: node + linkType: hard + "@expo/bunyan@npm:4.0.0, @expo/bunyan@npm:^4.0.0": version: 4.0.0 resolution: "@expo/bunyan@npm:4.0.0" @@ -2085,6 +2224,81 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/load-nyc-config@npm:^1.0.0": + version: 1.1.0 + resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" + dependencies: + camelcase: ^5.3.1 + find-up: ^4.1.0 + get-package-type: ^0.1.0 + js-yaml: ^3.13.1 + resolve-from: ^5.0.0 + checksum: d578da5e2e804d5c93228450a1380e1a3c691de4953acc162f387b717258512a3e07b83510a936d9fab03eac90817473917e24f5d16297af3867f59328d58568 + languageName: node + linkType: hard + +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 5282759d961d61350f33d9118d16bcaed914ebf8061a52f4fa474b2cb08720c9c81d165e13b82f2e5a8a212cc5af482f0c6fc1ac27b9e067e5394c9a6ed186c9 + languageName: node + linkType: hard + +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@types/node": "*" + chalk: ^4.0.0 + jest-message-util: ^29.7.0 + jest-util: ^29.7.0 + slash: ^3.0.0 + checksum: 0e3624e32c5a8e7361e889db70b170876401b7d70f509a2538c31d5cd50deb0c1ae4b92dc63fe18a0902e0a48c590c21d53787a0df41a52b34fa7cab96c384d6 + languageName: node + linkType: hard + +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": ^29.7.0 + "@jest/reporters": ^29.7.0 + "@jest/test-result": ^29.7.0 + "@jest/transform": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + ansi-escapes: ^4.2.1 + chalk: ^4.0.0 + ci-info: ^3.2.0 + exit: ^0.1.2 + graceful-fs: ^4.2.9 + jest-changed-files: ^29.7.0 + jest-config: ^29.7.0 + jest-haste-map: ^29.7.0 + jest-message-util: ^29.7.0 + jest-regex-util: ^29.6.3 + jest-resolve: ^29.7.0 + jest-resolve-dependencies: ^29.7.0 + jest-runner: ^29.7.0 + jest-runtime: ^29.7.0 + jest-snapshot: ^29.7.0 + jest-util: ^29.7.0 + jest-validate: ^29.7.0 + jest-watcher: ^29.7.0 + micromatch: ^4.0.4 + pretty-format: ^29.7.0 + slash: ^3.0.0 + strip-ansi: ^6.0.0 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d + languageName: node + linkType: hard + "@jest/create-cache-key-function@npm:^29.2.1": version: 29.7.0 resolution: "@jest/create-cache-key-function@npm:29.7.0" @@ -2115,6 +2329,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: ^29.7.0 + jest-snapshot: ^29.7.0 + checksum: a01cb85fd9401bab3370618f4b9013b90c93536562222d920e702a0b575d239d74cecfe98010aaec7ad464f67cf534a353d92d181646a4b792acaa7e912ae55e + languageName: node + linkType: hard + "@jest/fake-timers@npm:^29.7.0": version: 29.7.0 resolution: "@jest/fake-timers@npm:29.7.0" @@ -2129,6 +2353,55 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" + dependencies: + "@jest/environment": ^29.7.0 + "@jest/expect": ^29.7.0 + "@jest/types": ^29.6.3 + jest-mock: ^29.7.0 + checksum: 97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 + languageName: node + linkType: hard + +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" + dependencies: + "@bcoe/v8-coverage": ^0.2.3 + "@jest/console": ^29.7.0 + "@jest/test-result": ^29.7.0 + "@jest/transform": ^29.7.0 + "@jest/types": ^29.6.3 + "@jridgewell/trace-mapping": ^0.3.18 + "@types/node": "*" + chalk: ^4.0.0 + collect-v8-coverage: ^1.0.0 + exit: ^0.1.2 + glob: ^7.1.3 + graceful-fs: ^4.2.9 + istanbul-lib-coverage: ^3.0.0 + istanbul-lib-instrument: ^6.0.0 + istanbul-lib-report: ^3.0.0 + istanbul-lib-source-maps: ^4.0.0 + istanbul-reports: ^3.1.3 + jest-message-util: ^29.7.0 + jest-util: ^29.7.0 + jest-worker: ^29.7.0 + slash: ^3.0.0 + string-length: ^4.0.1 + strip-ansi: ^6.0.0 + v8-to-istanbul: ^9.0.1 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 7eadabd62cc344f629024b8a268ecc8367dba756152b761bdcb7b7e570a3864fc51b2a9810cd310d85e0a0173ef002ba4528d5ea0329fbf66ee2a3ada9c40455 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -2138,6 +2411,64 @@ __metadata: languageName: node linkType: hard +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": ^0.3.18 + callsites: ^3.0.0 + graceful-fs: ^4.2.9 + checksum: bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb + languageName: node + linkType: hard + +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" + dependencies: + "@jest/console": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/istanbul-lib-coverage": ^2.0.0 + collect-v8-coverage: ^1.0.0 + checksum: 67b6317d526e335212e5da0e768e3b8ab8a53df110361b80761353ad23b6aea4432b7c5665bdeb87658ea373b90fb1afe02ed3611ef6c858c7fba377505057fa + languageName: node + linkType: hard + +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" + dependencies: + "@jest/test-result": ^29.7.0 + graceful-fs: ^4.2.9 + jest-haste-map: ^29.7.0 + slash: ^3.0.0 + checksum: 73f43599017946be85c0b6357993b038f875b796e2f0950487a82f4ebcb115fa12131932dd9904026b4ad8be131fe6e28bd8d0aa93b1563705185f9804bff8bd + languageName: node + linkType: hard + +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" + dependencies: + "@babel/core": ^7.11.6 + "@jest/types": ^29.6.3 + "@jridgewell/trace-mapping": ^0.3.18 + babel-plugin-istanbul: ^6.1.1 + chalk: ^4.0.0 + convert-source-map: ^2.0.0 + fast-json-stable-stringify: ^2.1.0 + graceful-fs: ^4.2.9 + jest-haste-map: ^29.7.0 + jest-regex-util: ^29.6.3 + jest-util: ^29.7.0 + micromatch: ^4.0.4 + pirates: ^4.0.4 + slash: ^3.0.0 + write-file-atomic: ^4.0.2 + checksum: 0f8ac9f413903b3cb6d240102db848f2a354f63971ab885833799a9964999dd51c388162106a807f810071f864302cdd8e3f0c241c29ce02d85a36f18f3f40ab + languageName: node + linkType: hard + "@jest/types@npm:^26.6.2": version: 26.6.2 resolution: "@jest/types@npm:26.6.2" @@ -2220,6 +2551,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18": + version: 0.3.22 + resolution: "@jridgewell/trace-mapping@npm:0.3.22" + dependencies: + "@jridgewell/resolve-uri": ^3.1.0 + "@jridgewell/sourcemap-codec": ^1.4.14 + checksum: ac7dd2cfe0b479aa1b81776d40d789243131cc792dc8b6b6a028c70fcd6171958ae1a71bf67b618ffe3c0c3feead9870c095ee46a5e30319410d92976b28f498 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:^0.3.17, @jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.20 resolution: "@jridgewell/trace-mapping@npm:0.3.20" @@ -2633,6 +2974,47 @@ __metadata: languageName: node linkType: hard +"@types/babel__core@npm:^7.1.14": + version: 7.20.5 + resolution: "@types/babel__core@npm:7.20.5" + dependencies: + "@babel/parser": ^7.20.7 + "@babel/types": ^7.20.7 + "@types/babel__generator": "*" + "@types/babel__template": "*" + "@types/babel__traverse": "*" + checksum: a3226f7930b635ee7a5e72c8d51a357e799d19cbf9d445710fa39ab13804f79ab1a54b72ea7d8e504659c7dfc50675db974b526142c754398d7413aa4bc30845 + languageName: node + linkType: hard + +"@types/babel__generator@npm:*": + version: 7.6.8 + resolution: "@types/babel__generator@npm:7.6.8" + dependencies: + "@babel/types": ^7.0.0 + checksum: 5b332ea336a2efffbdeedb92b6781949b73498606ddd4205462f7d96dafd45ff3618770b41de04c4881e333dd84388bfb8afbdf6f2764cbd98be550d85c6bb48 + languageName: node + linkType: hard + +"@types/babel__template@npm:*": + version: 7.4.4 + resolution: "@types/babel__template@npm:7.4.4" + dependencies: + "@babel/parser": ^7.1.0 + "@babel/types": ^7.0.0 + checksum: d7a02d2a9b67e822694d8e6a7ddb8f2b71a1d6962dfd266554d2513eefbb205b33ca71a0d163b1caea3981ccf849211f9964d8bd0727124d18ace45aa6c9ae29 + languageName: node + linkType: hard + +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": + version: 7.20.5 + resolution: "@types/babel__traverse@npm:7.20.5" + dependencies: + "@babel/types": ^7.20.7 + checksum: 608e0ab4fc31cd47011d98942e6241b34d461608c0c0e153377c5fd822c436c475f1ded76a56bfa76a1adf8d9266b727bbf9bfac90c4cb152c97f30dadc5b7e8 + languageName: node + linkType: hard + "@types/detox@npm:^18.1.0": version: 18.1.0 resolution: "@types/detox@npm:18.1.0" @@ -2642,7 +3024,16 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0": +"@types/graceful-fs@npm:^4.1.3": + version: 4.1.9 + resolution: "@types/graceful-fs@npm:4.1.9" + dependencies: + "@types/node": "*" + checksum: 79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 + languageName: node + linkType: hard + +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 @@ -3092,6 +3483,48 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" + dependencies: + "@jest/transform": ^29.7.0 + "@types/babel__core": ^7.1.14 + babel-plugin-istanbul: ^6.1.1 + babel-preset-jest: ^29.6.3 + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + slash: ^3.0.0 + peerDependencies: + "@babel/core": ^7.8.0 + checksum: ee6f8e0495afee07cac5e4ee167be705c711a8cc8a737e05a587a131fdae2b3c8f9aa55dfd4d9c03009ac2d27f2de63d8ba96d3e8460da4d00e8af19ef9a83f7 + languageName: node + linkType: hard + +"babel-plugin-istanbul@npm:^6.1.1": + version: 6.1.1 + resolution: "babel-plugin-istanbul@npm:6.1.1" + dependencies: + "@babel/helper-plugin-utils": ^7.0.0 + "@istanbuljs/load-nyc-config": ^1.0.0 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-instrument: ^5.0.4 + test-exclude: ^6.0.0 + checksum: cb4fd95738219f232f0aece1116628cccff16db891713c4ccb501cddbbf9272951a5df81f2f2658dfdf4b3e7b236a9d5cbcf04d5d8c07dd5077297339598061a + languageName: node + linkType: hard + +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" + dependencies: + "@babel/template": ^7.3.3 + "@babel/types": ^7.3.3 + "@types/babel__core": ^7.1.14 + "@types/babel__traverse": ^7.0.6 + checksum: 51250f22815a7318f17214a9d44650ba89551e6d4f47a2dc259128428324b52f5a73979d010cefd921fd5a720d8c1d55ad74ff601cd94c7bd44d5f6292fde2d1 + languageName: node + linkType: hard + "babel-plugin-module-resolver@npm:^5.0.0": version: 5.0.0 resolution: "babel-plugin-module-resolver@npm:5.0.0" @@ -3164,6 +3597,28 @@ __metadata: languageName: node linkType: hard +"babel-preset-current-node-syntax@npm:^1.0.0": + version: 1.0.1 + resolution: "babel-preset-current-node-syntax@npm:1.0.1" + dependencies: + "@babel/plugin-syntax-async-generators": ^7.8.4 + "@babel/plugin-syntax-bigint": ^7.8.3 + "@babel/plugin-syntax-class-properties": ^7.8.3 + "@babel/plugin-syntax-import-meta": ^7.8.3 + "@babel/plugin-syntax-json-strings": ^7.8.3 + "@babel/plugin-syntax-logical-assignment-operators": ^7.8.3 + "@babel/plugin-syntax-nullish-coalescing-operator": ^7.8.3 + "@babel/plugin-syntax-numeric-separator": ^7.8.3 + "@babel/plugin-syntax-object-rest-spread": ^7.8.3 + "@babel/plugin-syntax-optional-catch-binding": ^7.8.3 + "@babel/plugin-syntax-optional-chaining": ^7.8.3 + "@babel/plugin-syntax-top-level-await": ^7.8.3 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: d118c2742498c5492c095bc8541f4076b253e705b5f1ad9a2e7d302d81a84866f0070346662355c8e25fc02caa28dc2da8d69bcd67794a0d60c4d6fab6913cc8 + languageName: node + linkType: hard + "babel-preset-expo@npm:~9.5.2": version: 9.5.2 resolution: "babel-preset-expo@npm:9.5.2" @@ -3217,6 +3672,18 @@ __metadata: languageName: node linkType: hard +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" + dependencies: + babel-plugin-jest-hoist: ^29.6.3 + babel-preset-current-node-syntax: ^1.0.0 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -3359,6 +3826,20 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.22.2": + version: 4.22.2 + resolution: "browserslist@npm:4.22.2" + dependencies: + caniuse-lite: ^1.0.30001565 + electron-to-chromium: ^1.4.601 + node-releases: ^2.0.14 + update-browserslist-db: ^1.0.13 + bin: + browserslist: cli.js + checksum: 33ddfcd9145220099a7a1ac533cecfe5b7548ffeb29b313e1b57be6459000a1f8fa67e781cf4abee97268ac594d44134fcc4a6b2b4750ceddc9796e3a22076d9 + languageName: node + linkType: hard + "bs-logger@npm:0.x": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" @@ -3606,7 +4087,14 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.0.0": +"callsites@npm:^3.0.0": + version: 3.1.0 + resolution: "callsites@npm:3.1.0" + checksum: 072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3 + languageName: node + linkType: hard + +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -3627,6 +4115,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001565": + version: 1.0.30001579 + resolution: "caniuse-lite@npm:1.0.30001579" + checksum: 7539dcff74d2243a30c428393dc690c87fa34d7da36434731853e9bcfe783757763b2971f5cc878e25242a93e184e53f167d11bd74955af956579f7af71cc764 + languageName: node + linkType: hard + "chalk@npm:^2.0.1, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -3648,6 +4143,13 @@ __metadata: languageName: node linkType: hard +"char-regex@npm:^1.0.2": + version: 1.0.2 + resolution: "char-regex@npm:1.0.2" + checksum: b563e4b6039b15213114626621e7a3d12f31008bdce20f9c741d69987f62aeaace7ec30f6018890ad77b2e9b4d95324c9f5acfca58a9441e3b1dcdd1e2525d17 + languageName: node + linkType: hard + "charenc@npm:0.0.2, charenc@npm:~0.0.1": version: 0.0.2 resolution: "charenc@npm:0.0.2" @@ -3687,6 +4189,13 @@ __metadata: languageName: node linkType: hard +"cjs-module-lexer@npm:^1.0.0": + version: 1.2.3 + resolution: "cjs-module-lexer@npm:1.2.3" + checksum: 5ea3cb867a9bb609b6d476cd86590d105f3cfd6514db38ff71f63992ab40939c2feb68967faa15a6d2b1f90daa6416b79ea2de486e9e2485a6f8b66a21b4fb0a + languageName: node + linkType: hard + "clean-stack@npm:^2.0.0": version: 2.2.0 resolution: "clean-stack@npm:2.2.0" @@ -3766,6 +4275,20 @@ __metadata: languageName: node linkType: hard +"co@npm:^4.6.0": + version: 4.6.0 + resolution: "co@npm:4.6.0" + checksum: 5210d9223010eb95b29df06a91116f2cf7c8e0748a9013ed853b53f362ea0e822f1e5bb054fb3cefc645239a4cf966af1f6133a3b43f40d591f3b68ed6cf0510 + languageName: node + linkType: hard + +"collect-v8-coverage@npm:^1.0.0": + version: 1.0.2 + resolution: "collect-v8-coverage@npm:1.0.2" + checksum: c10f41c39ab84629d16f9f6137bc8a63d332244383fc368caf2d2052b5e04c20cd1fd70f66fcf4e2422b84c8226598b776d39d5f2d2a51867cc1ed5d1982b4da + languageName: node + linkType: hard + "color-convert@npm:^1.9.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -3962,6 +4485,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + chalk: ^4.0.0 + exit: ^0.1.2 + graceful-fs: ^4.2.9 + jest-config: ^29.7.0 + jest-util: ^29.7.0 + prompts: ^2.0.1 + bin: + create-jest: bin/create-jest.js + checksum: 1427d49458adcd88547ef6fa39041e1fe9033a661293aa8d2c3aa1b4967cb5bf4f0c00436c7a61816558f28ba2ba81a94d5c962e8022ea9a883978fc8e1f2945 + languageName: node + linkType: hard + "cross-fetch@npm:^3.1.5": version: 3.1.8 resolution: "cross-fetch@npm:3.1.8" @@ -4091,6 +4631,18 @@ __metadata: languageName: node linkType: hard +"dedent@npm:^1.0.0": + version: 1.5.1 + resolution: "dedent@npm:1.5.1" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: c3c300a14edf1bdf5a873f9e4b22e839d62490bc5c8d6169c1f15858a1a76733d06a9a56930e963d677a2ceeca4b6b0894cc5ea2f501aa382ca5b92af3413c2a + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -4098,7 +4650,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.3.0": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.0": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 2024c6a980a1b7128084170c4cf56b0fd58a63f2da1660dcfe977415f27b17dbe5888668b59d0b063753f3220719d5e400b7f113609489c90160bb9a5518d052 @@ -4206,6 +4758,13 @@ __metadata: languageName: node linkType: hard +"detect-newline@npm:^3.0.0": + version: 3.1.0 + resolution: "detect-newline@npm:3.1.0" + checksum: ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 + languageName: node + linkType: hard + "detox@npm:*, detox@npm:^20.14.7": version: 20.14.7 resolution: "detox@npm:20.14.7" @@ -4340,6 +4899,20 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.4.601": + version: 1.4.645 + resolution: "electron-to-chromium@npm:1.4.645" + checksum: ac7d23b8123f09e2343016216b1a8f297ccfb4ae9dccefe3716023344cda8a81656916d40a87039fa3d448cac31c2c4147c6b913b22178a3a00d0221a8019513 + languageName: node + linkType: hard + +"emittery@npm:^0.13.1": + version: 0.13.1 + resolution: "emittery@npm:0.13.1" + checksum: 2b089ab6306f38feaabf4f6f02792f9ec85fc054fda79f44f6790e61bbf6bc4e1616afb9b232e0c5ec5289a8a452f79bfa6d905a6fd64e94b49981f0934001c6 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -4556,7 +5129,14 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0": +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: abc407f07a875c3961e4781dfcb743b58d6c93de9ab263f4f8c9d23bb6da5f9b7764fc773f86b43dd88030444d5ab8abcb611cb680fba8ca075362b77114bba3 + languageName: node + linkType: hard + +"expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -4737,7 +5317,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:2.x": +"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: b191531e36c607977e5b1c47811158733c34ccb3bfde92c44798929e9b4154884378536d26ad90dfecd32e1ffc09c545d23535ad91b3161a27ddbb8ebe0cbecb @@ -4865,7 +5445,7 @@ __metadata: languageName: node linkType: hard -"find-up@npm:^4.1.0": +"find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: @@ -5096,6 +5676,13 @@ __metadata: languageName: node linkType: hard +"get-package-type@npm:^0.1.0": + version: 0.1.0 + resolution: "get-package-type@npm:0.1.0" + checksum: bba0811116d11e56d702682ddef7c73ba3481f114590e705fc549f4d868972263896af313c57a25c076e3c0d567e11d919a64ba1b30c879be985fc9d44f96148 + languageName: node + linkType: hard + "get-port@npm:^3.2.0": version: 3.2.0 resolution: "get-port@npm:3.2.0" @@ -5339,6 +5926,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: d2df2da3ad40ca9ee3a39c5cc6475ef67c8f83c234475f24d8e9ce0dc80a2c82df8e1d6fa78ddd1e9022a586ea1bd247a615e80a5cd9273d90111ddda7d9e974 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -5449,6 +6043,18 @@ __metadata: languageName: node linkType: hard +"import-local@npm:^3.0.2": + version: 3.1.0 + resolution: "import-local@npm:3.1.0" + dependencies: + pkg-dir: ^4.2.0 + resolve-cwd: ^3.0.0 + bin: + import-local-fixture: fixtures/cli.js + checksum: bfcdb63b5e3c0e245e347f3107564035b128a414c4da1172a20dc67db2504e05ede4ac2eee1252359f78b0bfd7b19ef180aec427c2fce6493ae782d73a04cddd + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -5608,6 +6214,13 @@ __metadata: languageName: node linkType: hard +"is-generator-fn@npm:^2.0.0": + version: 2.1.0 + resolution: "is-generator-fn@npm:2.1.0" + checksum: a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 + languageName: node + linkType: hard + "is-glob@npm:^2.0.0": version: 2.0.1 resolution: "is-glob@npm:2.0.1" @@ -5753,23 +6366,191 @@ __metadata: languageName: node linkType: hard -"isobject@npm:^3.0.1": - version: 3.0.1 - resolution: "isobject@npm:3.0.1" - checksum: db85c4c970ce30693676487cca0e61da2ca34e8d4967c2e1309143ff910c207133a969f9e4ddb2dc6aba670aabce4e0e307146c310350b298e74a31f7d464703 +"isobject@npm:^3.0.1": + version: 3.0.1 + resolution: "isobject@npm:3.0.1" + checksum: db85c4c970ce30693676487cca0e61da2ca34e8d4967c2e1309143ff910c207133a969f9e4ddb2dc6aba670aabce4e0e307146c310350b298e74a31f7d464703 + languageName: node + linkType: hard + +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 2367407a8d13982d8f7a859a35e7f8dd5d8f75aae4bb5484ede3a9ea1b426dc245aff28b976a2af48ee759fdd9be374ce2bd2669b644f31e76c5f46a2e29a831 + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^5.0.4": + version: 5.2.1 + resolution: "istanbul-lib-instrument@npm:5.2.1" + dependencies: + "@babel/core": ^7.12.3 + "@babel/parser": ^7.14.7 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-coverage: ^3.2.0 + semver: ^6.3.0 + checksum: bf16f1803ba5e51b28bbd49ed955a736488381e09375d830e42ddeb403855b2006f850711d95ad726f2ba3f1ae8e7366de7e51d2b9ac67dc4d80191ef7ddf272 + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.1 + resolution: "istanbul-lib-instrument@npm:6.0.1" + dependencies: + "@babel/core": ^7.12.3 + "@babel/parser": ^7.14.7 + "@istanbuljs/schema": ^0.1.2 + istanbul-lib-coverage: ^3.2.0 + semver: ^7.5.4 + checksum: fb23472e739cfc9b027cefcd7d551d5e7ca7ff2817ae5150fab99fe42786a7f7b56a29a2aa8309c37092e18297b8003f9c274f50ca4360949094d17fbac81472 + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: ^3.0.0 + make-dir: ^4.0.0 + supports-color: ^7.1.0 + checksum: fd17a1b879e7faf9bb1dc8f80b2a16e9f5b7b8498fe6ed580a618c34df0bfe53d2abd35bf8a0a00e628fb7405462576427c7df20bbe4148d19c14b431c974b21 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: ^4.1.1 + istanbul-lib-coverage: ^3.0.0 + source-map: ^0.6.1 + checksum: 21ad3df45db4b81852b662b8d4161f6446cd250c1ddc70ef96a585e2e85c26ed7cd9c2a396a71533cfb981d1a645508bc9618cae431e55d01a0628e7dec62ef2 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.3": + version: 3.1.6 + resolution: "istanbul-reports@npm:3.1.6" + dependencies: + html-escaper: ^2.0.0 + istanbul-lib-report: ^3.0.0 + checksum: 44c4c0582f287f02341e9720997f9e82c071627e1e862895745d5f52ec72c9b9f38e1d12370015d2a71dcead794f34c7732aaef3fab80a24bc617a21c3d911d6 + languageName: node + linkType: hard + +"jackspeak@npm:^2.3.5": + version: 2.3.6 + resolution: "jackspeak@npm:2.3.6" + dependencies: + "@isaacs/cliui": ^8.0.2 + "@pkgjs/parseargs": ^0.11.0 + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54 + languageName: node + linkType: hard + +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" + dependencies: + execa: ^5.0.0 + jest-util: ^29.7.0 + p-limit: ^3.1.0 + checksum: 963e203893c396c5dfc75e00a49426688efea7361b0f0e040035809cecd2d46b3c01c02be2d9e8d38b1138357d2de7719ea5b5be21f66c10f2e9685a5a73bb99 + languageName: node + linkType: hard + +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" + dependencies: + "@jest/environment": ^29.7.0 + "@jest/expect": ^29.7.0 + "@jest/test-result": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + chalk: ^4.0.0 + co: ^4.6.0 + dedent: ^1.0.0 + is-generator-fn: ^2.0.0 + jest-each: ^29.7.0 + jest-matcher-utils: ^29.7.0 + jest-message-util: ^29.7.0 + jest-runtime: ^29.7.0 + jest-snapshot: ^29.7.0 + jest-util: ^29.7.0 + p-limit: ^3.1.0 + pretty-format: ^29.7.0 + pure-rand: ^6.0.0 + slash: ^3.0.0 + stack-utils: ^2.0.3 + checksum: 349437148924a5a109c9b8aad6d393a9591b4dac1918fc97d81b7fc515bc905af9918495055071404af1fab4e48e4b04ac3593477b1d5dcf48c4e71b527c70a7 + languageName: node + linkType: hard + +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" + dependencies: + "@jest/core": ^29.7.0 + "@jest/test-result": ^29.7.0 + "@jest/types": ^29.6.3 + chalk: ^4.0.0 + create-jest: ^29.7.0 + exit: ^0.1.2 + import-local: ^3.0.2 + jest-config: ^29.7.0 + jest-util: ^29.7.0 + jest-validate: ^29.7.0 + yargs: ^17.3.1 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 664901277a3f5007ea4870632ed6e7889db9da35b2434e7cb488443e6bf5513889b344b7fddf15112135495b9875892b156faeb2d7391ddb9e2a849dcb7b6c36 languageName: node linkType: hard -"jackspeak@npm:^2.3.5": - version: 2.3.6 - resolution: "jackspeak@npm:2.3.6" +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" dependencies: - "@isaacs/cliui": ^8.0.2 - "@pkgjs/parseargs": ^0.11.0 - dependenciesMeta: - "@pkgjs/parseargs": + "@babel/core": ^7.11.6 + "@jest/test-sequencer": ^29.7.0 + "@jest/types": ^29.6.3 + babel-jest: ^29.7.0 + chalk: ^4.0.0 + ci-info: ^3.2.0 + deepmerge: ^4.2.2 + glob: ^7.1.3 + graceful-fs: ^4.2.9 + jest-circus: ^29.7.0 + jest-environment-node: ^29.7.0 + jest-get-type: ^29.6.3 + jest-regex-util: ^29.6.3 + jest-resolve: ^29.7.0 + jest-runner: ^29.7.0 + jest-util: ^29.7.0 + jest-validate: ^29.7.0 + micromatch: ^4.0.4 + parse-json: ^5.2.0 + pretty-format: ^29.7.0 + slash: ^3.0.0 + strip-json-comments: ^3.1.1 + peerDependencies: + "@types/node": "*" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": optional: true - checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54 + ts-node: + optional: true + checksum: 4cabf8f894c180cac80b7df1038912a3fc88f96f2622de33832f4b3314f83e22b08fb751da570c0ab2b7988f21604bdabade95e3c0c041068ac578c085cf7dff languageName: node linkType: hard @@ -5785,6 +6566,28 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" + dependencies: + detect-newline: ^3.0.0 + checksum: 66390c3e9451f8d96c5da62f577a1dad701180cfa9b071c5025acab2f94d7a3efc2515cfa1654ebe707213241541ce9c5530232cdc8017c91ed64eea1bd3b192 + languageName: node + linkType: hard + +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + chalk: ^4.0.0 + jest-get-type: ^29.6.3 + jest-util: ^29.7.0 + pretty-format: ^29.7.0 + checksum: e88f99f0184000fc8813f2a0aa79e29deeb63700a3b9b7928b8a418d7d93cd24933608591dbbdea732b473eb2021c72991b5cc51a17966842841c6e28e6f691c + languageName: node + linkType: hard + "jest-environment-emit@npm:^1.0.5": version: 1.0.5 resolution: "jest-environment-emit@npm:1.0.5" @@ -5818,7 +6621,7 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:^29.2.1": +"jest-environment-node@npm:^29.2.1, jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" dependencies: @@ -5839,6 +6642,39 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@types/graceful-fs": ^4.1.3 + "@types/node": "*" + anymatch: ^3.0.3 + fb-watchman: ^2.0.0 + fsevents: ^2.3.2 + graceful-fs: ^4.2.9 + jest-regex-util: ^29.6.3 + jest-util: ^29.7.0 + jest-worker: ^29.7.0 + micromatch: ^4.0.4 + walker: ^1.0.8 + dependenciesMeta: + fsevents: + optional: true + checksum: c2c8f2d3e792a963940fbdfa563ce14ef9e14d4d86da645b96d3cd346b8d35c5ce0b992ee08593939b5f718cf0a1f5a90011a056548a1dbf58397d4356786f01 + languageName: node + linkType: hard + +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" + dependencies: + jest-get-type: ^29.6.3 + pretty-format: ^29.7.0 + checksum: e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -5879,6 +6715,18 @@ __metadata: languageName: node linkType: hard +"jest-pnp-resolver@npm:^1.2.2": + version: 1.2.3 + resolution: "jest-pnp-resolver@npm:1.2.3" + peerDependencies: + jest-resolve: "*" + peerDependenciesMeta: + jest-resolve: + optional: true + checksum: db1a8ab2cb97ca19c01b1cfa9a9c8c69a143fde833c14df1fab0766f411b1148ff0df878adea09007ac6a2085ec116ba9a996a6ad104b1e58c20adbf88eed9b2 + languageName: node + linkType: hard + "jest-regex-util@npm:^27.0.6": version: 27.5.1 resolution: "jest-regex-util@npm:27.5.1" @@ -5886,6 +6734,127 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a + languageName: node + linkType: hard + +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" + dependencies: + jest-regex-util: ^29.6.3 + jest-snapshot: ^29.7.0 + checksum: aeb75d8150aaae60ca2bb345a0d198f23496494677cd6aefa26fc005faf354061f073982175daaf32b4b9d86b26ca928586344516e3e6969aa614cb13b883984 + languageName: node + linkType: hard + +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" + dependencies: + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + jest-haste-map: ^29.7.0 + jest-pnp-resolver: ^1.2.2 + jest-util: ^29.7.0 + jest-validate: ^29.7.0 + resolve: ^1.20.0 + resolve.exports: ^2.0.0 + slash: ^3.0.0 + checksum: 0ca218e10731aa17920526ec39deaec59ab9b966237905ffc4545444481112cd422f01581230eceb7e82d86f44a543d520a71391ec66e1b4ef1a578bd5c73487 + languageName: node + linkType: hard + +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" + dependencies: + "@jest/console": ^29.7.0 + "@jest/environment": ^29.7.0 + "@jest/test-result": ^29.7.0 + "@jest/transform": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + chalk: ^4.0.0 + emittery: ^0.13.1 + graceful-fs: ^4.2.9 + jest-docblock: ^29.7.0 + jest-environment-node: ^29.7.0 + jest-haste-map: ^29.7.0 + jest-leak-detector: ^29.7.0 + jest-message-util: ^29.7.0 + jest-resolve: ^29.7.0 + jest-runtime: ^29.7.0 + jest-util: ^29.7.0 + jest-watcher: ^29.7.0 + jest-worker: ^29.7.0 + p-limit: ^3.1.0 + source-map-support: 0.5.13 + checksum: f0405778ea64812bf9b5c50b598850d94ccf95d7ba21f090c64827b41decd680ee19fcbb494007cdd7f5d0d8906bfc9eceddd8fa583e753e736ecd462d4682fb + languageName: node + linkType: hard + +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": ^29.7.0 + "@jest/fake-timers": ^29.7.0 + "@jest/globals": ^29.7.0 + "@jest/source-map": ^29.6.3 + "@jest/test-result": ^29.7.0 + "@jest/transform": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + chalk: ^4.0.0 + cjs-module-lexer: ^1.0.0 + collect-v8-coverage: ^1.0.0 + glob: ^7.1.3 + graceful-fs: ^4.2.9 + jest-haste-map: ^29.7.0 + jest-message-util: ^29.7.0 + jest-mock: ^29.7.0 + jest-regex-util: ^29.6.3 + jest-resolve: ^29.7.0 + jest-snapshot: ^29.7.0 + jest-util: ^29.7.0 + slash: ^3.0.0 + strip-bom: ^4.0.0 + checksum: d19f113d013e80691e07047f68e1e3448ef024ff2c6b586ce4f90cd7d4c62a2cd1d460110491019719f3c59bfebe16f0e201ed005ef9f80e2cf798c374eed54e + languageName: node + linkType: hard + +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" + dependencies: + "@babel/core": ^7.11.6 + "@babel/generator": ^7.7.2 + "@babel/plugin-syntax-jsx": ^7.7.2 + "@babel/plugin-syntax-typescript": ^7.7.2 + "@babel/types": ^7.3.3 + "@jest/expect-utils": ^29.7.0 + "@jest/transform": ^29.7.0 + "@jest/types": ^29.6.3 + babel-preset-current-node-syntax: ^1.0.0 + chalk: ^4.0.0 + expect: ^29.7.0 + graceful-fs: ^4.2.9 + jest-diff: ^29.7.0 + jest-get-type: ^29.6.3 + jest-matcher-utils: ^29.7.0 + jest-message-util: ^29.7.0 + jest-util: ^29.7.0 + natural-compare: ^1.4.0 + pretty-format: ^29.7.0 + semver: ^7.5.3 + checksum: 86821c3ad0b6899521ce75ee1ae7b01b17e6dfeff9166f2cf17f012e0c5d8c798f30f9e4f8f7f5bed01ea7b55a6bc159f5eda778311162cbfa48785447c237ad + languageName: node + linkType: hard + "jest-util@npm:^27.2.0": version: 27.5.1 resolution: "jest-util@npm:27.5.1" @@ -5914,7 +6883,7 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:^29.2.1": +"jest-validate@npm:^29.2.1, jest-validate@npm:^29.7.0": version: 29.7.0 resolution: "jest-validate@npm:29.7.0" dependencies: @@ -5928,6 +6897,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" + dependencies: + "@jest/test-result": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + ansi-escapes: ^4.2.1 + chalk: ^4.0.0 + emittery: ^0.13.1 + jest-util: ^29.7.0 + string-length: ^4.0.1 + checksum: 67e6e7fe695416deff96b93a14a561a6db69389a0667e9489f24485bb85e5b54e12f3b2ba511ec0b777eca1e727235b073e3ebcdd473d68888650489f88df92f + languageName: node + linkType: hard + "jest-worker@npm:^27.2.0": version: 27.5.1 resolution: "jest-worker@npm:27.5.1" @@ -5939,6 +6924,37 @@ __metadata: languageName: node linkType: hard +"jest-worker@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-worker@npm:29.7.0" + dependencies: + "@types/node": "*" + jest-util: ^29.7.0 + merge-stream: ^2.0.0 + supports-color: ^8.0.0 + checksum: 30fff60af49675273644d408b650fc2eb4b5dcafc5a0a455f238322a8f9d8a98d847baca9d51ff197b6747f54c7901daa2287799230b856a0f48287d131f8c13 + languageName: node + linkType: hard + +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" + dependencies: + "@jest/core": ^29.7.0 + "@jest/types": ^29.6.3 + import-local: ^3.0.2 + jest-cli: ^29.7.0 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 17ca8d67504a7dbb1998cf3c3077ec9031ba3eb512da8d71cb91bcabb2b8995c4e4b292b740cb9bf1cbff5ce3e110b3f7c777b0cefb6f41ab05445f248d0ee0b + languageName: node + linkType: hard + "jimp-compact@npm:0.16.1": version: 0.16.1 resolution: "jimp-compact@npm:0.16.1" @@ -6089,6 +7105,13 @@ __metadata: languageName: node linkType: hard +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 798ed4cf3354a2d9ccd78e86d2169515a0097a5c133337807cdf7f1fc32e1391d207ccfc276518cc1d7d8d4db93288b8a50ba4293d212ad1336e52a8ec0a941f + languageName: node + linkType: hard + "json-schema-deref-sync@npm:^0.13.0": version: 0.13.0 resolution: "json-schema-deref-sync@npm:0.13.0" @@ -6415,6 +7438,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: ^7.5.3 + checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-error@npm:1.x": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -7138,6 +8170,13 @@ __metadata: languageName: node linkType: hard +"natural-compare@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare@npm:1.4.0" + checksum: 23ad088b08f898fc9b53011d7bb78ec48e79de7627e01ab5518e806033861bef68d5b0cd0e2205c2f36690ac9571ff6bcb05eb777ced2eeda8d4ac5b44592c3d + languageName: node + linkType: hard + "ncp@npm:~2.0.0": version: 2.0.0 resolution: "ncp@npm:2.0.0" @@ -7264,6 +8303,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.14": + version: 2.0.14 + resolution: "node-releases@npm:2.0.14" + checksum: 59443a2f77acac854c42d321bf1b43dea0aef55cd544c6a686e9816a697300458d4e82239e2d794ea05f7bbbc8a94500332e2d3ac3f11f52e4b16cbe638b3c41 + languageName: node + linkType: hard + "node-stream-zip@npm:^1.9.1": version: 1.15.0 resolution: "node-stream-zip@npm:1.15.0" @@ -7497,7 +8543,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2": +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -7559,6 +8605,18 @@ __metadata: languageName: node linkType: hard +"parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": ^7.0.0 + error-ex: ^1.3.1 + json-parse-even-better-errors: ^2.3.0 + lines-and-columns: ^1.1.6 + checksum: 62085b17d64da57f40f6afc2ac1f4d95def18c4323577e1eced571db75d9ab59b297d1d10582920f84b15985cbfc6b6d450ccbf317644cfa176f3ed982ad87e2 + languageName: node + linkType: hard + "parse-png@npm:^2.1.0": version: 2.1.0 resolution: "parse-png@npm:2.1.0" @@ -7672,7 +8730,7 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.1, pirates@npm:^4.0.5": +"pirates@npm:^4.0.1, pirates@npm:^4.0.4, pirates@npm:^4.0.5": version: 4.0.6 resolution: "pirates@npm:4.0.6" checksum: 46a65fefaf19c6f57460388a5af9ab81e3d7fd0e7bc44ca59d753cb5c4d0df97c6c6e583674869762101836d68675f027d60f841c105d72734df9dfca97cbcc6 @@ -7688,6 +8746,15 @@ __metadata: languageName: node linkType: hard +"pkg-dir@npm:^4.2.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: ^4.0.0 + checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 + languageName: node + linkType: hard + "pkg-up@npm:^3.1.0": version: 3.1.0 resolution: "pkg-up@npm:3.1.0" @@ -7819,7 +8886,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.3.2, prompts@npm:^2.4.0": +"prompts@npm:^2.0.1, prompts@npm:^2.3.2, prompts@npm:^2.4.0": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -7875,6 +8942,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.0.4 + resolution: "pure-rand@npm:6.0.4" + checksum: e1c4e69f8bf7303e5252756d67c3c7551385cd34d94a1f511fe099727ccbab74c898c03a06d4c4a24a89b51858781057b83ebbfe740d984240cdc04fead36068 + languageName: node + linkType: hard + "qrcode-terminal@npm:0.11.0": version: 0.11.0 resolution: "qrcode-terminal@npm:0.11.0" @@ -8006,6 +9080,7 @@ __metadata: expo: ~49.0.16 expo-splash-screen: ~0.20.5 expo-status-bar: ~1.7.1 + jest: ^29.7.0 react: 18.2.0 react-native: 0.72.6 react-native-dotenv: ^3.4.9 @@ -8252,6 +9327,15 @@ __metadata: languageName: node linkType: hard +"resolve-cwd@npm:^3.0.0": + version: 3.0.0 + resolution: "resolve-cwd@npm:3.0.0" + dependencies: + resolve-from: ^5.0.0 + checksum: 546e0816012d65778e580ad62b29e975a642989108d9a3c5beabfb2304192fa3c9f9146fbdfe213563c6ff51975ae41bac1d3c6e047dd9572c94863a057b4d81 + languageName: node + linkType: hard + "resolve-from@npm:^3.0.0": version: 3.0.0 resolution: "resolve-from@npm:3.0.0" @@ -8266,7 +9350,14 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.14.2, resolve@npm:^1.22.1": +"resolve.exports@npm:^2.0.0": + version: 2.0.2 + resolution: "resolve.exports@npm:2.0.2" + checksum: 1c7778ca1b86a94f8ab4055d196c7d87d1874b96df4d7c3e67bbf793140f0717fd506dcafd62785b079cd6086b9264424ad634fb904409764c3509c3df1653f2 + languageName: node + linkType: hard + +"resolve@npm:^1.14.2, resolve@npm:^1.20.0, resolve@npm:^1.22.1": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -8288,7 +9379,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.22.1#~builtin": +"resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.1#~builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d" dependencies: @@ -8479,7 +9570,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.1": +"semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -8488,7 +9579,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.0.0, semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.5.3": +"semver@npm:^7.0.0, semver@npm:^7.3.5, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4": version: 7.5.4 resolution: "semver@npm:7.5.4" dependencies: @@ -8649,7 +9740,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -8741,6 +9832,16 @@ __metadata: languageName: node linkType: hard +"source-map-support@npm:0.5.13": + version: 0.5.13 + resolution: "source-map-support@npm:0.5.13" + dependencies: + buffer-from: ^1.0.0 + source-map: ^0.6.0 + checksum: 933550047b6c1a2328599a21d8b7666507427c0f5ef5eaadd56b5da0fd9505e239053c66fe181bf1df469a3b7af9d775778eee283cbb7ae16b902ddc09e93a97 + languageName: node + linkType: hard + "source-map-support@npm:^0.5.16, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" @@ -8758,7 +9859,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:~0.6.1": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2 @@ -8868,6 +9969,16 @@ __metadata: languageName: node linkType: hard +"string-length@npm:^4.0.1": + version: 4.0.2 + resolution: "string-length@npm:4.0.2" + dependencies: + char-regex: ^1.0.2 + strip-ansi: ^6.0.0 + checksum: ce85533ef5113fcb7e522bcf9e62cb33871aa99b3729cec5595f4447f660b0cefd542ca6df4150c97a677d58b0cb727a3fe09ac1de94071d05526c73579bf505 + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -8935,6 +10046,13 @@ __metadata: languageName: node linkType: hard +"strip-bom@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-bom@npm:4.0.0" + checksum: 9dbcfbaf503c57c06af15fe2c8176fb1bf3af5ff65003851a102749f875a6dbe0ab3b30115eccf6e805e9d756830d3e40ec508b62b3f1ddf3761a20ebe29d3f3 + languageName: node + linkType: hard + "strip-eof@npm:^1.0.0": version: 1.0.0 resolution: "strip-eof@npm:1.0.0" @@ -8949,6 +10067,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^3.1.1": + version: 3.1.1 + resolution: "strip-json-comments@npm:3.1.1" + checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443 + languageName: node + linkType: hard + "strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" @@ -9157,6 +10282,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^6.0.0": + version: 6.0.0 + resolution: "test-exclude@npm:6.0.0" + dependencies: + "@istanbuljs/schema": ^0.1.2 + glob: ^7.1.4 + minimatch: ^3.0.4 + checksum: 3b34a3d77165a2cb82b34014b3aba93b1c4637a5011807557dc2f3da826c59975a5ccad765721c4648b39817e3472789f9b0fa98fc854c5c1c7a1e632aacdc28 + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -9639,6 +10775,17 @@ __metadata: languageName: node linkType: hard +"v8-to-istanbul@npm:^9.0.1": + version: 9.2.0 + resolution: "v8-to-istanbul@npm:9.2.0" + dependencies: + "@jridgewell/trace-mapping": ^0.3.12 + "@types/istanbul-lib-coverage": ^2.0.1 + convert-source-map: ^2.0.0 + checksum: 31ef98c6a31b1dab6be024cf914f235408cd4c0dc56a5c744a5eea1a9e019ba279e1b6f90d695b78c3186feed391ed492380ccf095009e2eb91f3d058f0b4491 + languageName: node + linkType: hard + "valid-url@npm:~1.0.9": version: 1.0.9 resolution: "valid-url@npm:1.0.9" @@ -9669,7 +10816,7 @@ __metadata: languageName: node linkType: hard -"walker@npm:^1.0.7": +"walker@npm:^1.0.7, walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" dependencies: @@ -9816,6 +10963,16 @@ __metadata: languageName: node linkType: hard +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: ^0.1.4 + signal-exit: ^3.0.7 + checksum: 5da60bd4eeeb935eec97ead3df6e28e5917a6bd317478e4a85a5285e8480b8ed96032bbcc6ecd07b236142a24f3ca871c924ec4a6575e623ec1b11bf8c1c253c + languageName: node + linkType: hard + "ws@npm:^6.2.2": version: 6.2.2 resolution: "ws@npm:6.2.2" @@ -9993,7 +11150,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.0.0, yargs@npm:^17.6.2": +"yargs@npm:^17.0.0, yargs@npm:^17.3.1, yargs@npm:^17.6.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: diff --git a/packages/sdk/react-native/jest.config.ts b/packages/sdk/react-native/jest.config.ts index a740765ad..68246526c 100644 --- a/packages/sdk/react-native/jest.config.ts +++ b/packages/sdk/react-native/jest.config.ts @@ -1,7 +1,9 @@ import type { JestConfigWithTsJest } from 'ts-jest'; +import { defaults as tsjPreset } from 'ts-jest/presets'; const jestConfig: JestConfigWithTsJest = { - preset: 'ts-jest', + ...tsjPreset, + preset: 'react-native', testEnvironment: 'jsdom', transform: { '^.+\\.tsx?$': [ @@ -12,6 +14,7 @@ const jestConfig: JestConfigWithTsJest = { ], }, testPathIgnorePatterns: ['node_modules', 'example', 'dist'], + setupFiles: ['./jestSetupFile.ts'], }; export default jestConfig; diff --git a/packages/sdk/react-native/jestSetupFile.ts b/packages/sdk/react-native/jestSetupFile.ts new file mode 100644 index 000000000..03f14b96f --- /dev/null +++ b/packages/sdk/react-native/jestSetupFile.ts @@ -0,0 +1,19 @@ +jest.mock('@react-native-async-storage/async-storage', () => + jest.requireActual('@react-native-async-storage/async-storage/jest/async-storage-mock'), +); + +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + RN.NativeModules.SettingsManager = { + settings: { + AppleLocale: 'en-us', + }, + }; + + // HACK: force set Platform which is read-only + Object.defineProperty(RN.Platform, 'Version', { + get: () => 21, + }); + + return RN; +}); diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index 556f4bd74..13d3c1eec 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -64,6 +64,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.7.0", "launchdarkly-js-test-helpers": "^2.2.0", + "metro-react-native-babel-preset": "^0.77.0", "prettier": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/sdk/react-native/src/fromExternal/js-sha256/LICENSE b/packages/sdk/react-native/src/fromExternal/js-sha256/LICENSE new file mode 100644 index 000000000..d06f6d49a --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/js-sha256/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014-2023 Chen, Yi-Cyuan + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/sdk/react-native/src/fromExternal/js-sha256/index.d.ts b/packages/sdk/react-native/src/fromExternal/js-sha256/index.d.ts new file mode 100644 index 000000000..1ac455ecb --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/js-sha256/index.d.ts @@ -0,0 +1,149 @@ +type Message = string | number[] | ArrayBuffer | Uint8Array; + +interface Hasher { + /** + * Update hash + * + * @param message The message you want to hash. + */ + update(message: Message): Hasher; + + /** + * Return hash in hex string. + */ + hex(): string; + + /** + * Return hash in hex string. + */ + toString(): string; + + /** + * Return hash in ArrayBuffer. + */ + arrayBuffer(): ArrayBuffer; + + /** + * Return hash in integer array. + */ + digest(): number[]; + + /** + * Return hash in integer array. + */ + array(): number[]; +} + +interface Hmac { + /** + * Computes a Hash-based message authentication code (HMAC) using a secret key + * + * @param secretKey The Secret Key + * @param message The message you want to hash. + */ + (secretKey: Message, message: Message): string; + + /** + * Create a hash object using a secret key. + * + * @param secretKey The Secret Key + */ + create(secretKey: Message): Hasher; + + /** + * Create a hash object and hash message using a secret key + * + * @param secretKey The Secret Key + * @param message The message you want to hash. + */ + update(secretKey: Message, message: Message): Hasher; + + /** + * Return hash in hex string. + * + * @param secretKey The Secret Key + * @param message The message you want to hash. + */ + hex(secretKey: Message, message: Message): string; + + /** + * Return hash in ArrayBuffer. + * + * @param secretKey The Secret Key + * @param message The message you want to hash. + */ + arrayBuffer(secretKey: Message, message: Message): ArrayBuffer; + + /** + * Return hash in integer array. + * + * @param secretKey The Secret Key + * @param message The message you want to hash. + */ + digest(secretKey: Message, message: Message): number[]; + + /** + * Return hash in integer array. + * + * @param secretKey The Secret Key + * @param message The message you want to hash. + */ + array(secretKey: Message, message: Message): number[]; +} + +interface Hash { + /** + * Hash and return hex string. + * + * @param message The message you want to hash. + */ + (message: Message): string; + + /** + * Create a hash object. + */ + create(): Hasher; + + /** + * Create a hash object and hash message. + * + * @param message The message you want to hash. + */ + update(message: Message): Hasher; + + /** + * Return hash in hex string. + * + * @param message The message you want to hash. + */ + hex(message: Message): string; + + /** + * Return hash in ArrayBuffer. + * + * @param message The message you want to hash. + */ + arrayBuffer(message: Message): ArrayBuffer; + + /** + * Return hash in integer array. + * + * @param message The message you want to hash. + */ + digest(message: Message): number[]; + + /** + * Return hash in integer array. + * + * @param message The message you want to hash. + */ + array(message: Message): number[]; + + /** + * HMAC interface + */ + hmac: Hmac; +} + +export var sha256: Hash; +export var sha224: Hash; diff --git a/packages/sdk/react-native/src/fromExternal/js-sha256/index.js b/packages/sdk/react-native/src/fromExternal/js-sha256/index.js new file mode 100644 index 000000000..6edd54555 --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/js-sha256/index.js @@ -0,0 +1,633 @@ +/** + * [js-sha256]{@link https://github.com/emn178/js-sha256} + * + * @version 0.10.1 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2014-2023 + * @license MIT + */ +/*jslint bitwise: true */ +(function () { + 'use strict'; + + var ERROR = 'input is invalid type'; + var WINDOW = typeof window === 'object'; + var root = WINDOW ? window : {}; + if (root.JS_SHA256_NO_WINDOW) { + WINDOW = false; + } + var WEB_WORKER = !WINDOW && typeof self === 'object'; + var NODE_JS = + !root.JS_SHA256_NO_NODE_JS && + typeof process === 'object' && + process.versions && + process.versions.node; + if (NODE_JS) { + root = global; + } else if (WEB_WORKER) { + root = self; + } + var COMMON_JS = !root.JS_SHA256_NO_COMMON_JS && typeof module === 'object' && module.exports; + var AMD = typeof define === 'function' && define.amd; + var ARRAY_BUFFER = !root.JS_SHA256_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined'; + var HEX_CHARS = '0123456789abcdef'.split(''); + var EXTRA = [-2147483648, 8388608, 32768, 128]; + var SHIFT = [24, 16, 8, 0]; + var K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + var OUTPUT_TYPES = ['hex', 'array', 'digest', 'arrayBuffer']; + + var blocks = []; + + if (root.JS_SHA256_NO_NODE_JS || !Array.isArray) { + Array.isArray = function (obj) { + return Object.prototype.toString.call(obj) === '[object Array]'; + }; + } + + if (ARRAY_BUFFER && (root.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) { + ArrayBuffer.isView = function (obj) { + return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; + }; + } + + var createOutputMethod = function (outputType, is224) { + return function (message) { + return new Sha256(is224, true).update(message)[outputType](); + }; + }; + + var createMethod = function (is224) { + var method = createOutputMethod('hex', is224); + method.create = function () { + return new Sha256(is224); + }; + method.update = function (message) { + return method.create().update(message); + }; + for (var i = 0; i < OUTPUT_TYPES.length; ++i) { + var type = OUTPUT_TYPES[i]; + method[type] = createOutputMethod(type, is224); + } + return method; + }; + + var createHmacOutputMethod = function (outputType, is224) { + return function (key, message) { + return new HmacSha256(key, is224, true).update(message)[outputType](); + }; + }; + + var createHmacMethod = function (is224) { + var method = createHmacOutputMethod('hex', is224); + method.create = function (key) { + return new HmacSha256(key, is224); + }; + method.update = function (key, message) { + return method.create(key).update(message); + }; + for (var i = 0; i < OUTPUT_TYPES.length; ++i) { + var type = OUTPUT_TYPES[i]; + method[type] = createHmacOutputMethod(type, is224); + } + return method; + }; + + function Sha256(is224, sharedMemory) { + if (sharedMemory) { + blocks[0] = + blocks[16] = + blocks[1] = + blocks[2] = + blocks[3] = + blocks[4] = + blocks[5] = + blocks[6] = + blocks[7] = + blocks[8] = + blocks[9] = + blocks[10] = + blocks[11] = + blocks[12] = + blocks[13] = + blocks[14] = + blocks[15] = + 0; + this.blocks = blocks; + } else { + this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + } + + if (is224) { + this.h0 = 0xc1059ed8; + this.h1 = 0x367cd507; + this.h2 = 0x3070dd17; + this.h3 = 0xf70e5939; + this.h4 = 0xffc00b31; + this.h5 = 0x68581511; + this.h6 = 0x64f98fa7; + this.h7 = 0xbefa4fa4; + } else { + // 256 + this.h0 = 0x6a09e667; + this.h1 = 0xbb67ae85; + this.h2 = 0x3c6ef372; + this.h3 = 0xa54ff53a; + this.h4 = 0x510e527f; + this.h5 = 0x9b05688c; + this.h6 = 0x1f83d9ab; + this.h7 = 0x5be0cd19; + } + + this.block = this.start = this.bytes = this.hBytes = 0; + this.finalized = this.hashed = false; + this.first = true; + this.is224 = is224; + } + + Sha256.prototype.update = function (message) { + if (this.finalized) { + return; + } + var notString, + type = typeof message; + if (type !== 'string') { + if (type === 'object') { + if (message === null) { + throw new Error(ERROR); + } else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { + message = new Uint8Array(message); + } else if (!Array.isArray(message)) { + if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { + throw new Error(ERROR); + } + } + } else { + throw new Error(ERROR); + } + notString = true; + } + var code, + index = 0, + i, + length = message.length, + blocks = this.blocks; + + while (index < length) { + if (this.hashed) { + this.hashed = false; + blocks[0] = this.block; + blocks[16] = + blocks[1] = + blocks[2] = + blocks[3] = + blocks[4] = + blocks[5] = + blocks[6] = + blocks[7] = + blocks[8] = + blocks[9] = + blocks[10] = + blocks[11] = + blocks[12] = + blocks[13] = + blocks[14] = + blocks[15] = + 0; + } + + if (notString) { + for (i = this.start; index < length && i < 64; ++index) { + blocks[i >> 2] |= message[index] << SHIFT[i++ & 3]; + } + } else { + for (i = this.start; index < length && i < 64; ++index) { + code = message.charCodeAt(index); + if (code < 0x80) { + blocks[i >> 2] |= code << SHIFT[i++ & 3]; + } else if (code < 0x800) { + blocks[i >> 2] |= (0xc0 | (code >> 6)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else if (code < 0xd800 || code >= 0xe000) { + blocks[i >> 2] |= (0xe0 | (code >> 12)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); + blocks[i >> 2] |= (0xf0 | (code >> 18)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 12) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } + } + } + + this.lastByteIndex = i; + this.bytes += i - this.start; + if (i >= 64) { + this.block = blocks[16]; + this.start = i - 64; + this.hash(); + this.hashed = true; + } else { + this.start = i; + } + } + if (this.bytes > 4294967295) { + this.hBytes += (this.bytes / 4294967296) << 0; + this.bytes = this.bytes % 4294967296; + } + return this; + }; + + Sha256.prototype.finalize = function () { + if (this.finalized) { + return; + } + this.finalized = true; + var blocks = this.blocks, + i = this.lastByteIndex; + blocks[16] = this.block; + blocks[i >> 2] |= EXTRA[i & 3]; + this.block = blocks[16]; + if (i >= 56) { + if (!this.hashed) { + this.hash(); + } + blocks[0] = this.block; + blocks[16] = + blocks[1] = + blocks[2] = + blocks[3] = + blocks[4] = + blocks[5] = + blocks[6] = + blocks[7] = + blocks[8] = + blocks[9] = + blocks[10] = + blocks[11] = + blocks[12] = + blocks[13] = + blocks[14] = + blocks[15] = + 0; + } + blocks[14] = (this.hBytes << 3) | (this.bytes >>> 29); + blocks[15] = this.bytes << 3; + this.hash(); + }; + + Sha256.prototype.hash = function () { + var a = this.h0, + b = this.h1, + c = this.h2, + d = this.h3, + e = this.h4, + f = this.h5, + g = this.h6, + h = this.h7, + blocks = this.blocks, + j, + s0, + s1, + maj, + t1, + t2, + ch, + ab, + da, + cd, + bc; + + for (j = 16; j < 64; ++j) { + // rightrotate + t1 = blocks[j - 15]; + s0 = ((t1 >>> 7) | (t1 << 25)) ^ ((t1 >>> 18) | (t1 << 14)) ^ (t1 >>> 3); + t1 = blocks[j - 2]; + s1 = ((t1 >>> 17) | (t1 << 15)) ^ ((t1 >>> 19) | (t1 << 13)) ^ (t1 >>> 10); + blocks[j] = (blocks[j - 16] + s0 + blocks[j - 7] + s1) << 0; + } + + bc = b & c; + for (j = 0; j < 64; j += 4) { + if (this.first) { + if (this.is224) { + ab = 300032; + t1 = blocks[0] - 1413257819; + h = (t1 - 150054599) << 0; + d = (t1 + 24177077) << 0; + } else { + ab = 704751109; + t1 = blocks[0] - 210244248; + h = (t1 - 1521486534) << 0; + d = (t1 + 143694565) << 0; + } + this.first = false; + } else { + s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); + s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); + ab = a & b; + maj = ab ^ (a & c) ^ bc; + ch = (e & f) ^ (~e & g); + t1 = h + s1 + ch + K[j] + blocks[j]; + t2 = s0 + maj; + h = (d + t1) << 0; + d = (t1 + t2) << 0; + } + s0 = ((d >>> 2) | (d << 30)) ^ ((d >>> 13) | (d << 19)) ^ ((d >>> 22) | (d << 10)); + s1 = ((h >>> 6) | (h << 26)) ^ ((h >>> 11) | (h << 21)) ^ ((h >>> 25) | (h << 7)); + da = d & a; + maj = da ^ (d & b) ^ ab; + ch = (h & e) ^ (~h & f); + t1 = g + s1 + ch + K[j + 1] + blocks[j + 1]; + t2 = s0 + maj; + g = (c + t1) << 0; + c = (t1 + t2) << 0; + s0 = ((c >>> 2) | (c << 30)) ^ ((c >>> 13) | (c << 19)) ^ ((c >>> 22) | (c << 10)); + s1 = ((g >>> 6) | (g << 26)) ^ ((g >>> 11) | (g << 21)) ^ ((g >>> 25) | (g << 7)); + cd = c & d; + maj = cd ^ (c & a) ^ da; + ch = (g & h) ^ (~g & e); + t1 = f + s1 + ch + K[j + 2] + blocks[j + 2]; + t2 = s0 + maj; + f = (b + t1) << 0; + b = (t1 + t2) << 0; + s0 = ((b >>> 2) | (b << 30)) ^ ((b >>> 13) | (b << 19)) ^ ((b >>> 22) | (b << 10)); + s1 = ((f >>> 6) | (f << 26)) ^ ((f >>> 11) | (f << 21)) ^ ((f >>> 25) | (f << 7)); + bc = b & c; + maj = bc ^ (b & d) ^ cd; + ch = (f & g) ^ (~f & h); + t1 = e + s1 + ch + K[j + 3] + blocks[j + 3]; + t2 = s0 + maj; + e = (a + t1) << 0; + a = (t1 + t2) << 0; + this.chromeBugWorkAround = true; + } + + this.h0 = (this.h0 + a) << 0; + this.h1 = (this.h1 + b) << 0; + this.h2 = (this.h2 + c) << 0; + this.h3 = (this.h3 + d) << 0; + this.h4 = (this.h4 + e) << 0; + this.h5 = (this.h5 + f) << 0; + this.h6 = (this.h6 + g) << 0; + this.h7 = (this.h7 + h) << 0; + }; + + Sha256.prototype.hex = function () { + this.finalize(); + + var h0 = this.h0, + h1 = this.h1, + h2 = this.h2, + h3 = this.h3, + h4 = this.h4, + h5 = this.h5, + h6 = this.h6, + h7 = this.h7; + + var hex = + HEX_CHARS[(h0 >> 28) & 0x0f] + + HEX_CHARS[(h0 >> 24) & 0x0f] + + HEX_CHARS[(h0 >> 20) & 0x0f] + + HEX_CHARS[(h0 >> 16) & 0x0f] + + HEX_CHARS[(h0 >> 12) & 0x0f] + + HEX_CHARS[(h0 >> 8) & 0x0f] + + HEX_CHARS[(h0 >> 4) & 0x0f] + + HEX_CHARS[h0 & 0x0f] + + HEX_CHARS[(h1 >> 28) & 0x0f] + + HEX_CHARS[(h1 >> 24) & 0x0f] + + HEX_CHARS[(h1 >> 20) & 0x0f] + + HEX_CHARS[(h1 >> 16) & 0x0f] + + HEX_CHARS[(h1 >> 12) & 0x0f] + + HEX_CHARS[(h1 >> 8) & 0x0f] + + HEX_CHARS[(h1 >> 4) & 0x0f] + + HEX_CHARS[h1 & 0x0f] + + HEX_CHARS[(h2 >> 28) & 0x0f] + + HEX_CHARS[(h2 >> 24) & 0x0f] + + HEX_CHARS[(h2 >> 20) & 0x0f] + + HEX_CHARS[(h2 >> 16) & 0x0f] + + HEX_CHARS[(h2 >> 12) & 0x0f] + + HEX_CHARS[(h2 >> 8) & 0x0f] + + HEX_CHARS[(h2 >> 4) & 0x0f] + + HEX_CHARS[h2 & 0x0f] + + HEX_CHARS[(h3 >> 28) & 0x0f] + + HEX_CHARS[(h3 >> 24) & 0x0f] + + HEX_CHARS[(h3 >> 20) & 0x0f] + + HEX_CHARS[(h3 >> 16) & 0x0f] + + HEX_CHARS[(h3 >> 12) & 0x0f] + + HEX_CHARS[(h3 >> 8) & 0x0f] + + HEX_CHARS[(h3 >> 4) & 0x0f] + + HEX_CHARS[h3 & 0x0f] + + HEX_CHARS[(h4 >> 28) & 0x0f] + + HEX_CHARS[(h4 >> 24) & 0x0f] + + HEX_CHARS[(h4 >> 20) & 0x0f] + + HEX_CHARS[(h4 >> 16) & 0x0f] + + HEX_CHARS[(h4 >> 12) & 0x0f] + + HEX_CHARS[(h4 >> 8) & 0x0f] + + HEX_CHARS[(h4 >> 4) & 0x0f] + + HEX_CHARS[h4 & 0x0f] + + HEX_CHARS[(h5 >> 28) & 0x0f] + + HEX_CHARS[(h5 >> 24) & 0x0f] + + HEX_CHARS[(h5 >> 20) & 0x0f] + + HEX_CHARS[(h5 >> 16) & 0x0f] + + HEX_CHARS[(h5 >> 12) & 0x0f] + + HEX_CHARS[(h5 >> 8) & 0x0f] + + HEX_CHARS[(h5 >> 4) & 0x0f] + + HEX_CHARS[h5 & 0x0f] + + HEX_CHARS[(h6 >> 28) & 0x0f] + + HEX_CHARS[(h6 >> 24) & 0x0f] + + HEX_CHARS[(h6 >> 20) & 0x0f] + + HEX_CHARS[(h6 >> 16) & 0x0f] + + HEX_CHARS[(h6 >> 12) & 0x0f] + + HEX_CHARS[(h6 >> 8) & 0x0f] + + HEX_CHARS[(h6 >> 4) & 0x0f] + + HEX_CHARS[h6 & 0x0f]; + if (!this.is224) { + hex += + HEX_CHARS[(h7 >> 28) & 0x0f] + + HEX_CHARS[(h7 >> 24) & 0x0f] + + HEX_CHARS[(h7 >> 20) & 0x0f] + + HEX_CHARS[(h7 >> 16) & 0x0f] + + HEX_CHARS[(h7 >> 12) & 0x0f] + + HEX_CHARS[(h7 >> 8) & 0x0f] + + HEX_CHARS[(h7 >> 4) & 0x0f] + + HEX_CHARS[h7 & 0x0f]; + } + return hex; + }; + + Sha256.prototype.toString = Sha256.prototype.hex; + + Sha256.prototype.digest = function () { + this.finalize(); + + var h0 = this.h0, + h1 = this.h1, + h2 = this.h2, + h3 = this.h3, + h4 = this.h4, + h5 = this.h5, + h6 = this.h6, + h7 = this.h7; + + var arr = [ + (h0 >> 24) & 0xff, + (h0 >> 16) & 0xff, + (h0 >> 8) & 0xff, + h0 & 0xff, + (h1 >> 24) & 0xff, + (h1 >> 16) & 0xff, + (h1 >> 8) & 0xff, + h1 & 0xff, + (h2 >> 24) & 0xff, + (h2 >> 16) & 0xff, + (h2 >> 8) & 0xff, + h2 & 0xff, + (h3 >> 24) & 0xff, + (h3 >> 16) & 0xff, + (h3 >> 8) & 0xff, + h3 & 0xff, + (h4 >> 24) & 0xff, + (h4 >> 16) & 0xff, + (h4 >> 8) & 0xff, + h4 & 0xff, + (h5 >> 24) & 0xff, + (h5 >> 16) & 0xff, + (h5 >> 8) & 0xff, + h5 & 0xff, + (h6 >> 24) & 0xff, + (h6 >> 16) & 0xff, + (h6 >> 8) & 0xff, + h6 & 0xff, + ]; + if (!this.is224) { + arr.push((h7 >> 24) & 0xff, (h7 >> 16) & 0xff, (h7 >> 8) & 0xff, h7 & 0xff); + } + return arr; + }; + + Sha256.prototype.array = Sha256.prototype.digest; + + Sha256.prototype.arrayBuffer = function () { + this.finalize(); + + var buffer = new ArrayBuffer(this.is224 ? 28 : 32); + var dataView = new DataView(buffer); + dataView.setUint32(0, this.h0); + dataView.setUint32(4, this.h1); + dataView.setUint32(8, this.h2); + dataView.setUint32(12, this.h3); + dataView.setUint32(16, this.h4); + dataView.setUint32(20, this.h5); + dataView.setUint32(24, this.h6); + if (!this.is224) { + dataView.setUint32(28, this.h7); + } + return buffer; + }; + + function HmacSha256(key, is224, sharedMemory) { + var i, + type = typeof key; + if (type === 'string') { + var bytes = [], + length = key.length, + index = 0, + code; + for (i = 0; i < length; ++i) { + code = key.charCodeAt(i); + if (code < 0x80) { + bytes[index++] = code; + } else if (code < 0x800) { + bytes[index++] = 0xc0 | (code >> 6); + bytes[index++] = 0x80 | (code & 0x3f); + } else if (code < 0xd800 || code >= 0xe000) { + bytes[index++] = 0xe0 | (code >> 12); + bytes[index++] = 0x80 | ((code >> 6) & 0x3f); + bytes[index++] = 0x80 | (code & 0x3f); + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (key.charCodeAt(++i) & 0x3ff)); + bytes[index++] = 0xf0 | (code >> 18); + bytes[index++] = 0x80 | ((code >> 12) & 0x3f); + bytes[index++] = 0x80 | ((code >> 6) & 0x3f); + bytes[index++] = 0x80 | (code & 0x3f); + } + } + key = bytes; + } else { + if (type === 'object') { + if (key === null) { + throw new Error(ERROR); + } else if (ARRAY_BUFFER && key.constructor === ArrayBuffer) { + key = new Uint8Array(key); + } else if (!Array.isArray(key)) { + if (!ARRAY_BUFFER || !ArrayBuffer.isView(key)) { + throw new Error(ERROR); + } + } + } else { + throw new Error(ERROR); + } + } + + if (key.length > 64) { + key = new Sha256(is224, true).update(key).array(); + } + + var oKeyPad = [], + iKeyPad = []; + for (i = 0; i < 64; ++i) { + var b = key[i] || 0; + oKeyPad[i] = 0x5c ^ b; + iKeyPad[i] = 0x36 ^ b; + } + + Sha256.call(this, is224, sharedMemory); + + this.update(iKeyPad); + this.oKeyPad = oKeyPad; + this.inner = true; + this.sharedMemory = sharedMemory; + } + HmacSha256.prototype = new Sha256(); + + HmacSha256.prototype.finalize = function () { + Sha256.prototype.finalize.call(this); + if (this.inner) { + this.inner = false; + var innerHash = this.array(); + Sha256.call(this, this.is224, this.sharedMemory); + this.update(this.oKeyPad); + this.update(innerHash); + Sha256.prototype.finalize.call(this); + } + }; + + var exports = createMethod(); + exports.sha256 = exports; + exports.sha224 = createMethod(true); + exports.sha256.hmac = createHmacMethod(); + exports.sha224.hmac = createHmacMethod(true); + + if (COMMON_JS) { + module.exports = exports; + } else { + root.sha256 = exports.sha256; + root.sha224 = exports.sha224; + if (AMD) { + define(function () { + return exports; + }); + } + } +})(); diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts new file mode 100644 index 000000000..92ba6944a --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -0,0 +1,331 @@ +/** + * Ripped from https://github.com/binaryminds/react-native-sse + * These changes are made from the above repo at fork-time: + * 1. converted to ts and fix ts related errors. + * 2. added onopen, onclose, onerror, onretrying functions. + * 3. modified dispatch to work with functions added in 2. + * 4. replaced all for of loops with foreach + */ +import type { EventSourceEvent, EventSourceListener, EventSourceOptions, EventType } from './types'; + +const XMLReadyStateMap = ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']; + +const defaultOptions: EventSourceOptions = { + body: undefined, + debug: false, + headers: {}, + method: 'GET', + pollingInterval: 5000, + timeout: 0, + timeoutBeforeConnection: 0, + withCredentials: false, + retryAndHandleError: undefined, +}; + +export default class EventSource { + ERROR = -1; + CONNECTING = 0; + OPEN = 1; + CLOSED = 2; + + private lastEventId: undefined | string; + private lastIndexProcessed = 0; + private eventType: undefined | EventType; + private status = this.CONNECTING; + private eventHandlers: any = { + open: [], + message: [], + error: [], + close: [], + }; + + private method: string; + private timeout: number; + private timeoutBeforeConnection: number; + private withCredentials: boolean; + private headers: Record; + private body: any; + private debug: boolean; + private url: string; + private xhr: XMLHttpRequest = new XMLHttpRequest(); + private pollTimer: any; + private pollingInterval: number; + private retryAndHandleError?: (err: any) => boolean; + + constructor(url: string, options?: EventSourceOptions) { + const opts = { + ...defaultOptions, + ...options, + }; + + this.url = url; + this.method = opts.method!; + this.timeout = opts.timeout!; + this.timeoutBeforeConnection = opts.timeoutBeforeConnection!; + this.withCredentials = opts.withCredentials!; + this.headers = opts.headers!; + this.body = opts.body; + this.debug = opts.debug!; + this.pollingInterval = opts.pollingInterval!; + this.retryAndHandleError = opts.retryAndHandleError; + + this.pollAgain(this.timeoutBeforeConnection, true); + } + + private pollAgain(time: number, allowZero: boolean) { + if (time > 0 || allowZero) { + this.logDebug(`[EventSource] Will open new connection in ${time} ms.`); + this.dispatch('retry', { type: 'retry' }); + this.pollTimer = setTimeout(() => { + this.open(); + }, time); + } + } + + open() { + try { + this.lastIndexProcessed = 0; + this.status = this.CONNECTING; + this.xhr.open(this.method, this.url, true); + + if (this.withCredentials) { + this.xhr.withCredentials = true; + } + + this.xhr.setRequestHeader('Accept', 'text/event-stream'); + this.xhr.setRequestHeader('Cache-Control', 'no-cache'); + this.xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + if (this.headers) { + Object.entries(this.headers).forEach(([key, value]) => { + this.xhr.setRequestHeader(key, value); + }); + } + + if (typeof this.lastEventId !== 'undefined') { + this.xhr.setRequestHeader('Last-Event-ID', this.lastEventId); + } + + this.xhr.timeout = this.timeout; + + this.xhr.onreadystatechange = () => { + if (this.status === this.CLOSED) { + return; + } + + this.logDebug( + `[EventSource][onreadystatechange] ReadyState: ${ + XMLReadyStateMap[this.xhr.readyState] || 'Unknown' + }(${this.xhr.readyState}), status: ${this.xhr.status}`, + ); + + if ( + this.xhr.readyState !== XMLHttpRequest.DONE && + this.xhr.readyState !== XMLHttpRequest.LOADING + ) { + return; + } + + if (this.xhr.status >= 200 && this.xhr.status < 400) { + if (this.status === this.CONNECTING) { + this.status = this.OPEN; + this.dispatch('open', { type: 'open' }); + this.logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.'); + } + + this.handleEvent(this.xhr.responseText || ''); + + if (this.xhr.readyState === XMLHttpRequest.DONE) { + this.logDebug('[EventSource][onreadystatechange][DONE] Operation done.'); + this.pollAgain(this.pollingInterval, false); + } + } else if (this.xhr.status !== 0) { + this.status = this.ERROR; + this.dispatch('error', { + type: 'error', + message: this.xhr.responseText, + xhrStatus: this.xhr.status, + xhrState: this.xhr.readyState, + }); + + if (this.xhr.readyState === XMLHttpRequest.DONE) { + this.logDebug('[EventSource][onreadystatechange][ERROR] Response status error.'); + + if (!this.retryAndHandleError) { + // default implementation + this.pollAgain(this.pollingInterval, false); + } else { + // custom retry logic + const shouldRetry = this.retryAndHandleError({ + status: this.xhr.status, + message: this.xhr.responseText, + }); + + if (shouldRetry) { + this.pollAgain(this.pollingInterval, true); + } + } + } + } + }; + + this.xhr.onerror = () => { + if (this.status === this.CLOSED) { + return; + } + + this.status = this.ERROR; + this.dispatch('error', { + type: 'error', + message: this.xhr.responseText, + xhrStatus: this.xhr.status, + xhrState: this.xhr.readyState, + }); + }; + + if (this.body) { + this.xhr.send(this.body); + } else { + this.xhr.send(); + } + + if (this.timeout > 0) { + setTimeout(() => { + if (this.xhr.readyState === XMLHttpRequest.LOADING) { + this.dispatch('error', { type: 'timeout' }); + this.close(); + } + }, this.timeout); + } + } catch (e: any) { + this.status = this.ERROR; + this.dispatch('error', { + type: 'exception', + message: e.message, + error: e, + }); + } + } + + private logDebug(...msg: string[]) { + if (this.debug) { + // eslint-disable-next-line no-console + console.debug(...msg); + } + } + + private handleEvent(response: string) { + const parts = response.slice(this.lastIndexProcessed).split('\n'); + + const indexOfDoubleNewline = response.lastIndexOf('\n\n'); + if (indexOfDoubleNewline !== -1) { + this.lastIndexProcessed = indexOfDoubleNewline + 2; + } + + let data = []; + let retry = 0; + let line = ''; + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < parts.length; i++) { + line = parts[i].replace(/^(\s|\u00A0)+|(\s|\u00A0)+$/g, ''); + if (line.indexOf('event') === 0) { + this.eventType = line.replace(/event:?\s*/, '') as EventType; + } else if (line.indexOf('retry') === 0) { + retry = parseInt(line.replace(/retry:?\s*/, ''), 10); + if (!Number.isNaN(retry)) { + this.pollingInterval = retry; + } + } else if (line.indexOf('data') === 0) { + data.push(line.replace(/data:?\s*/, '')); + } else if (line.indexOf('id:') === 0) { + this.lastEventId = line.replace(/id:?\s*/, ''); + } else if (line.indexOf('id') === 0) { + this.lastEventId = undefined; + } else if (line === '') { + if (data.length > 0) { + const eventType = this.eventType || 'message'; + const event: any = { + type: eventType, + data: data.join('\n'), + url: this.url, + lastEventId: this.lastEventId, + }; + + this.dispatch(eventType, event); + + data = []; + this.eventType = undefined; + } + } + } + } + + addEventListener>(type: T, listener: EventSourceListener): void { + if (this.eventHandlers[type] === undefined) { + this.eventHandlers[type] = []; + } + + this.eventHandlers[type].push(listener); + } + + removeEventListener>(type: T, listener: EventSourceListener): void { + if (this.eventHandlers[type] !== undefined) { + this.eventHandlers[type] = this.eventHandlers[type].filter( + (handler: EventSourceListener) => handler !== listener, + ); + } + } + + removeAllEventListeners>(type?: T) { + const availableTypes = Object.keys(this.eventHandlers); + + if (type === undefined) { + availableTypes.forEach((eventType) => { + this.eventHandlers[eventType] = []; + }); + } else { + if (!availableTypes.includes(type)) { + throw Error(`[EventSource] '${type}' type is not supported event type.`); + } + + this.eventHandlers[type] = []; + } + } + + dispatch>(type: T, data: EventSourceEvent) { + this.eventHandlers[type]?.forEach((handler: EventSourceListener) => handler(data)); + + switch (type) { + case 'open': + this.onopen(); + break; + case 'close': + this.onclose(); + break; + case 'error': + this.onerror(data); + break; + case 'retry': + this.onretrying({ delayMillis: this.pollingInterval }); + break; + default: + break; + } + } + + close() { + this.status = this.CLOSED; + clearTimeout(this.pollTimer); + if (this.xhr) { + this.xhr.abort(); + } + + this.dispatch('close', { type: 'close' }); + } + + onopen() {} + onclose() {} + onerror(_err: any) {} + onretrying(_e: any) {} +} diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/LICENSE b/packages/sdk/react-native/src/fromExternal/react-native-sse/LICENSE new file mode 100644 index 000000000..1f5662ff0 --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2021 Binary Minds + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/index.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/index.ts new file mode 100644 index 000000000..b9f3fe560 --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/index.ts @@ -0,0 +1,3 @@ +import EventSource from './EventSource'; + +export default EventSource; diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts new file mode 100644 index 000000000..1a417a7db --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts @@ -0,0 +1,73 @@ +export type BuiltInEventType = 'open' | 'message' | 'error' | 'close' | 'retry'; +export type EventType = E | BuiltInEventType; + +export interface MessageEvent { + type: 'message'; + data: string | null; + lastEventId: string | undefined; + url: string; +} + +export interface OpenEvent { + type: 'open'; +} + +export interface CloseEvent { + type: 'close'; +} + +export interface RetryEvent { + type: 'retry'; +} + +export interface TimeoutEvent { + type: 'timeout'; +} + +export interface ErrorEvent { + type: 'error'; + message: string; + xhrState: number; + xhrStatus: number; +} + +export interface CustomEvent { + type: E; + data: string | null; + lastEventId: string | null; + url: string; +} + +export interface ExceptionEvent { + type: 'exception'; + message: string; + error: Error; +} + +export interface EventSourceOptions { + method?: string; + timeout?: number; + timeoutBeforeConnection?: number; + withCredentials?: boolean; + headers?: Record; + body?: any; + debug?: boolean; + pollingInterval?: number; + retryAndHandleError?: (err: any) => boolean; +} + +type BuiltInEventMap = { + message: MessageEvent; + open: OpenEvent; + close: CloseEvent; + error: ErrorEvent | TimeoutEvent | ExceptionEvent; + retry: RetryEvent; +}; + +export type EventSourceEvent = E extends BuiltInEventType + ? BuiltInEventMap[E] + : CustomEvent; + +export type EventSourceListener = EventType> = ( + event: EventSourceEvent, +) => void; diff --git a/packages/sdk/react-native/src/platform/autoEnv.ts b/packages/sdk/react-native/src/platform/autoEnv.ts new file mode 100644 index 000000000..728841020 --- /dev/null +++ b/packages/sdk/react-native/src/platform/autoEnv.ts @@ -0,0 +1,40 @@ +import { Platform, type PlatformAndroidStatic } from 'react-native'; + +import type { LDApplication, LDDevice } from '@launchdarkly/js-sdk-common'; + +import locale from './locale'; + +export const ldApplication: LDApplication = { + // key is populated by client common sdk + key: '', + envAttributesVersion: '1.0', + + // TODO: populate application ID, name, version, versionName + id: '', + name: '', + version: '', + versionName: '', + locale, +}; + +export const ldDevice: LDDevice = { + // key is populated by client common sdk + key: '', + envAttributesVersion: '1.0', + manufacturer: Platform.select({ + ios: 'apple', + android: (Platform as PlatformAndroidStatic).constants.Manufacturer, + }), + model: Platform.select({ + // ios: model n/a from PlatformIOSStatic + android: (Platform as PlatformAndroidStatic).constants.Model, + }), + os: { + family: Platform.select({ + ios: 'apple', + default: Platform.OS, + }), + name: Platform.OS, + version: Platform.Version.toString(), + }, +}; diff --git a/packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts b/packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts new file mode 100644 index 000000000..38135983c --- /dev/null +++ b/packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts @@ -0,0 +1,62 @@ +import PlatformHasher from './PlatformHasher'; + +/** + * The links below are different from js-sha256 and are useful to verify the + * correctness of hash and encoding output: + * https://www.liavaag.org/English/SHA-Generator/ + * https://www.liavaag.org/English/SHA-Generator/HMAC/ + */ +describe('PlatformHasher', () => { + test('sha256 produces correct base64 output', () => { + const h = new PlatformHasher('sha256'); + + h.update('test-app-id'); + const output = h.digest('base64'); + + expect(output).toEqual('XVm6ZNk6ejx6+IVtL7zfwYwRQ2/ck9+y7FaN32EcudQ='); + }); + + test('sha256 produces correct hex output', () => { + const h = new PlatformHasher('sha256'); + + h.update('test-app-id'); + const output = h.digest('hex'); + + expect(output).toEqual('5d59ba64d93a7a3c7af8856d2fbcdfc18c11436fdc93dfb2ec568ddf611cb9d4'); + }); + + test('unsupported hash algorithm', () => { + expect(() => { + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const h = new PlatformHasher('sha1'); + }).toThrow(/unsupported/i); + }); + + test('unsupported output algorithm', () => { + expect(() => { + const h = new PlatformHasher('sha256'); + h.update('test-app-id'); + // @ts-ignore + h.digest('base122'); + }).toThrow(/unsupported/i); + }); + + test('hmac produces correct base64 output', () => { + const h = new PlatformHasher('sha256', 'hmac-key'); + + h.update('test-app-id'); + const output = h.digest('base64'); + + expect(output).toEqual('tB+++rKY29eF480Oe3ekuWk4AbXV2E8cTgk+UEB9xfA='); + }); + + test('hmac produces correct hex output', () => { + const h = new PlatformHasher('sha256', 'hmac-key'); + + h.update('test-app-id'); + const output = h.digest('hex'); + + expect(output).toEqual('b41fbefab298dbd785e3cd0e7b77a4b9693801b5d5d84f1c4e093e50407dc5f0'); + }); +}); diff --git a/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts b/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts new file mode 100644 index 000000000..081af2624 --- /dev/null +++ b/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts @@ -0,0 +1,37 @@ +import { Hasher as LDHasher } from '@launchdarkly/js-client-sdk-common'; + +import { Hasher, sha256 } from '../../fromExternal/js-sha256'; +import { base64FromByteArray } from '../../polyfills'; +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class PlatformHasher implements LDHasher { + private hasher: Hasher; + + constructor(algorithm: SupportedHashAlgorithm, hmacKey?: string) { + switch (algorithm) { + case 'sha256': + this.hasher = hmacKey ? sha256.hmac.create(hmacKey) : sha256.create(); + break; + default: + throw new Error(`Unsupported hash algorithm: ${algorithm}. Only sha256 is supported.`); + } + } + + digest(encoding: SupportedOutputEncoding): string { + switch (encoding) { + case 'base64': + return base64FromByteArray(new Uint8Array(this.hasher.arrayBuffer())); + case 'hex': + return this.hasher.hex(); + default: + throw new Error( + `unsupported output encoding: ${encoding}. Only base64 and hex are supported.`, + ); + } + } + + update(data: string): this { + this.hasher.update(data); + return this; + } +} diff --git a/packages/sdk/react-native/src/platform/crypto/index.ts b/packages/sdk/react-native/src/platform/crypto/index.ts new file mode 100644 index 000000000..a4bfdd4dd --- /dev/null +++ b/packages/sdk/react-native/src/platform/crypto/index.ts @@ -0,0 +1,40 @@ +import type { Crypto, Hmac } from '@launchdarkly/js-client-sdk-common'; + +import PlatformHasher from './PlatformHasher'; +import { SupportedHashAlgorithm } from './types'; + +/* eslint-disable no-bitwise */ +/** + * To avoid dependencies on uuid, this is good enough for now. + * Ripped from the react-native repo: + * https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Blob/BlobManager.js#L27 + * + * Based on the rfc4122-compliant solution posted at + * http://stackoverflow.com/questions/105034 + */ +function uuidv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Uses crypto-js as substitute to node:crypto because the latter + * is not yet supported in some runtimes. + * https://cryptojs.gitbook.io/docs/ + */ +export default class PlatformCrypto implements Crypto { + createHash(algorithm: SupportedHashAlgorithm): PlatformHasher { + return new PlatformHasher(algorithm); + } + + createHmac(algorithm: SupportedHashAlgorithm, key: string): Hmac { + return new PlatformHasher(algorithm, key); + } + + randomUUID(): string { + return uuidv4(); + } +} diff --git a/packages/sdk/react-native/src/platform/crypto/types.ts b/packages/sdk/react-native/src/platform/crypto/types.ts new file mode 100644 index 000000000..446d5f6df --- /dev/null +++ b/packages/sdk/react-native/src/platform/crypto/types.ts @@ -0,0 +1,2 @@ +export type SupportedHashAlgorithm = 'sha256'; +export type SupportedOutputEncoding = 'base64' | 'hex'; diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index ba2db6299..6e9e7a81c 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -1,12 +1,9 @@ /* eslint-disable max-classes-per-file */ import type { - Crypto, Encoding, EventName, EventSource, EventSourceInitDict, - Hasher, - Hmac, Info, LDLogger, Options, @@ -19,9 +16,11 @@ import type { } from '@launchdarkly/js-client-sdk-common'; import { name, version } from '../../package.json'; -import { btoa, uuidv4 } from '../polyfills'; -import RNEventSource from '../react-native-sse'; +import RNEventSource from '../fromExternal/react-native-sse'; +import { btoa } from '../polyfills'; +import { ldApplication, ldDevice } from './autoEnv'; import AsyncStorage from './ConditionalAsyncStorage'; +import PlatformCrypto from './crypto'; class PlatformRequests implements Requests { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { @@ -43,32 +42,28 @@ class PlatformEncoding implements Encoding { } class PlatformInfo implements Info { + constructor(private readonly logger: LDLogger) {} + platformData(): PlatformData { - return { + const data = { name: 'React Native', + ld_application: ldApplication, + ld_device: ldDevice, }; + + this.logger.debug(`platformData: ${JSON.stringify(data, null, 2)}`); + return data; } sdkData(): SdkData { - return { + const data = { name, version, userAgentBase: 'ReactNativeClient', }; - } -} - -class PlatformCrypto implements Crypto { - createHash(_algorithm: string): Hasher { - throw new Error('not implemented'); - } - - createHmac(_algorithm: string, _key: string): Hmac { - throw new Error('not implemented'); - } - randomUUID(): string { - return uuidv4(); + this.logger.debug(`sdkData: ${JSON.stringify(data, null, 2)}`); + return data; } } @@ -99,7 +94,7 @@ class PlatformStorage implements Storage { const createPlatform = (logger: LDLogger): Platform => ({ crypto: new PlatformCrypto(), - info: new PlatformInfo(), + info: new PlatformInfo(logger), requests: new PlatformRequests(), encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), diff --git a/packages/sdk/react-native/src/platform/locale.ts b/packages/sdk/react-native/src/platform/locale.ts new file mode 100644 index 000000000..d5a5fab5e --- /dev/null +++ b/packages/sdk/react-native/src/platform/locale.ts @@ -0,0 +1,12 @@ +import { NativeModules, Platform } from 'react-native'; + +/** + * Ripped from: + * https://dev.to/medaimane/localization-and-internationalization-in-react-native-reaching-global-audiences-3acj + */ +const locale = + Platform.OS === 'ios' + ? NativeModules.SettingsManager.settings.AppleLocale // iOS + : NativeModules.I18nManager.localeIdentifier; // Android and rest + +export default locale; diff --git a/packages/sdk/react-native/src/polyfills/btoa.ts b/packages/sdk/react-native/src/polyfills/btoa.ts index 944ce41c9..d9b29bfac 100644 --- a/packages/sdk/react-native/src/polyfills/btoa.ts +++ b/packages/sdk/react-native/src/polyfills/btoa.ts @@ -8,6 +8,10 @@ function convertToByteArray(s: string) { return Uint8Array.from(b); } -export default function btoa(s: string) { +export function btoa(s: string) { return fromByteArray(convertToByteArray(s)); } + +export function base64FromByteArray(a: Uint8Array) { + return fromByteArray(a); +} diff --git a/packages/sdk/react-native/src/polyfills/index.ts b/packages/sdk/react-native/src/polyfills/index.ts index bf2bac134..03e1b11fe 100644 --- a/packages/sdk/react-native/src/polyfills/index.ts +++ b/packages/sdk/react-native/src/polyfills/index.ts @@ -1,8 +1,8 @@ import EventTarget from 'event-target-shim'; -import btoa from './btoa'; +import { type Hasher, sha256 } from '../fromExternal/js-sha256'; +import { base64FromByteArray, btoa } from './btoa'; import CustomEvent from './CustomEvent'; -import uuidv4 from './uuid'; function setupPolyfill() { Object.assign(global, { @@ -10,4 +10,4 @@ function setupPolyfill() { CustomEvent, }); } -export { btoa, setupPolyfill, uuidv4 }; +export { base64FromByteArray, btoa, type Hasher, setupPolyfill, sha256 }; diff --git a/packages/sdk/react-native/tsconfig.json b/packages/sdk/react-native/tsconfig.json index e5a9204be..275a512e7 100644 --- a/packages/sdk/react-native/tsconfig.json +++ b/packages/sdk/react-native/tsconfig.json @@ -18,7 +18,16 @@ "strict": true, "stripInternal": true, "target": "ES2017", - "types": ["node"] + "types": ["node", "jest"], + "allowJs": true }, - "exclude": ["**/*.test.ts*", "dist", "node_modules", "__tests__", "example"] + "exclude": [ + "jest.config.ts", + "jestSetupFile.ts", + "**/*.test.ts*", + "dist", + "node_modules", + "__tests__", + "example" + ] } From 2028f4cec1bce80d429e162941fd5052542b626d Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 24 Jan 2024 23:58:55 -0800 Subject: [PATCH 05/33] chore: Fix broken common tests due to mocks api changes. --- packages/shared/common/jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/common/jest.config.js b/packages/shared/common/jest.config.js index 6753062cc..bcd6a8d01 100644 --- a/packages/shared/common/jest.config.js +++ b/packages/shared/common/jest.config.js @@ -4,4 +4,5 @@ module.exports = { testEnvironment: 'node', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: ['src/**/*.ts'], + setupFilesAfterEnv: ['@launchdarkly/private-js-mocks/setup'], }; From adc1de1dd8554d91141e89bae5c3c32138eed5dd Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 09:48:00 -0800 Subject: [PATCH 06/33] fix: Remove sdk data as fallback for auto env. Strip falsy values and only return well defined auto env objects. --- .../common/src/utils/deepCompact.test.ts | 39 ++ .../shared/common/src/utils/deepCompact.ts | 24 ++ .../shared/common/src/utils/isEmptyObject.ts | 3 + .../sdk-client/src/utils/addAutoEnv.test.ts | 357 +++++++++++------- .../shared/sdk-client/src/utils/addAutoEnv.ts | 81 ++-- 5 files changed, 338 insertions(+), 166 deletions(-) create mode 100644 packages/shared/common/src/utils/deepCompact.test.ts create mode 100644 packages/shared/common/src/utils/deepCompact.ts create mode 100644 packages/shared/common/src/utils/isEmptyObject.ts diff --git a/packages/shared/common/src/utils/deepCompact.test.ts b/packages/shared/common/src/utils/deepCompact.test.ts new file mode 100644 index 000000000..642f385a2 --- /dev/null +++ b/packages/shared/common/src/utils/deepCompact.test.ts @@ -0,0 +1,39 @@ +import deepCompact from './deepCompact'; + +describe('deepCompact', () => { + test('if arg is undefined, return it', () => { + const compacted = deepCompact(undefined); + expect(compacted).toBeUndefined(); + }); + + test('should remove all falsy, {} and ignored values', () => { + const data = { + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: {}, + manufacturer: 'coconut', + model: null, + storageBytes: undefined, + }, + }; + const compacted = deepCompact(data, ['key', 'envAttributesVersion']); + expect(compacted).toEqual({ + ld_application: { + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + manufacturer: 'coconut', + }, + }); + }); +}); diff --git a/packages/shared/common/src/utils/deepCompact.ts b/packages/shared/common/src/utils/deepCompact.ts new file mode 100644 index 000000000..c83beb310 --- /dev/null +++ b/packages/shared/common/src/utils/deepCompact.ts @@ -0,0 +1,24 @@ +import isEmptyObject from './isEmptyObject'; + +/** + * Strips all falsy and empty {} from a given object. Returns a new object with only truthy values. + * Sourced from below but modified to include checks for empty object and ignoring keys. + * https://www.w3resource.com/javascript-exercises/javascript-array-exercise-47.php + * + * @param obj + * @param ignoreKeys + */ +const deepCompact = (obj?: T, ignoreKeys?: string[]) => { + if (!obj) { + return obj; + } + + return Object.entries(obj).reduce((acc: any, [key, value]) => { + if (Boolean(value) && !isEmptyObject(value) && !ignoreKeys?.includes(key)) { + acc[key] = typeof value === 'object' ? deepCompact(value, ignoreKeys) : value; + } + return acc; + }, {}) as T; +}; + +export default deepCompact; diff --git a/packages/shared/common/src/utils/isEmptyObject.ts b/packages/shared/common/src/utils/isEmptyObject.ts new file mode 100644 index 000000000..2d2f73b0b --- /dev/null +++ b/packages/shared/common/src/utils/isEmptyObject.ts @@ -0,0 +1,3 @@ +const isEmptyObject = (obj: any) => JSON.stringify(obj) === '{}'; + +export default isEmptyObject; diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts index fec9407dc..2e074017c 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts @@ -24,185 +24,262 @@ describe('addAutoEnv', () => { expect(multi).toEqual({ kind: 'multi', user: { key: 'test-user-key-1', name: 'bob' } }); }); - test('LDUser is unsupported', async () => { - const config = new Configuration(); - // const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; - const user: LDUser = { key: 'legacy-user-key', name: 'bob' }; - const result = await addAutoEnv(user, basicPlatform, config); + describe('addAutoEnv', () => { + test('LDUser is unsupported', async () => { + const config = new Configuration(); + const user: LDUser = { key: 'legacy-user-key', name: 'bob' }; + const result = await addAutoEnv(user, basicPlatform, config); - expect(result).toEqual(user); + expect(result).toEqual(user); + }); + + test('nothing to add return context unchanged', async () => { + info.platformData = jest.fn().mockReturnValue({}); + const config = new Configuration(); + const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual(context); + }); + + test('single kind should be converted to multi', async () => { + const config = new Configuration(); + const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; + + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, + user: { key: 'test-user-key-1', name: 'bob' }, + }); + }); + + test('multi kind', async () => { + const config = new Configuration(); + const context: LDContext = { + kind: 'multi', + user: { key: 'test-user-key-1', name: 'bob' }, + org: { key: 'test-org-key-1', name: 'Best company' }, + }; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + user: { key: 'test-user-key-1', name: 'bob' }, + org: { key: 'test-org-key-1', name: 'Best company' }, + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, + }); + }); }); - test('single kind should be converted to multi', async () => { - const config = new Configuration(); - const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; + describe('addApplicationInfo', () => { + test('add application tags id, version', () => { + const config = new Configuration({ + application: { id: 'com.from-config.ld', version: '2.2.2' }, + }); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toEqual({ + envAttributesVersion: '1.0', + id: 'com.from-config.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '2.2.2', + }); + }); - const result = await addAutoEnv(context, basicPlatform, config); + test('add auto env application id, name, version', () => { + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); - expect(result).toEqual({ - kind: 'multi', - ld_application: { + expect(ldApplication).toEqual({ envAttributesVersion: '1.0', id: 'com.testapp.ld', key: '1234567890123456', name: 'LDApplication.TestApp', version: '1.1.1', - }, - ld_device: { - envAttributesVersion: '1.0', - key: 'test-device-key-1', - manufacturer: 'coconut', - os: { name: 'An OS', version: '1.0.1', family: 'orange' }, - }, - user: { key: 'test-user-key-1', name: 'bob' }, + }); }); - }); - test('multi kind', async () => { - const config = new Configuration(); - const context: LDContext = { - kind: 'multi', - user: { key: 'test-user-key-1', name: 'bob' }, - org: { key: 'test-org-key-1', name: 'Best company' }, - }; - const result = await addAutoEnv(context, basicPlatform, config); - - expect(result).toEqual({ - kind: 'multi', - user: { key: 'test-user-key-1', name: 'bob' }, - org: { key: 'test-org-key-1', name: 'Best company' }, - ld_application: { + test('final return value should not contain falsy values', () => { + const mockData = info.platformData(); + info.platformData = jest.fn().mockReturnValueOnce({ + ...mockData, + ld_application: { + ...mockData.ld_application, + name: '', + version: null, + versionName: undefined, + locale: '', + envAttributesVersion: 0, + }, + }); + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toEqual({ envAttributesVersion: '1.0', id: 'com.testapp.ld', key: '1234567890123456', - name: 'LDApplication.TestApp', - version: '1.1.1', - }, - ld_device: { + }); + }); + + test('omit if both tags and auto generated data are unavailable', () => { + info.platformData = jest.fn().mockReturnValueOnce({}); + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toBeUndefined(); + }); + + test('omit if tags unavailable and auto generated data are falsy', () => { + const mockData = info.platformData(); + info.platformData = jest.fn().mockReturnValueOnce({ + ld_application: { + ...mockData.ld_application, + name: '', + version: null, + id: undefined, + }, + }); + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toBeUndefined(); + }); + + test('omit if tags unavailable and auto generated data only contains key and attributesVersion', () => { + info.platformData = jest.fn().mockReturnValueOnce({ + ld_application: { key: 'key-from-sdk', envAttributesVersion: '0.0.1' }, + }); + const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toBeUndefined(); + }); + + test('omit if no id specified', () => { + info.platformData = jest + .fn() + .mockReturnValueOnce({ ld_application: { version: null, locale: '' } }); + const config = new Configuration({ application: { version: '1.2.3' } }); + const ldApplication = addApplicationInfo(basicPlatform, config); + + expect(ldApplication).toBeUndefined(); + }); + }); + + describe('addDeviceInfo', () => { + test('add platformData os name, version', async () => { + const ldDevice = await addDeviceInfo(basicPlatform); + + expect(ldDevice).toEqual({ envAttributesVersion: '1.0', key: 'test-device-key-1', manufacturer: 'coconut', os: { name: 'An OS', version: '1.0.1', family: 'orange' }, - }, + }); }); - }); - test('addApplicationInfo with config application id, version', () => { - const config = new Configuration({ - application: { id: 'com.from-config.ld', version: '2.2.2' }, - }); - const ldApplication = addApplicationInfo(basicPlatform, config); + test('add auto env os name, version', async () => { + const platformData = info.platformData(); + delete platformData.os; + info.platformData = jest.fn().mockReturnValueOnce(platformData); + + const ldDevice = await addDeviceInfo(basicPlatform); - expect(ldApplication).toEqual({ - envAttributesVersion: '1.0', - id: 'com.from-config.ld', - key: '1234567890123456', - name: 'LDApplication.TestApp', - version: '2.2.2', + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'Another OS', version: '99', family: 'orange' }, + }); }); - }); - test('addApplicationInfo with auto env application id, name, version', () => { - const config = new Configuration(); - const ldApplication = addApplicationInfo(basicPlatform, config); + test('add auto env os name, version when platform data are empty strings', async () => { + const platformData = info.platformData(); + platformData.os = { name: '', version: '' }; + info.platformData = jest.fn().mockReturnValueOnce(platformData); - expect(ldApplication).toEqual({ - envAttributesVersion: '1.0', - id: 'com.testapp.ld', - key: '1234567890123456', - name: 'LDApplication.TestApp', - version: '1.1.1', - }); - }); + const ldDevice = await addDeviceInfo(basicPlatform); - test('addApplicationInfo with sdk data name, version', () => { - const platformData = info.platformData(); - delete platformData.ld_application; - delete platformData.ld_device; - info.platformData = jest.fn().mockReturnValueOnce(platformData); - info.sdkData = jest.fn().mockReturnValueOnce({ - name: 'Name from sdk data', - version: '3.3.3', - userAgentBase: 'TestUserAgent', - wrapperName: 'Rapper', - wrapperVersion: '9.9.9', - }); - - const config = new Configuration(); - const ldApplication = addApplicationInfo(basicPlatform, config); - - expect(ldApplication).toEqual({ - envAttributesVersion: '1.0', - id: 'Name from sdk data', - key: '1234567890123456', - name: 'Name from sdk data', - version: '3.3.3', + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'Another OS', version: '99', family: 'orange' }, + }); }); - }); - test('addApplicationInfo with sdkData wrapperName, wrapperVersion', () => { - const platformData = info.platformData(); - delete platformData.ld_application; - delete platformData.ld_device; - info.platformData = jest.fn().mockReturnValueOnce(platformData); - info.sdkData = jest.fn().mockReturnValueOnce({ - name: '', - version: '', - userAgentBase: 'TestUserAgent', - wrapperName: 'Rapper', - wrapperVersion: '9.9.9', - }); - - const config = new Configuration(); - const ldApplication = addApplicationInfo(basicPlatform, config); - - expect(ldApplication).toEqual({ - envAttributesVersion: '1.0', - id: 'Rapper', - key: '1234567890123456', - name: 'Rapper', - version: '9.9.9', + test('no data return undefined', async () => { + info.platformData = jest.fn().mockReturnValueOnce({}); + const ldDevice = await addDeviceInfo(basicPlatform); + expect(ldDevice).toBeUndefined(); }); - }); - test('addDeviceInfo with platformData os name, version', async () => { - const ldDevice = await addDeviceInfo(basicPlatform); + test('platformData os is defined but empty', async () => { + const platformData = info.platformData(); + platformData.os = {}; + info.platformData = jest.fn().mockReturnValueOnce(platformData); + + const ldDevice = await addDeviceInfo(basicPlatform); - expect(ldDevice).toEqual({ - envAttributesVersion: '1.0', - key: 'test-device-key-1', - manufacturer: 'coconut', - os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'Another OS', version: '99', family: 'orange' }, + }); }); - }); - test('addDeviceInfo with auto env os name, version', async () => { - const platformData = info.platformData(); - delete platformData.os; - info.platformData = jest.fn().mockReturnValueOnce(platformData); + test('only os family is defined', async () => { + info.platformData = jest + .fn() + .mockReturnValueOnce({ os: {}, ld_device: { os: { family: 'orange' } } }); - const ldDevice = await addDeviceInfo(basicPlatform); + const ldDevice = await addDeviceInfo(basicPlatform); - expect(ldDevice).toEqual({ - envAttributesVersion: '1.0', - key: 'test-device-key-1', - manufacturer: 'coconut', - os: { name: 'Another OS', version: '99', family: 'orange' }, + expect(ldDevice).toEqual({ + envAttributesVersion: '1.0', + key: 'test-device-key-1', + os: { name: '', version: '', family: 'orange' }, + }); }); - }); - test('addDeviceInfo with auto env os name, version when platform data are empty strings', async () => { - const platformData = info.platformData(); - platformData.os = { name: '', version: '' }; - info.platformData = jest.fn().mockReturnValueOnce(platformData); + test('return undefined when device only contains key and envAttributesVersion', async () => { + info.platformData = jest.fn().mockReturnValueOnce({ + os: {}, + ld_device: { key: 'test-device-key', envAttributesVersion: '0.1' }, + }); - const ldDevice = await addDeviceInfo(basicPlatform); + const ldDevice = await addDeviceInfo(basicPlatform); - expect(ldDevice).toEqual({ - envAttributesVersion: '1.0', - key: 'test-device-key-1', - manufacturer: 'coconut', - os: { name: 'Another OS', version: '99', family: 'orange' }, + expect(ldDevice).toBeUndefined(); }); }); }); diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.ts index 65d1e128b..e79e56594 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { - clone, internal, LDApplication, LDContext, @@ -10,6 +9,7 @@ import { LDUser, Platform, } from '@launchdarkly/js-sdk-common'; +import deepCompact from '@launchdarkly/js-sdk-common/dist/utils/deepCompact'; import Configuration from '../configuration'; import { getOrGenerateKey } from './getOrGenerateKey'; @@ -27,48 +27,71 @@ export const toMulti = (c: LDSingleKindContext) => { }; /** - * Clones the LDApplication object and populates the key, id and version fields. + * Clones the LDApplication object and populates the key, envAttributesVersion, id and version fields. * * @param crypto * @param info + * @param applicationTags * @param config - * @return An LDApplication object with populated key, id and version. + * @return An LDApplication object with populated key, envAttributesVersion, id and version. */ -export const addApplicationInfo = ({ crypto, info }: Platform, config: Configuration) => { - const { name, version, wrapperName, wrapperVersion } = info.sdkData(); +export const addApplicationInfo = ( + { crypto, info }: Platform, + { application: applicationTags }: Configuration, +): LDApplication | undefined => { const { ld_application } = info.platformData(); + const app = deepCompact(ld_application) ?? ({} as LDApplication); + const id = applicationTags?.id || app?.id; - const ldApplication = clone(ld_application) ?? {}; - ldApplication.id = config.application?.id || ldApplication?.id || name || wrapperName; - ldApplication.name = ldApplication?.name || name || wrapperName; - ldApplication.version = - config.application?.version || ldApplication.version || version || wrapperVersion; + if (id) { + app.id = id; - const hasher = crypto.createHash('sha256'); - hasher.update(ldApplication.id!); - ldApplication.key = hasher.digest('base64'); - ldApplication.envAttributesVersion = - ldApplication.envAttributesVersion || defaultAutoEnvSchemaVersion; + const version = applicationTags?.version || app?.version; + if (version) { + app.version = version; + } - return ldApplication; + const hasher = crypto.createHash('sha256'); + hasher.update(app.id); + app.key = hasher.digest('base64'); + app.envAttributesVersion = app.envAttributesVersion || defaultAutoEnvSchemaVersion; + + return app; + } + + return undefined; }; /** - * Clones the LDDevice object and populates the key field. + * Clones the LDDevice object and populates the key and envAttributesVersion field. * * @param platform - * @return An LDDevice object with populated key. + * @return An LDDevice object with populated key and envAttributesVersion. */ export const addDeviceInfo = async (platform: Platform) => { const { ld_device, os } = platform.info.platformData(); - const ldDevice = clone(ld_device); + const device = deepCompact(ld_device) ?? ({} as LDDevice); - ldDevice.os.name = os?.name || ldDevice.os.name; - ldDevice.os.version = os?.version || ldDevice.os.version; - ldDevice.key = await getOrGenerateKey('ld_device', platform); - ldDevice.envAttributesVersion = ldDevice.envAttributesVersion || defaultAutoEnvSchemaVersion; + const osName = os?.name || device.os?.name || ''; + const osVersion = os?.version || device.os?.version || ''; + const osFamily = device.os?.family || ''; + + if (osName || osVersion || osFamily) { + device.os = { + name: osName, + version: osVersion, + family: osFamily, + }; + } - return ldDevice; + // Check if device has any meaningful data before we return it. + if (Object.keys(device).filter((k) => k !== 'key' && k !== 'envAttributesVersion').length) { + device.key = await getOrGenerateKey('ld_device', platform); + device.envAttributesVersion = device.envAttributesVersion || defaultAutoEnvSchemaVersion; + return device; + } + + return undefined; }; export const addAutoEnv = async (context: LDContext, platform: Platform, config: Configuration) => { @@ -76,12 +99,18 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config: if (isLegacyUser(context)) { return context as LDUser; } + const ld_application = addApplicationInfo(platform, config); + const ld_device = await addDeviceInfo(platform); + + if (!ld_application && !ld_device) { + return context; + } const multi = isSingleKind(context) ? toMulti(context) : context; return { ...multi, - ld_application: addApplicationInfo(platform, config), - ld_device: await addDeviceInfo(platform), + ...(ld_application ? { ld_application } : {}), + ...(ld_device ? { ld_device } : {}), } as LDMultiKindContext; }; From 04bb98fb7cfe363063bf1b6cc3db9f6a9bdea218 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 10:36:38 -0800 Subject: [PATCH 07/33] fix: Respect customer provided ld_application and ld_device contexts. --- .../sdk-client/src/utils/addAutoEnv.test.ts | 114 +++++++++++++++++- .../shared/sdk-client/src/utils/addAutoEnv.ts | 30 +++-- 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts index 2e074017c..9273a0c10 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts @@ -1,4 +1,4 @@ -import { Info, type LDContext, LDUser } from '@launchdarkly/js-sdk-common'; +import { Info, type LDContext, LDMultiKindContext, LDUser } from '@launchdarkly/js-sdk-common'; import { basicPlatform } from '@launchdarkly/private-js-mocks'; import Configuration from '../configuration'; @@ -33,6 +33,118 @@ describe('addAutoEnv', () => { expect(result).toEqual(user); }); + test('customer provides single context of kind ld_application. should only add ld_device.', async () => { + const config = new Configuration(); + const context = { kind: 'ld_application', key: 'test-customer-app-key-1', name: 'test-app' }; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + ld_application: { + key: 'test-customer-app-key-1', + name: 'test-app', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { + family: 'orange', + name: 'An OS', + version: '1.0.1', + }, + }, + }); + }); + + test('customer provides multi context with an ld_application context. should only add ld_device.', async () => { + const config = new Configuration(); + const context = { + kind: 'multi', + ld_application: { + key: 'test-customer-app-key-1', + name: 'test-app', + }, + } as LDMultiKindContext; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + ...context, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { + family: 'orange', + name: 'An OS', + version: '1.0.1', + }, + }, + }); + }); + + test('customer provides single context of kind ld_device. should only add ld_application.', async () => { + const config = new Configuration(); + const context = { kind: 'ld_device', key: 'test-customer-dev-key-1', name: 'test-dev' }; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + ld_device: { + key: 'test-customer-dev-key-1', + name: 'test-dev', + }, + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + }); + }); + + test('customer provides multi context with ld_device context. should only add ld_application.', async () => { + const config = new Configuration(); + const context = { + kind: 'multi', + ld_device: { + key: 'test-customer-dev-key-1', + name: 'test-dev', + }, + } as LDMultiKindContext; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + ...context, + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + }); + }); + + test('customer provides ld_application and ld_device contexts. no changes.', async () => { + const config = new Configuration(); + const context = { + kind: 'multi', + ld_application: { + key: 'test-customer-app-key-1', + name: 'test-app', + }, + ld_device: { + key: 'test-customer-dev-key-1', + name: 'test-dev', + }, + } as LDMultiKindContext; + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual(context); + }); + test('nothing to add return context unchanged', async () => { info.platformData = jest.fn().mockReturnValue({}); const config = new Configuration(); diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.ts index e79e56594..ef26af4b5 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.ts @@ -99,18 +99,28 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config: if (isLegacyUser(context)) { return context as LDUser; } - const ld_application = addApplicationInfo(platform, config); - const ld_device = await addDeviceInfo(platform); - if (!ld_application && !ld_device) { - return context; + let ld_application: LDApplication | undefined; + let ld_device: LDDevice | undefined; + + // keep customer contexts if they exist + if (context.kind !== 'ld_application' && !context.ld_application) { + ld_application = addApplicationInfo(platform, config); } - const multi = isSingleKind(context) ? toMulti(context) : context; + if (context.kind !== 'ld_device' && !context.ld_device) { + ld_device = await addDeviceInfo(platform); + } - return { - ...multi, - ...(ld_application ? { ld_application } : {}), - ...(ld_device ? { ld_device } : {}), - } as LDMultiKindContext; + if (ld_application || ld_device) { + const multi = isSingleKind(context) ? toMulti(context) : context; + + return { + ...multi, + ...(ld_application ? { ld_application } : {}), + ...(ld_device ? { ld_device } : {}), + } as LDMultiKindContext; + } + + return context; }; From 75bb880f7986902d725b43d8122b7c9015916a11 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 12:12:46 -0800 Subject: [PATCH 08/33] fix: Add mandatory autoEnvAttributes argument to LDClient constructor. --- .../sdk-client/src/LDClientImpl.storage.test.ts | 6 +++--- .../shared/sdk-client/src/LDClientImpl.test.ts | 12 ++++++++---- packages/shared/sdk-client/src/LDClientImpl.ts | 5 +++-- .../sdk-client/src/api/AutoEnvAttributes.ts | 15 +++++++++++++++ packages/shared/sdk-client/src/api/LDOptions.ts | 12 ------------ packages/shared/sdk-client/src/api/index.ts | 1 + .../src/configuration/Configuration.test.ts | 1 - .../sdk-client/src/configuration/Configuration.ts | 1 - .../sdk-client/src/configuration/validators.ts | 1 - 9 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 packages/shared/sdk-client/src/api/AutoEnvAttributes.ts diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 563db1cad..9df26d83f 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -1,6 +1,7 @@ import { clone, type LDContext, noop } from '@launchdarkly/js-sdk-common'; import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; +import { AutoEnvAttributes } from './api'; import LDEmitter from './api/LDEmitter'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; @@ -77,8 +78,7 @@ describe('sdk-client storage', () => { .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); - ldc = new LDClientImpl(testSdkKey, basicPlatform, { - autoEnvAttributes: false, + ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, basicPlatform, { logger, sendEvents: false, }); @@ -121,7 +121,7 @@ describe('sdk-client storage', () => { }); test('initialize from storage succeeds with auto env', async () => { - ldc = new LDClientImpl(testSdkKey, basicPlatform, { + ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, { logger, sendEvents: false, }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 1859997ce..98ab55ac6 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -6,6 +6,7 @@ import { setupMockStreamingProcessor, } from '@launchdarkly/private-js-mocks'; +import { AutoEnvAttributes } from './api'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; import { Flags } from './types'; @@ -51,7 +52,10 @@ describe('sdk-client object', () => { basicPlatform.crypto.randomUUID.mockReturnValue('random1'); hasher.digest.mockReturnValue('digested1'); - ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); + ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, { + logger, + sendEvents: false, + }); jest .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); @@ -62,7 +66,8 @@ describe('sdk-client object', () => { }); test('instantiate with blank options', () => { - ldc = new LDClientImpl(testSdkKey, basicPlatform, {}); + ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, {}); + expect(ldc.config).toMatchObject({ allAttributesPrivate: false, baseUri: 'https://sdk.launchdarkly.com', @@ -162,8 +167,7 @@ describe('sdk-client object', () => { test('identify success without auto env', async () => { defaultPutResponse['dev-test-flag'].value = false; const carContext: LDContext = { kind: 'car', key: 'mazda-cx7' }; - ldc = new LDClientImpl(testSdkKey, basicPlatform, { - autoEnvAttributes: false, + ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, basicPlatform, { logger, sendEvents: false, }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 3c06ac079..7b14220a6 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -17,7 +17,7 @@ import { TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { LDClient, type LDOptions } from './api'; +import { AutoEnvAttributes, LDClient, type LDOptions } from './api'; import LDEmitter, { EventName } from './api/LDEmitter'; import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; @@ -51,6 +51,7 @@ export default class LDClientImpl implements LDClient { */ constructor( public readonly sdkKey: string, + public readonly autoEnvAttributes: AutoEnvAttributes, public readonly platform: Platform, options: LDOptions, internalOptions?: internal.LDInternalOptions, @@ -234,7 +235,7 @@ export default class LDClientImpl implements LDClient { async identify(pristineContext: LDContext, _hash?: string): Promise { let context = await ensureKey(pristineContext, this.platform); - if (this.config.autoEnvAttributes) { + if (this.autoEnvAttributes === AutoEnvAttributes.Enabled) { context = await addAutoEnv(context, this.platform, this.config); } diff --git a/packages/shared/sdk-client/src/api/AutoEnvAttributes.ts b/packages/shared/sdk-client/src/api/AutoEnvAttributes.ts new file mode 100644 index 000000000..bbaa747fc --- /dev/null +++ b/packages/shared/sdk-client/src/api/AutoEnvAttributes.ts @@ -0,0 +1,15 @@ +/* eslint-disable import/prefer-default-export */ +/** + * Enable / disable Auto environment attributes. When enabled, the SDK will automatically + * provide data about the mobile environment where the application is running. This data makes it simpler to target + * your mobile customers based on application name or version, or on device characteristics including manufacturer, + * model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. To learn more, + * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). + * for more documentation. + * + * The default is disabled. + */ +export enum AutoEnvAttributes { + Disabled, + Enabled, +} diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 9d85323e8..d7e210bcc 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -35,18 +35,6 @@ export interface LDOptions { version?: string; }; - /** - * Enable / disable Auto environment attributes. When enabled, the SDK will automatically - * provide data about the mobile environment where the application is running. This data makes it simpler to target - * your mobile customers based on application name or version, or on device characteristics including manufacturer, - * model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. To learn more, - * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). - * for more documentation. - * - * The default is true. - */ - autoEnvAttributes?: boolean; - /** * The base uri for the LaunchDarkly server. * diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 3492632b1..de6ffb64a 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -1,2 +1,3 @@ export * from './LDOptions'; export * from './LDClient'; +export * from './AutoEnvAttributes'; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts index 3305f6395..6a569b296 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -12,7 +12,6 @@ describe('Configuration', () => { expect(config).toMatchObject({ allAttributesPrivate: false, - autoEnvAttributes: true, baseUri: 'https://sdk.launchdarkly.com', capacity: 100, diagnosticOptOut: false, diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index e50f37f5a..e4f25940e 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -28,7 +28,6 @@ export default class Configuration { public readonly flushInterval = 2; public readonly streamInitialReconnectDelay = 1; - public readonly autoEnvAttributes = true; public readonly allAttributesPrivate = false; public readonly diagnosticOptOut = false; public readonly withReasons = false; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 9688e99c8..a48f9c5cf 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -25,7 +25,6 @@ const validators: Record = { flushInterval: TypeValidators.numberWithMin(2), streamInitialReconnectDelay: TypeValidators.numberWithMin(0), - autoEnvAttributes: TypeValidators.Boolean, allAttributesPrivate: TypeValidators.Boolean, diagnosticOptOut: TypeValidators.Boolean, withReasons: TypeValidators.Boolean, From 5243f3fe225d991167d296b9f0da2fd1c336e994 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 12:41:13 -0800 Subject: [PATCH 09/33] chore: Move AutoEnvAttributes enum to common. --- .../shared/common/src/api/platform/AutoEnv.ts | 16 ++++++++++++++++ .../sdk-client/src/LDClientImpl.storage.test.ts | 3 +-- .../shared/sdk-client/src/LDClientImpl.test.ts | 3 +-- packages/shared/sdk-client/src/LDClientImpl.ts | 3 ++- .../sdk-client/src/api/AutoEnvAttributes.ts | 15 --------------- packages/shared/sdk-client/src/api/index.ts | 1 - 6 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 packages/shared/sdk-client/src/api/AutoEnvAttributes.ts diff --git a/packages/shared/common/src/api/platform/AutoEnv.ts b/packages/shared/common/src/api/platform/AutoEnv.ts index 536374d14..14fc58086 100644 --- a/packages/shared/common/src/api/platform/AutoEnv.ts +++ b/packages/shared/common/src/api/platform/AutoEnv.ts @@ -1,3 +1,19 @@ +/* eslint-disable import/prefer-default-export */ +/** + * Enable / disable Auto environment attributes. When enabled, the SDK will automatically + * provide data about the mobile environment where the application is running. This data makes it simpler to target + * your mobile customers based on application name or version, or on device characteristics including manufacturer, + * model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. To learn more, + * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). + * for more documentation. + * + * The default is disabled. + */ +export enum AutoEnvAttributes { + Disabled, + Enabled, +} + interface AutoEnvCommon { /** * Unique key for the context kind. diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 9df26d83f..8fd27e69c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -1,7 +1,6 @@ -import { clone, type LDContext, noop } from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, type LDContext, noop } from '@launchdarkly/js-sdk-common'; import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; -import { AutoEnvAttributes } from './api'; import LDEmitter from './api/LDEmitter'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 98ab55ac6..3dda58c22 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -1,4 +1,4 @@ -import { clone, LDContext } from '@launchdarkly/js-sdk-common'; +import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common'; import { basicPlatform, hasher, @@ -6,7 +6,6 @@ import { setupMockStreamingProcessor, } from '@launchdarkly/private-js-mocks'; -import { AutoEnvAttributes } from './api'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; import { Flags } from './types'; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 7b14220a6..5c27e3d6e 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -1,4 +1,5 @@ import { + AutoEnvAttributes, ClientContext, clone, Context, @@ -17,7 +18,7 @@ import { TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { AutoEnvAttributes, LDClient, type LDOptions } from './api'; +import { LDClient, type LDOptions } from './api'; import LDEmitter, { EventName } from './api/LDEmitter'; import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; diff --git a/packages/shared/sdk-client/src/api/AutoEnvAttributes.ts b/packages/shared/sdk-client/src/api/AutoEnvAttributes.ts deleted file mode 100644 index bbaa747fc..000000000 --- a/packages/shared/sdk-client/src/api/AutoEnvAttributes.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable import/prefer-default-export */ -/** - * Enable / disable Auto environment attributes. When enabled, the SDK will automatically - * provide data about the mobile environment where the application is running. This data makes it simpler to target - * your mobile customers based on application name or version, or on device characteristics including manufacturer, - * model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. To learn more, - * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). - * for more documentation. - * - * The default is disabled. - */ -export enum AutoEnvAttributes { - Disabled, - Enabled, -} diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index de6ffb64a..3492632b1 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -1,3 +1,2 @@ export * from './LDOptions'; export * from './LDClient'; -export * from './AutoEnvAttributes'; From 22b97bdc53e4d68d0cdbb724275cfa29e2a0c4c4 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 13:09:16 -0800 Subject: [PATCH 10/33] fix: Make all device.os properties optional. --- packages/shared/common/src/api/platform/AutoEnv.ts | 6 +++--- .../shared/sdk-client/src/utils/addAutoEnv.test.ts | 2 +- packages/shared/sdk-client/src/utils/addAutoEnv.ts | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/shared/common/src/api/platform/AutoEnv.ts b/packages/shared/common/src/api/platform/AutoEnv.ts index 14fc58086..2256210de 100644 --- a/packages/shared/common/src/api/platform/AutoEnv.ts +++ b/packages/shared/common/src/api/platform/AutoEnv.ts @@ -46,8 +46,8 @@ export interface LDDevice extends AutoEnvCommon { /** * The family of operating system. */ - family: string; - name: string; - version: string; + family?: string; + name?: string; + version?: string; }; } diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts index 9273a0c10..6a118fda4 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts @@ -379,7 +379,7 @@ describe('addAutoEnv', () => { expect(ldDevice).toEqual({ envAttributesVersion: '1.0', key: 'test-device-key-1', - os: { name: '', version: '', family: 'orange' }, + os: { family: 'orange' }, }); }); diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.ts index ef26af4b5..515c509d0 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.ts @@ -72,15 +72,15 @@ export const addDeviceInfo = async (platform: Platform) => { const { ld_device, os } = platform.info.platformData(); const device = deepCompact(ld_device) ?? ({} as LDDevice); - const osName = os?.name || device.os?.name || ''; - const osVersion = os?.version || device.os?.version || ''; - const osFamily = device.os?.family || ''; + const name = os?.name || device.os?.name; + const version = os?.version || device.os?.version; + const family = device.os?.family; - if (osName || osVersion || osFamily) { + if (name || version || family) { device.os = { - name: osName, - version: osVersion, - family: osFamily, + ...(name ? { name } : {}), + ...(version ? { version } : {}), + ...(family ? { family } : {}), }; } From d365184cac9aa21d33c1590e2b683a807aaabf9f Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 13:48:32 -0800 Subject: [PATCH 11/33] fix: Added mandatory AutoEnvAttributes constructor arg. --- .eslintrc.js | 20 +++++++++++++--- package.json | 3 ++- packages/sdk/react-native/README.md | 4 ++-- packages/sdk/react-native/example/App.tsx | 3 ++- packages/sdk/react-native/package.json | 2 +- .../src/ReactNativeLDClient.test.ts | 4 ++-- .../react-native/src/ReactNativeLDClient.ts | 17 +++++++++++--- .../src/provider/LDProvider.test.tsx | 23 ++++++++++--------- .../src/provider/setupListeners.test.ts | 4 +++- .../sdk/react-native/tsconfig.eslint.json | 2 +- 10 files changed, 56 insertions(+), 26 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index dbc1e1a4d..4a2d284a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,14 +1,15 @@ module.exports = { env: { node: true, + 'jest/globals': true, }, extends: ['airbnb-base', 'airbnb-typescript/base', 'prettier'], parser: '@typescript-eslint/parser', parserOptions: { project: './tsconfig.eslint.json', }, - plugins: ['@typescript-eslint', 'prettier'], - ignorePatterns: ['**/dist/**', '**/vercel/examples/**', '**/fromExternal/**'], + plugins: ['@typescript-eslint', 'prettier', 'jest'], + ignorePatterns: ['**/dist/**', '**/vercel/examples/**', '**/react-native/example/**', '**/fromExternal/**'], rules: { '@typescript-eslint/lines-between-class-members': 'off', '@typescript-eslint/no-unused-vars': [ @@ -20,7 +21,12 @@ module.exports = { 'import/no-extraneous-dependencies': [ 'error', { - devDependencies: ['**/jest*.ts', '**/*.test.ts', '**/rollup.config.ts'], + devDependencies: [ + '**/jest*.ts', + '**/*.test.ts', + '**/rollup.config.ts', + '**/*{.,_}{test,spec}.{ts,tsx}', + ], }, ], 'import/default': 'error', @@ -30,5 +36,13 @@ module.exports = { 'import/no-cycle': 'error', 'import/no-useless-path-segments': 'error', 'import/no-duplicates': 'error', + 'jest/no-disabled-tests': 'warn', + 'jest/no-focused-tests': 'error', + 'jest/no-identical-title': 'error', + // 'jest/prefer-to-have-length': 'warn', + 'jest/valid-expect': 'error', + }, + globals: { + BigInt: 'readonly', }, }; diff --git a/package.json b/package.json index 1538409bc..fa433e048 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,12 @@ "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", - "eslint": "^8.45.0", + "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^17.1.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", "typedoc": "0.25.0", diff --git a/packages/sdk/react-native/README.md b/packages/sdk/react-native/README.md index cff58ab1b..ce43eecb1 100644 --- a/packages/sdk/react-native/README.md +++ b/packages/sdk/react-native/README.md @@ -42,7 +42,7 @@ and re-run pod install for [auto-linking to work](https://github.com/react-nativ // App.tsx import { LDProvider, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; -const featureClient = new ReactNativeLDClient('mobile-key'); +const featureClient = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled); const userContext = { kind: 'user', key: 'test-user-1' }; const App = () => ( @@ -100,7 +100,7 @@ yarn && yarn ios-go ## Verifying SDK build provenance with the SLSA framework -LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. To learn more, see the [provenance guide](PROVENANCE.md). +LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. To learn more, see the [provenance guide](PROVENANCE.md). ## About LaunchDarkly diff --git a/packages/sdk/react-native/example/App.tsx b/packages/sdk/react-native/example/App.tsx index 0855d46a7..4079f9d5b 100644 --- a/packages/sdk/react-native/example/App.tsx +++ b/packages/sdk/react-native/example/App.tsx @@ -1,10 +1,11 @@ import { MOBILE_KEY } from '@env'; +import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; import { LDProvider, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; import Welcome from './src/welcome'; -const featureClient = new ReactNativeLDClient(MOBILE_KEY); +const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled); const App = () => { return ( diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index 13d3c1eec..96476e2fb 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -30,7 +30,7 @@ "build": "npx tsc && yarn link-dev", "tsw": "yarn tsc --watch", "start": "rimraf dist && yarn tsw", - "lint": "eslint . --ext .ts", + "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", "test": "jest", "coverage": "yarn test --coverage", diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.test.ts b/packages/sdk/react-native/src/ReactNativeLDClient.test.ts index f7c9f717f..00569fcd3 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.test.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.test.ts @@ -1,4 +1,4 @@ -import { type LDContext } from '@launchdarkly/js-client-sdk-common'; +import { AutoEnvAttributes, type LDContext } from '@launchdarkly/js-client-sdk-common'; import ReactNativeLDClient from './ReactNativeLDClient'; @@ -6,7 +6,7 @@ describe('ReactNativeLDClient', () => { let ldc: ReactNativeLDClient; beforeEach(() => { - ldc = new ReactNativeLDClient('mobile-key', { sendEvents: false }); + ldc = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { sendEvents: false }); }); test('constructing a new client', () => { diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 5058b4b9a..2b13d9889 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -1,4 +1,5 @@ import { + AutoEnvAttributes, base64UrlEncode, BasicLogger, internal, @@ -15,7 +16,7 @@ import createPlatform from './platform'; * * @example * ```tsx - * const featureClient = new ReactNativeLDClient(MOBILE_KEY); + * const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled); * * * @@ -27,9 +28,13 @@ export default class ReactNativeLDClient extends LDClientImpl { * Creates an instance of the LaunchDarkly client. * * @param sdkKey The LaunchDarkly mobile key. + * @param autoEnvAttributes Enable / disable Auto environment attributes. When enabled, the SDK will automatically + * provide data about the mobile environment where the application is running. To learn more, + * read [Automatic environment attributes](https://docs.launchdarkly.com/sdk/features/environment-attributes). + * for more documentation. * @param options {@link LDOptions} to initialize the client with. */ - constructor(sdkKey: string, options: LDOptions = {}) { + constructor(sdkKey: string, autoEnvAttributes: AutoEnvAttributes, options: LDOptions = {}) { const logger = options.logger ?? new BasicLogger({ @@ -43,7 +48,13 @@ export default class ReactNativeLDClient extends LDClientImpl { diagnosticEventPath: `/mobile/events/diagnostic`, }; - super(sdkKey, createPlatform(logger), { ...options, logger }, internalOptions); + super( + sdkKey, + autoEnvAttributes, + createPlatform(logger), + { ...options, logger }, + internalOptions, + ); } override createStreamUriPath(context: LDContext) { diff --git a/packages/sdk/react-native/src/provider/LDProvider.test.tsx b/packages/sdk/react-native/src/provider/LDProvider.test.tsx index 146e4673c..548aec1e8 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.test.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; -import type { LDContext, LDOptions } from '@launchdarkly/js-client-sdk-common'; +import { AutoEnvAttributes, LDContext, LDOptions } from '@launchdarkly/js-client-sdk-common'; import { useLDClient } from '../hooks'; import ReactNativeLDClient from '../ReactNativeLDClient'; @@ -23,21 +23,22 @@ const TestApp = () => { describe('LDProvider', () => { let ldc: ReactNativeLDClient; let context: LDContext; - let mockSetupListeners = setupListeners as jest.Mock; + const mockSetupListeners = setupListeners as jest.Mock; beforeEach(() => { jest.useFakeTimers(); (ReactNativeLDClient as jest.Mock).mockImplementation( - (mobileKey: string, _options?: LDOptions) => { - let context: LDContext; + (mobileKey: string, autoEnvAttributes: AutoEnvAttributes, _options?: LDOptions) => { + let internalCachedContext: LDContext; return { sdkKey: mobileKey, + autoEnvAttributes, identify: jest.fn((c: LDContext) => { - context = c; + internalCachedContext = c; return Promise.resolve(); }), - getContext: jest.fn(() => context), + getContext: jest.fn(() => internalCachedContext), on: jest.fn(), logger: { debug: jest.fn(), @@ -48,7 +49,7 @@ describe('LDProvider', () => { mockSetupListeners.mockImplementation((client: ReactNativeLDClient, setState: any) => { setState({ client }); }); - ldc = new ReactNativeLDClient('mobile-key'); + ldc = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled); context = { kind: 'user', key: 'test-user-key-1' }; }); @@ -82,10 +83,10 @@ describe('LDProvider', () => { }); test('identify errors are caught', async () => { - (ldc.identify as jest.Mock).mockImplementation(() => { - return Promise.reject('faking error when identifying'); - }); - const { getByText } = render( + (ldc.identify as jest.Mock).mockImplementation(() => + Promise.reject(new Error('faking error when identifying')), + ); + render( , diff --git a/packages/sdk/react-native/src/provider/setupListeners.test.ts b/packages/sdk/react-native/src/provider/setupListeners.test.ts index 763195e00..4d6878a29 100644 --- a/packages/sdk/react-native/src/provider/setupListeners.test.ts +++ b/packages/sdk/react-native/src/provider/setupListeners.test.ts @@ -1,3 +1,5 @@ +import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; + import ReactNativeLDClient from '../ReactNativeLDClient'; import setupListeners from './setupListeners'; @@ -11,7 +13,7 @@ describe('setupListeners', () => { beforeEach(() => { mockSetState = jest.fn(); - ldc = new ReactNativeLDClient('mob-test-key'); + ldc = new ReactNativeLDClient('mob-test-key', AutoEnvAttributes.Enabled); }); afterEach(() => resetAllMocks()); diff --git a/packages/sdk/react-native/tsconfig.eslint.json b/packages/sdk/react-native/tsconfig.eslint.json index 56c9b3830..8241f86c3 100644 --- a/packages/sdk/react-native/tsconfig.eslint.json +++ b/packages/sdk/react-native/tsconfig.eslint.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["/**/*.ts"], + "include": ["/**/*.ts", "/**/*.tsx"], "exclude": ["node_modules"] } From b47a5e29e627483c30228b75c415a741c0d1055a Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 14:49:44 -0800 Subject: [PATCH 12/33] chore: Log warning if auto env attributes are not added because they already exist. --- .../sdk-client/src/utils/addAutoEnv.test.ts | 40 ++++++++++++++++++- .../shared/sdk-client/src/utils/addAutoEnv.ts | 12 +++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts index 6a118fda4..714e30427 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts @@ -1,5 +1,5 @@ import { Info, type LDContext, LDMultiKindContext, LDUser } from '@launchdarkly/js-sdk-common'; -import { basicPlatform } from '@launchdarkly/private-js-mocks'; +import { basicPlatform, logger } from '@launchdarkly/private-js-mocks'; import Configuration from '../configuration'; import { addApplicationInfo, addAutoEnv, addDeviceInfo, toMulti } from './addAutoEnv'; @@ -207,6 +207,44 @@ describe('addAutoEnv', () => { }, }); }); + + test('log warning when ld_application is not added', async () => { + const config = new Configuration({ logger }); + const context: LDContext = { + kind: 'multi', + org: { key: 'test-org-key-1', name: 'Best company' }, + ld_application: { + key: 'test-customer-app-key-1', + name: 'test-app', + }, + }; + + await addAutoEnv(context, basicPlatform, config); + + expect(config.logger.warn).toHaveBeenCalledTimes(1); + expect(config.logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/ld_application.*already exists/), + ); + }); + + test('log warning when ld_device is not added', async () => { + const config = new Configuration({ logger }); + const context: LDContext = { + kind: 'multi', + org: { key: 'test-org-key-1', name: 'Best company' }, + ld_device: { + key: 'test-customer-dev-key-1', + name: 'test-dev', + }, + }; + + await addAutoEnv(context, basicPlatform, config); + + expect(config.logger.warn).toHaveBeenCalledTimes(1); + expect(config.logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/ld_device.*already exists/), + ); + }); }); describe('addApplicationInfo', () => { diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.ts index 515c509d0..bbb9206c4 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.ts @@ -76,8 +76,10 @@ export const addDeviceInfo = async (platform: Platform) => { const version = os?.version || device.os?.version; const family = device.os?.family; + // only add device.os if there's data if (name || version || family) { device.os = { + // only add props if they are defined ...(name ? { name } : {}), ...(version ? { version } : {}), ...(family ? { family } : {}), @@ -103,15 +105,23 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config: let ld_application: LDApplication | undefined; let ld_device: LDDevice | undefined; - // keep customer contexts if they exist + // Check if customer contexts exist. Only override if they are not provided. if (context.kind !== 'ld_application' && !context.ld_application) { ld_application = addApplicationInfo(platform, config); + } else { + config.logger.warn( + 'Not adding ld_application environment attributes because it already exists.', + ); } if (context.kind !== 'ld_device' && !context.ld_device) { ld_device = await addDeviceInfo(platform); + } else { + config.logger.warn('Not adding ld_device environment attributes because it already exists.'); } + // Unable to automatically add environment attributes for kind: {}. {} already exists. + if (ld_application || ld_device) { const multi = isSingleKind(context) ? toMulti(context) : context; From ebc670aa95da26e4039cc6b10187bb1c63aedf36 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 14:59:26 -0800 Subject: [PATCH 13/33] chore: Remove unused import in platform mock. --- .eslintrc.js | 7 ++++++- packages/shared/mocks/src/platform.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4a2d284a8..5e73909ae 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,7 +9,12 @@ module.exports = { project: './tsconfig.eslint.json', }, plugins: ['@typescript-eslint', 'prettier', 'jest'], - ignorePatterns: ['**/dist/**', '**/vercel/examples/**', '**/react-native/example/**', '**/fromExternal/**'], + ignorePatterns: [ + '**/dist/**', + '**/vercel/examples/**', + '**/react-native/example/**', + '**/fromExternal/**', + ], rules: { '@typescript-eslint/lines-between-class-members': 'off', '@typescript-eslint/no-unused-vars': [ diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/mocks/src/platform.ts index 24bcc0016..3f64c34ea 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -1,4 +1,4 @@ -import type { Encoding, Info, Platform, PlatformData, Requests, SdkData, Storage } from '@common'; +import type { Encoding, Platform, PlatformData, Requests, SdkData, Storage } from '@common'; import { setupCrypto } from './crypto'; From b5ce0978f281c2a5071b10f5e4abe33feadbb748 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 26 Jan 2024 15:15:51 -0800 Subject: [PATCH 14/33] chore: Fix duplicated test name. --- .../shared/sdk-server/__tests__/store/serialization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/store/serialization.test.ts b/packages/shared/sdk-server/__tests__/store/serialization.test.ts index a18ac959d..4656ea551 100644 --- a/packages/shared/sdk-server/__tests__/store/serialization.test.ts +++ b/packages/shared/sdk-server/__tests__/store/serialization.test.ts @@ -196,7 +196,7 @@ describe('when deserializing all data', () => { expect(ref?.isValid).toBeTruthy(); }); - it('handles a flag with an attribute literal in a clause', () => { + it('handles a flag with an attribute ref in a clause', () => { const jsonString = makeSerializedAllData(flagWithAttributeReferenceInClause); const parsed = deserializeAll(jsonString); const ref = parsed?.data.flags.flagName.rules?.[0].clauses?.[0].attributeReference; @@ -249,7 +249,7 @@ describe('when deserializing patch data', () => { expect(ref?.isValid).toBeTruthy(); }); - it('handles a flag with an attribute literal in a clause', () => { + it('handles a flag with an attribute ref in a clause', () => { const jsonString = makeSerializedPatchData(flagWithAttributeReferenceInClause); const parsed = deserializePatch(jsonString); const ref = (parsed?.data as Flag).rules?.[0].clauses?.[0].attributeReference; From ccb841990662a6140a88c2030a987b2788ecf80b Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Sat, 27 Jan 2024 11:21:54 -0800 Subject: [PATCH 15/33] fix: Implemented separate namespaces for anon and contexts. Fixed bug where single contexts with attributes called ld_application and ld_device are not added with auto env attributes. --- packages/shared/common/package.json | 4 +- packages/shared/common/src/utils/index.ts | 2 + .../sdk-client/src/utils/addAutoEnv.test.ts | 107 ++++++++++++++---- .../shared/sdk-client/src/utils/addAutoEnv.ts | 16 ++- .../sdk-client/src/utils/ensureKey.test.ts | 20 ++-- .../shared/sdk-client/src/utils/ensureKey.ts | 2 +- .../src/utils/getOrGenerateKey.test.ts | 57 ++++++++-- .../sdk-client/src/utils/getOrGenerateKey.ts | 33 +++++- 8 files changed, 188 insertions(+), 53 deletions(-) diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 447dd48e0..bb2820864 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -24,7 +24,9 @@ "build": "npx tsc", "clean": "npx tsc --build --clean", "lint": "npx eslint . --ext .ts", - "lint:fix": "yarn run lint --fix" + "lint:fix": "yarn run lint --fix", + "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", + "check": "yarn && yarn prettier && yarn lint && tsc && yarn test" }, "license": "Apache-2.0", "devDependencies": { diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index 7424ac658..ec5abefdb 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,5 +1,6 @@ import clone from './clone'; import { secondsToMillis } from './date'; +import deepCompact from './deepCompact'; import fastDeepEqual from './fast-deep-equal'; import { base64UrlEncode, defaultHeaders, httpErrorMessage, LDHeaders, shouldRetry } from './http'; import noop from './noop'; @@ -9,6 +10,7 @@ import { VoidFunction } from './VoidFunction'; export { base64UrlEncode, clone, + deepCompact, defaultHeaders, fastDeepEqual, httpErrorMessage, diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts index 714e30427..584a7ad1a 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.test.ts @@ -4,13 +4,15 @@ import { basicPlatform, logger } from '@launchdarkly/private-js-mocks'; import Configuration from '../configuration'; import { addApplicationInfo, addAutoEnv, addDeviceInfo, toMulti } from './addAutoEnv'; -describe('addAutoEnv', () => { +describe('automatic environment attributes', () => { let crypto: Crypto; let info: Info; + let config: Configuration; beforeEach(() => { ({ crypto, info } = basicPlatform); (crypto.randomUUID as jest.Mock).mockResolvedValue('test-device-key-1'); + config = new Configuration({ logger }); }); afterEach(() => { @@ -26,7 +28,6 @@ describe('addAutoEnv', () => { describe('addAutoEnv', () => { test('LDUser is unsupported', async () => { - const config = new Configuration(); const user: LDUser = { key: 'legacy-user-key', name: 'bob' }; const result = await addAutoEnv(user, basicPlatform, config); @@ -34,7 +35,6 @@ describe('addAutoEnv', () => { }); test('customer provides single context of kind ld_application. should only add ld_device.', async () => { - const config = new Configuration(); const context = { kind: 'ld_application', key: 'test-customer-app-key-1', name: 'test-app' }; const result = await addAutoEnv(context, basicPlatform, config); @@ -58,7 +58,6 @@ describe('addAutoEnv', () => { }); test('customer provides multi context with an ld_application context. should only add ld_device.', async () => { - const config = new Configuration(); const context = { kind: 'multi', ld_application: { @@ -84,7 +83,6 @@ describe('addAutoEnv', () => { }); test('customer provides single context of kind ld_device. should only add ld_application.', async () => { - const config = new Configuration(); const context = { kind: 'ld_device', key: 'test-customer-dev-key-1', name: 'test-dev' }; const result = await addAutoEnv(context, basicPlatform, config); @@ -105,7 +103,6 @@ describe('addAutoEnv', () => { }); test('customer provides multi context with ld_device context. should only add ld_application.', async () => { - const config = new Configuration(); const context = { kind: 'multi', ld_device: { @@ -128,7 +125,6 @@ describe('addAutoEnv', () => { }); test('customer provides ld_application and ld_device contexts. no changes.', async () => { - const config = new Configuration(); const context = { kind: 'multi', ld_application: { @@ -147,7 +143,7 @@ describe('addAutoEnv', () => { test('nothing to add return context unchanged', async () => { info.platformData = jest.fn().mockReturnValue({}); - const config = new Configuration(); + const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; const result = await addAutoEnv(context, basicPlatform, config); @@ -155,7 +151,6 @@ describe('addAutoEnv', () => { }); test('single kind should be converted to multi', async () => { - const config = new Configuration(); const context = { kind: 'user', key: 'test-user-key-1', name: 'bob' }; const result = await addAutoEnv(context, basicPlatform, config); @@ -180,7 +175,6 @@ describe('addAutoEnv', () => { }); test('multi kind', async () => { - const config = new Configuration(); const context: LDContext = { kind: 'multi', user: { key: 'test-user-key-1', name: 'bob' }, @@ -209,7 +203,6 @@ describe('addAutoEnv', () => { }); test('log warning when ld_application is not added', async () => { - const config = new Configuration({ logger }); const context: LDContext = { kind: 'multi', org: { key: 'test-org-key-1', name: 'Best company' }, @@ -228,7 +221,6 @@ describe('addAutoEnv', () => { }); test('log warning when ld_device is not added', async () => { - const config = new Configuration({ logger }); const context: LDContext = { kind: 'multi', org: { key: 'test-org-key-1', name: 'Best company' }, @@ -245,11 +237,89 @@ describe('addAutoEnv', () => { expect.stringMatching(/ld_device.*already exists/), ); }); + + test('single context with an attribute called ld_application should have auto env attributes', async () => { + const context: LDContext = { + kind: 'user', + key: 'test-user-key-1', + name: 'bob', + ld_application: { + key: 'test-customer-app-key-1', + name: 'test-dev', + }, + }; + + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, + user: { + key: 'test-user-key-1', + name: 'bob', + ld_application: { + key: 'test-customer-app-key-1', + name: 'test-dev', + }, + }, + }); + }); + + test('single context with an attribute called ld_device should have auto env attributes', async () => { + const context: LDContext = { + kind: 'user', + key: 'test-user-key-1', + name: 'bob', + ld_device: { + key: 'test-customer-dev-key-1', + name: 'test-dev', + }, + }; + + const result = await addAutoEnv(context, basicPlatform, config); + + expect(result).toEqual({ + kind: 'multi', + ld_application: { + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + key: '1234567890123456', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + envAttributesVersion: '1.0', + key: 'test-device-key-1', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, + user: { + key: 'test-user-key-1', + name: 'bob', + ld_device: { + key: 'test-customer-dev-key-1', + name: 'test-dev', + }, + }, + }); + }); }); describe('addApplicationInfo', () => { test('add application tags id, version', () => { - const config = new Configuration({ + config = new Configuration({ application: { id: 'com.from-config.ld', version: '2.2.2' }, }); const ldApplication = addApplicationInfo(basicPlatform, config); @@ -264,7 +334,6 @@ describe('addAutoEnv', () => { }); test('add auto env application id, name, version', () => { - const config = new Configuration(); const ldApplication = addApplicationInfo(basicPlatform, config); expect(ldApplication).toEqual({ @@ -289,7 +358,7 @@ describe('addAutoEnv', () => { envAttributesVersion: 0, }, }); - const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); expect(ldApplication).toEqual({ @@ -301,7 +370,7 @@ describe('addAutoEnv', () => { test('omit if both tags and auto generated data are unavailable', () => { info.platformData = jest.fn().mockReturnValueOnce({}); - const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); expect(ldApplication).toBeUndefined(); @@ -317,7 +386,7 @@ describe('addAutoEnv', () => { id: undefined, }, }); - const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); expect(ldApplication).toBeUndefined(); @@ -327,7 +396,7 @@ describe('addAutoEnv', () => { info.platformData = jest.fn().mockReturnValueOnce({ ld_application: { key: 'key-from-sdk', envAttributesVersion: '0.0.1' }, }); - const config = new Configuration(); + const ldApplication = addApplicationInfo(basicPlatform, config); expect(ldApplication).toBeUndefined(); @@ -337,7 +406,7 @@ describe('addAutoEnv', () => { info.platformData = jest .fn() .mockReturnValueOnce({ ld_application: { version: null, locale: '' } }); - const config = new Configuration({ application: { version: '1.2.3' } }); + config = new Configuration({ application: { version: '1.2.3' } }); const ldApplication = addApplicationInfo(basicPlatform, config); expect(ldApplication).toBeUndefined(); diff --git a/packages/shared/sdk-client/src/utils/addAutoEnv.ts b/packages/shared/sdk-client/src/utils/addAutoEnv.ts index bbb9206c4..c1be8c1ef 100644 --- a/packages/shared/sdk-client/src/utils/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/utils/addAutoEnv.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { + deepCompact, internal, LDApplication, LDContext, @@ -9,12 +10,11 @@ import { LDUser, Platform, } from '@launchdarkly/js-sdk-common'; -import deepCompact from '@launchdarkly/js-sdk-common/dist/utils/deepCompact'; import Configuration from '../configuration'; import { getOrGenerateKey } from './getOrGenerateKey'; -const { isLegacyUser, isSingleKind } = internal; +const { isLegacyUser, isSingleKind, isMultiKind } = internal; const defaultAutoEnvSchemaVersion = '1.0'; export const toMulti = (c: LDSingleKindContext) => { @@ -88,7 +88,7 @@ export const addDeviceInfo = async (platform: Platform) => { // Check if device has any meaningful data before we return it. if (Object.keys(device).filter((k) => k !== 'key' && k !== 'envAttributesVersion').length) { - device.key = await getOrGenerateKey('ld_device', platform); + device.key = await getOrGenerateKey('context', 'ld_device', platform); device.envAttributesVersion = device.envAttributesVersion || defaultAutoEnvSchemaVersion; return device; } @@ -106,7 +106,10 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config: let ld_device: LDDevice | undefined; // Check if customer contexts exist. Only override if they are not provided. - if (context.kind !== 'ld_application' && !context.ld_application) { + if ( + (isSingleKind(context) && context.kind !== 'ld_application') || + (isMultiKind(context) && !context.ld_application) + ) { ld_application = addApplicationInfo(platform, config); } else { config.logger.warn( @@ -114,7 +117,10 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config: ); } - if (context.kind !== 'ld_device' && !context.ld_device) { + if ( + (isSingleKind(context) && context.kind !== 'ld_device') || + (isMultiKind(context) && !context.ld_device) + ) { ld_device = await addDeviceInfo(platform); } else { config.logger.warn('Not adding ld_device environment attributes because it already exists.'); diff --git a/packages/shared/sdk-client/src/utils/ensureKey.test.ts b/packages/shared/sdk-client/src/utils/ensureKey.test.ts index bee38193f..69ef4a9a5 100644 --- a/packages/shared/sdk-client/src/utils/ensureKey.test.ts +++ b/packages/shared/sdk-client/src/utils/ensureKey.test.ts @@ -9,7 +9,7 @@ import type { import { basicPlatform } from '@launchdarkly/private-js-mocks'; import ensureKey from './ensureKey'; -import { addNamespace, getOrGenerateKey } from './getOrGenerateKey'; +import { getOrGenerateKey, prefixNamespace } from './getOrGenerateKey'; describe('ensureKey', () => { let crypto: Crypto; @@ -26,30 +26,30 @@ describe('ensureKey', () => { jest.resetAllMocks(); }); - test('addNamespace', async () => { - const nsKey = addNamespace('org'); - expect(nsKey).toEqual('LaunchDarkly_AnonKeys_org'); + test('prefixNamespace', async () => { + const nsKey = prefixNamespace('anonymous', 'org'); + expect(nsKey).toEqual('LaunchDarkly_AnonymousKeys_org'); }); test('getOrGenerateKey create new key', async () => { - const key = await getOrGenerateKey('org', basicPlatform); + const key = await getOrGenerateKey('anonymous', 'org', basicPlatform); expect(key).toEqual('random1'); expect(crypto.randomUUID).toHaveBeenCalled(); - expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org'); - expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org', 'random1'); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org', 'random1'); }); test('getOrGenerateKey existing key', async () => { (storage.get as jest.Mock).mockImplementation((namespacedKind: string) => - namespacedKind === 'LaunchDarkly_AnonKeys_org' ? 'random1' : undefined, + namespacedKind === 'LaunchDarkly_AnonymousKeys_org' ? 'random1' : undefined, ); - const key = await getOrGenerateKey('org', basicPlatform); + const key = await getOrGenerateKey('anonymous', 'org', basicPlatform); expect(key).toEqual('random1'); expect(crypto.randomUUID).not.toHaveBeenCalled(); - expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org'); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); expect(storage.set).not.toHaveBeenCalled(); }); diff --git a/packages/shared/sdk-client/src/utils/ensureKey.ts b/packages/shared/sdk-client/src/utils/ensureKey.ts index e5314df58..dfbce2b6f 100644 --- a/packages/shared/sdk-client/src/utils/ensureKey.ts +++ b/packages/shared/sdk-client/src/utils/ensureKey.ts @@ -32,7 +32,7 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf if (anonymous && !key) { // This mutates a cloned copy of the original context from ensureyKey so this is safe. // eslint-disable-next-line no-param-reassign - c.key = await getOrGenerateKey(kind, platform); + c.key = await getOrGenerateKey('anonymous', kind, platform); } }; diff --git a/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts b/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts index 7b6e587a9..5ff2204c5 100644 --- a/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts +++ b/packages/shared/sdk-client/src/utils/getOrGenerateKey.test.ts @@ -18,21 +18,54 @@ describe('getOrGenerateKey', () => { jest.resetAllMocks(); }); - test('key does not exist in cache so it must be generated', async () => { - (storage.get as jest.Mock).mockResolvedValue(undefined); - const k = await getOrGenerateKey('org', basicPlatform); + describe('anonymous namespace', () => { + test('anonymous key does not exist so should be generated', async () => { + (storage.get as jest.Mock).mockResolvedValue(undefined); + const k = await getOrGenerateKey('anonymous', 'org', basicPlatform); - expect(crypto.randomUUID).toHaveBeenCalled(); - expect(storage.set).toHaveBeenCalled(); - expect(k).toEqual('test-org-key-1'); + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + expect(storage.set).toHaveBeenCalled(); + expect(k).toEqual('test-org-key-1'); + }); + + test('anonymous key exists so should not be generated', async () => { + (storage.get as jest.Mock).mockResolvedValue('test-org-key-2'); + const k = await getOrGenerateKey('anonymous', 'org', basicPlatform); + + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + expect(storage.set).not.toHaveBeenCalled(); + expect(k).toEqual('test-org-key-2'); + }); }); - test('key exists in cache so not generated', async () => { - (storage.get as jest.Mock).mockResolvedValue('test-org-key-2'); - const k = await getOrGenerateKey('org', basicPlatform); + describe('context namespace', () => { + test('context key does not exist so should be generated', async () => { + (storage.get as jest.Mock).mockResolvedValue(undefined); + const k = await getOrGenerateKey('context', 'org', basicPlatform); + + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org'); + expect(storage.set).toHaveBeenCalled(); + expect(k).toEqual('test-org-key-1'); + }); + + test('context key exists so should not be generated', async () => { + (storage.get as jest.Mock).mockResolvedValue('test-org-key-2'); + const k = await getOrGenerateKey('context', 'org', basicPlatform); + + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org'); + expect(storage.set).not.toHaveBeenCalled(); + expect(k).toEqual('test-org-key-2'); + }); + }); - expect(crypto.randomUUID).not.toHaveBeenCalled(); - expect(storage.set).not.toHaveBeenCalled(); - expect(k).toEqual('test-org-key-2'); + test('unsupported namespace', async () => { + // @ts-ignore + await expect(getOrGenerateKey('wrongNamespace', 'org', basicPlatform)).rejects.toThrow( + /unsupported/i, + ); }); }); diff --git a/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts b/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts index e4d3c4211..df49fcc8e 100644 --- a/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts +++ b/packages/shared/sdk-client/src/utils/getOrGenerateKey.ts @@ -1,14 +1,37 @@ import { Platform } from '@launchdarkly/js-sdk-common'; -export const addNamespace = (s: string) => `LaunchDarkly_AnonKeys_${s}`; +export type Namespace = 'anonymous' | 'context'; -export const getOrGenerateKey = async (kind: string, { crypto, storage }: Platform) => { - const namespacedKind = addNamespace(kind); - let contextKey = await storage?.get(namespacedKind); +export const prefixNamespace = (namespace: Namespace, s: string) => { + let n: string; + + switch (namespace) { + case 'anonymous': + n = 'LaunchDarkly_AnonymousKeys_'; + break; + case 'context': + n = 'LaunchDarkly_ContextKeys_'; + break; + default: + throw new Error( + `Unsupported namespace ${namespace}. Only 'anonymous' or 'context' are supported.`, + ); + } + + return `${n}${s}`; +}; + +export const getOrGenerateKey = async ( + namespace: Namespace, + contextKind: string, + { crypto, storage }: Platform, +) => { + const storageKey = prefixNamespace(namespace, contextKind); + let contextKey = await storage?.get(storageKey); if (!contextKey) { contextKey = crypto.randomUUID(); - await storage?.set(namespacedKind, contextKey); + await storage?.set(storageKey, contextKey); } return contextKey; From 4e7f1c1c38a0fbeffbcfc2b54d0c866b734da12f Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Sat, 27 Jan 2024 21:54:18 -0800 Subject: [PATCH 16/33] fix: Hardcode @types/node version to avoid crypto typings but in v20.11.8. --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1538409bc..1323dfa55 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,8 @@ "typedoc": "0.25.0", "typescript": "5.1.6" }, - "packageManager": "yarn@3.4.1" + "packageManager": "yarn@3.4.1", + "resolutions": { + "@types/node": "20.11.7" + } } From caa1f14473c150a7856b26872187f21af4994569 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Sat, 27 Jan 2024 23:52:12 -0800 Subject: [PATCH 17/33] chore: Add comment to explain harcoding of @types.node. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 1323dfa55..6f8bfef9a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "typescript": "5.1.6" }, "packageManager": "yarn@3.4.1", + "//": "There is a bug with @types/node: 20.11.8 where crypto types are erroneous. See https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/68351", "resolutions": { "@types/node": "20.11.7" } From 189d379c5d2b95f59fa8ca6ac2521e6373242557 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Sun, 28 Jan 2024 15:10:27 -0800 Subject: [PATCH 18/33] fix: Import AutoEnvAttributes from the rn sdk instead of client common. --- packages/sdk/react-native/example/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react-native/example/App.tsx b/packages/sdk/react-native/example/App.tsx index 4079f9d5b..5445bca48 100644 --- a/packages/sdk/react-native/example/App.tsx +++ b/packages/sdk/react-native/example/App.tsx @@ -1,7 +1,10 @@ import { MOBILE_KEY } from '@env'; -import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; -import { LDProvider, ReactNativeLDClient } from '@launchdarkly/react-native-client-sdk'; +import { + AutoEnvAttributes, + LDProvider, + ReactNativeLDClient, +} from '@launchdarkly/react-native-client-sdk'; import Welcome from './src/welcome'; From 31643eb9d275cfad8c671600bc096ffc84c19462 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Sun, 28 Jan 2024 15:13:35 -0800 Subject: [PATCH 19/33] fix: Add tsconfig jsx react setting. --- packages/sdk/react-native/example/tsconfig.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react-native/example/tsconfig.json b/packages/sdk/react-native/example/tsconfig.json index a62623004..28d5470b2 100644 --- a/packages/sdk/react-native/example/tsconfig.json +++ b/packages/sdk/react-native/example/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { + "jsx": "react", "strict": true, - "typeRoots": ["./types"] + "typeRoots": ["./types"], }, - "exclude": ["e2e"] + "exclude": ["e2e"], } From 1788a21f91225f9d40b1bfd5a5c3900eb5dec395 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 29 Jan 2024 10:53:07 -0800 Subject: [PATCH 20/33] chore: Remove hardcoded @types/node version. --- package.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/package.json b/package.json index 6f8bfef9a..1538409bc 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,5 @@ "typedoc": "0.25.0", "typescript": "5.1.6" }, - "packageManager": "yarn@3.4.1", - "//": "There is a bug with @types/node: 20.11.8 where crypto types are erroneous. See https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/68351", - "resolutions": { - "@types/node": "20.11.7" - } + "packageManager": "yarn@3.4.1" } From d9dc2e5df864fd73863c9725e3214c284612e3ca Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 30 Jan 2024 12:25:58 -0800 Subject: [PATCH 21/33] chore: Deleted redundant react-native-sse files and folder. --- .../src/react-native-sse/EventSource.ts | 331 ------------------ .../react-native/src/react-native-sse/LICENSE | 21 -- .../src/react-native-sse/index.ts | 3 - .../src/react-native-sse/types.ts | 73 ---- 4 files changed, 428 deletions(-) delete mode 100644 packages/sdk/react-native/src/react-native-sse/EventSource.ts delete mode 100644 packages/sdk/react-native/src/react-native-sse/LICENSE delete mode 100644 packages/sdk/react-native/src/react-native-sse/index.ts delete mode 100644 packages/sdk/react-native/src/react-native-sse/types.ts diff --git a/packages/sdk/react-native/src/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/react-native-sse/EventSource.ts deleted file mode 100644 index 92ba6944a..000000000 --- a/packages/sdk/react-native/src/react-native-sse/EventSource.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Ripped from https://github.com/binaryminds/react-native-sse - * These changes are made from the above repo at fork-time: - * 1. converted to ts and fix ts related errors. - * 2. added onopen, onclose, onerror, onretrying functions. - * 3. modified dispatch to work with functions added in 2. - * 4. replaced all for of loops with foreach - */ -import type { EventSourceEvent, EventSourceListener, EventSourceOptions, EventType } from './types'; - -const XMLReadyStateMap = ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']; - -const defaultOptions: EventSourceOptions = { - body: undefined, - debug: false, - headers: {}, - method: 'GET', - pollingInterval: 5000, - timeout: 0, - timeoutBeforeConnection: 0, - withCredentials: false, - retryAndHandleError: undefined, -}; - -export default class EventSource { - ERROR = -1; - CONNECTING = 0; - OPEN = 1; - CLOSED = 2; - - private lastEventId: undefined | string; - private lastIndexProcessed = 0; - private eventType: undefined | EventType; - private status = this.CONNECTING; - private eventHandlers: any = { - open: [], - message: [], - error: [], - close: [], - }; - - private method: string; - private timeout: number; - private timeoutBeforeConnection: number; - private withCredentials: boolean; - private headers: Record; - private body: any; - private debug: boolean; - private url: string; - private xhr: XMLHttpRequest = new XMLHttpRequest(); - private pollTimer: any; - private pollingInterval: number; - private retryAndHandleError?: (err: any) => boolean; - - constructor(url: string, options?: EventSourceOptions) { - const opts = { - ...defaultOptions, - ...options, - }; - - this.url = url; - this.method = opts.method!; - this.timeout = opts.timeout!; - this.timeoutBeforeConnection = opts.timeoutBeforeConnection!; - this.withCredentials = opts.withCredentials!; - this.headers = opts.headers!; - this.body = opts.body; - this.debug = opts.debug!; - this.pollingInterval = opts.pollingInterval!; - this.retryAndHandleError = opts.retryAndHandleError; - - this.pollAgain(this.timeoutBeforeConnection, true); - } - - private pollAgain(time: number, allowZero: boolean) { - if (time > 0 || allowZero) { - this.logDebug(`[EventSource] Will open new connection in ${time} ms.`); - this.dispatch('retry', { type: 'retry' }); - this.pollTimer = setTimeout(() => { - this.open(); - }, time); - } - } - - open() { - try { - this.lastIndexProcessed = 0; - this.status = this.CONNECTING; - this.xhr.open(this.method, this.url, true); - - if (this.withCredentials) { - this.xhr.withCredentials = true; - } - - this.xhr.setRequestHeader('Accept', 'text/event-stream'); - this.xhr.setRequestHeader('Cache-Control', 'no-cache'); - this.xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); - - if (this.headers) { - Object.entries(this.headers).forEach(([key, value]) => { - this.xhr.setRequestHeader(key, value); - }); - } - - if (typeof this.lastEventId !== 'undefined') { - this.xhr.setRequestHeader('Last-Event-ID', this.lastEventId); - } - - this.xhr.timeout = this.timeout; - - this.xhr.onreadystatechange = () => { - if (this.status === this.CLOSED) { - return; - } - - this.logDebug( - `[EventSource][onreadystatechange] ReadyState: ${ - XMLReadyStateMap[this.xhr.readyState] || 'Unknown' - }(${this.xhr.readyState}), status: ${this.xhr.status}`, - ); - - if ( - this.xhr.readyState !== XMLHttpRequest.DONE && - this.xhr.readyState !== XMLHttpRequest.LOADING - ) { - return; - } - - if (this.xhr.status >= 200 && this.xhr.status < 400) { - if (this.status === this.CONNECTING) { - this.status = this.OPEN; - this.dispatch('open', { type: 'open' }); - this.logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.'); - } - - this.handleEvent(this.xhr.responseText || ''); - - if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logDebug('[EventSource][onreadystatechange][DONE] Operation done.'); - this.pollAgain(this.pollingInterval, false); - } - } else if (this.xhr.status !== 0) { - this.status = this.ERROR; - this.dispatch('error', { - type: 'error', - message: this.xhr.responseText, - xhrStatus: this.xhr.status, - xhrState: this.xhr.readyState, - }); - - if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logDebug('[EventSource][onreadystatechange][ERROR] Response status error.'); - - if (!this.retryAndHandleError) { - // default implementation - this.pollAgain(this.pollingInterval, false); - } else { - // custom retry logic - const shouldRetry = this.retryAndHandleError({ - status: this.xhr.status, - message: this.xhr.responseText, - }); - - if (shouldRetry) { - this.pollAgain(this.pollingInterval, true); - } - } - } - } - }; - - this.xhr.onerror = () => { - if (this.status === this.CLOSED) { - return; - } - - this.status = this.ERROR; - this.dispatch('error', { - type: 'error', - message: this.xhr.responseText, - xhrStatus: this.xhr.status, - xhrState: this.xhr.readyState, - }); - }; - - if (this.body) { - this.xhr.send(this.body); - } else { - this.xhr.send(); - } - - if (this.timeout > 0) { - setTimeout(() => { - if (this.xhr.readyState === XMLHttpRequest.LOADING) { - this.dispatch('error', { type: 'timeout' }); - this.close(); - } - }, this.timeout); - } - } catch (e: any) { - this.status = this.ERROR; - this.dispatch('error', { - type: 'exception', - message: e.message, - error: e, - }); - } - } - - private logDebug(...msg: string[]) { - if (this.debug) { - // eslint-disable-next-line no-console - console.debug(...msg); - } - } - - private handleEvent(response: string) { - const parts = response.slice(this.lastIndexProcessed).split('\n'); - - const indexOfDoubleNewline = response.lastIndexOf('\n\n'); - if (indexOfDoubleNewline !== -1) { - this.lastIndexProcessed = indexOfDoubleNewline + 2; - } - - let data = []; - let retry = 0; - let line = ''; - - // eslint-disable-next-line no-plusplus - for (let i = 0; i < parts.length; i++) { - line = parts[i].replace(/^(\s|\u00A0)+|(\s|\u00A0)+$/g, ''); - if (line.indexOf('event') === 0) { - this.eventType = line.replace(/event:?\s*/, '') as EventType; - } else if (line.indexOf('retry') === 0) { - retry = parseInt(line.replace(/retry:?\s*/, ''), 10); - if (!Number.isNaN(retry)) { - this.pollingInterval = retry; - } - } else if (line.indexOf('data') === 0) { - data.push(line.replace(/data:?\s*/, '')); - } else if (line.indexOf('id:') === 0) { - this.lastEventId = line.replace(/id:?\s*/, ''); - } else if (line.indexOf('id') === 0) { - this.lastEventId = undefined; - } else if (line === '') { - if (data.length > 0) { - const eventType = this.eventType || 'message'; - const event: any = { - type: eventType, - data: data.join('\n'), - url: this.url, - lastEventId: this.lastEventId, - }; - - this.dispatch(eventType, event); - - data = []; - this.eventType = undefined; - } - } - } - } - - addEventListener>(type: T, listener: EventSourceListener): void { - if (this.eventHandlers[type] === undefined) { - this.eventHandlers[type] = []; - } - - this.eventHandlers[type].push(listener); - } - - removeEventListener>(type: T, listener: EventSourceListener): void { - if (this.eventHandlers[type] !== undefined) { - this.eventHandlers[type] = this.eventHandlers[type].filter( - (handler: EventSourceListener) => handler !== listener, - ); - } - } - - removeAllEventListeners>(type?: T) { - const availableTypes = Object.keys(this.eventHandlers); - - if (type === undefined) { - availableTypes.forEach((eventType) => { - this.eventHandlers[eventType] = []; - }); - } else { - if (!availableTypes.includes(type)) { - throw Error(`[EventSource] '${type}' type is not supported event type.`); - } - - this.eventHandlers[type] = []; - } - } - - dispatch>(type: T, data: EventSourceEvent) { - this.eventHandlers[type]?.forEach((handler: EventSourceListener) => handler(data)); - - switch (type) { - case 'open': - this.onopen(); - break; - case 'close': - this.onclose(); - break; - case 'error': - this.onerror(data); - break; - case 'retry': - this.onretrying({ delayMillis: this.pollingInterval }); - break; - default: - break; - } - } - - close() { - this.status = this.CLOSED; - clearTimeout(this.pollTimer); - if (this.xhr) { - this.xhr.abort(); - } - - this.dispatch('close', { type: 'close' }); - } - - onopen() {} - onclose() {} - onerror(_err: any) {} - onretrying(_e: any) {} -} diff --git a/packages/sdk/react-native/src/react-native-sse/LICENSE b/packages/sdk/react-native/src/react-native-sse/LICENSE deleted file mode 100644 index 1f5662ff0..000000000 --- a/packages/sdk/react-native/src/react-native-sse/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License - -Copyright (c) 2021 Binary Minds - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. \ No newline at end of file diff --git a/packages/sdk/react-native/src/react-native-sse/index.ts b/packages/sdk/react-native/src/react-native-sse/index.ts deleted file mode 100644 index b9f3fe560..000000000 --- a/packages/sdk/react-native/src/react-native-sse/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import EventSource from './EventSource'; - -export default EventSource; diff --git a/packages/sdk/react-native/src/react-native-sse/types.ts b/packages/sdk/react-native/src/react-native-sse/types.ts deleted file mode 100644 index 1a417a7db..000000000 --- a/packages/sdk/react-native/src/react-native-sse/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -export type BuiltInEventType = 'open' | 'message' | 'error' | 'close' | 'retry'; -export type EventType = E | BuiltInEventType; - -export interface MessageEvent { - type: 'message'; - data: string | null; - lastEventId: string | undefined; - url: string; -} - -export interface OpenEvent { - type: 'open'; -} - -export interface CloseEvent { - type: 'close'; -} - -export interface RetryEvent { - type: 'retry'; -} - -export interface TimeoutEvent { - type: 'timeout'; -} - -export interface ErrorEvent { - type: 'error'; - message: string; - xhrState: number; - xhrStatus: number; -} - -export interface CustomEvent { - type: E; - data: string | null; - lastEventId: string | null; - url: string; -} - -export interface ExceptionEvent { - type: 'exception'; - message: string; - error: Error; -} - -export interface EventSourceOptions { - method?: string; - timeout?: number; - timeoutBeforeConnection?: number; - withCredentials?: boolean; - headers?: Record; - body?: any; - debug?: boolean; - pollingInterval?: number; - retryAndHandleError?: (err: any) => boolean; -} - -type BuiltInEventMap = { - message: MessageEvent; - open: OpenEvent; - close: CloseEvent; - error: ErrorEvent | TimeoutEvent | ExceptionEvent; - retry: RetryEvent; -}; - -export type EventSourceEvent = E extends BuiltInEventType - ? BuiltInEventMap[E] - : CustomEvent; - -export type EventSourceListener = EventType> = ( - event: EventSourceEvent, -) => void; From f6ca89ce9584f157c6b578b01b981d111f6ea119 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 30 Jan 2024 14:42:35 -0800 Subject: [PATCH 22/33] fix: Added rn sdk streaming jitter backoff. --- .../sdk/react-native/example/tsconfig.json | 4 +- .../react-native-sse/EventSource.ts | 83 +++++++++++-------- .../fromExternal/react-native-sse/types.ts | 6 +- .../sdk/react-native/src/platform/index.ts | 5 +- 4 files changed, 56 insertions(+), 42 deletions(-) diff --git a/packages/sdk/react-native/example/tsconfig.json b/packages/sdk/react-native/example/tsconfig.json index 28d5470b2..15f31e3b9 100644 --- a/packages/sdk/react-native/example/tsconfig.json +++ b/packages/sdk/react-native/example/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react", "strict": true, - "typeRoots": ["./types"], + "typeRoots": ["./types"] }, - "exclude": ["e2e"], + "exclude": ["e2e"] } diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index 92ba6944a..de8a0739b 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -12,16 +12,27 @@ const XMLReadyStateMap = ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DO const defaultOptions: EventSourceOptions = { body: undefined, - debug: false, headers: {}, method: 'GET', - pollingInterval: 5000, timeout: 0, - timeoutBeforeConnection: 0, withCredentials: false, retryAndHandleError: undefined, + initialRetryDelayMillis: 1000, + logger: undefined, }; +const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds. +const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time. + +function backoff(base: number, retryCount: number) { + const delay = base * Math.pow(2, retryCount); + return delay > maxRetryDelay ? maxRetryDelay : delay; +} + +function jitter(computedDelayMillis: number) { + return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis); +} + export default class EventSource { ERROR = -1; CONNECTING = 0; @@ -41,16 +52,16 @@ export default class EventSource { private method: string; private timeout: number; - private timeoutBeforeConnection: number; private withCredentials: boolean; private headers: Record; private body: any; - private debug: boolean; private url: string; private xhr: XMLHttpRequest = new XMLHttpRequest(); private pollTimer: any; - private pollingInterval: number; private retryAndHandleError?: (err: any) => boolean; + private initialRetryDelayMillis: number = 1000; + private retryCount: number = 0; + private logger?: any; constructor(url: string, options?: EventSourceOptions) { const opts = { @@ -61,25 +72,29 @@ export default class EventSource { this.url = url; this.method = opts.method!; this.timeout = opts.timeout!; - this.timeoutBeforeConnection = opts.timeoutBeforeConnection!; this.withCredentials = opts.withCredentials!; this.headers = opts.headers!; this.body = opts.body; - this.debug = opts.debug!; - this.pollingInterval = opts.pollingInterval!; this.retryAndHandleError = opts.retryAndHandleError; + this.initialRetryDelayMillis = opts.initialRetryDelayMillis!; + this.logger = opts.logger; - this.pollAgain(this.timeoutBeforeConnection, true); + this.tryConnect(true); } - private pollAgain(time: number, allowZero: boolean) { - if (time > 0 || allowZero) { - this.logDebug(`[EventSource] Will open new connection in ${time} ms.`); - this.dispatch('retry', { type: 'retry' }); - this.pollTimer = setTimeout(() => { - this.open(); - }, time); - } + private getNextRetryDelay() { + const delay = jitter(backoff(this.initialRetryDelayMillis, this.retryCount)); + this.retryCount += 1; + return delay; + } + + private tryConnect(forceNoDelay: boolean = false) { + let delay = forceNoDelay ? 0 : this.getNextRetryDelay(); + this.logger?.debug(`[EventSource] Will open new connection in ${delay} ms.`); + this.dispatch('retry', { type: 'retry', delayMillis: delay }); + this.pollTimer = setTimeout(() => { + this.open(); + }, delay); } open() { @@ -113,7 +128,7 @@ export default class EventSource { return; } - this.logDebug( + this.logger?.debug( `[EventSource][onreadystatechange] ReadyState: ${ XMLReadyStateMap[this.xhr.readyState] || 'Unknown' }(${this.xhr.readyState}), status: ${this.xhr.status}`, @@ -128,16 +143,18 @@ export default class EventSource { if (this.xhr.status >= 200 && this.xhr.status < 400) { if (this.status === this.CONNECTING) { + this.retryCount = 0; this.status = this.OPEN; this.dispatch('open', { type: 'open' }); - this.logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.'); + this.logger?.debug('[EventSource][onreadystatechange][OPEN] Connection opened.'); } + // retry from server gets set here this.handleEvent(this.xhr.responseText || ''); if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logDebug('[EventSource][onreadystatechange][DONE] Operation done.'); - this.pollAgain(this.pollingInterval, false); + this.logger?.debug('[EventSource][onreadystatechange][DONE] Operation done.'); + this.tryConnect(); } } else if (this.xhr.status !== 0) { this.status = this.ERROR; @@ -149,20 +166,20 @@ export default class EventSource { }); if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logDebug('[EventSource][onreadystatechange][ERROR] Response status error.'); + this.logger?.debug('[EventSource][onreadystatechange][ERROR] Response status error.'); if (!this.retryAndHandleError) { - // default implementation - this.pollAgain(this.pollingInterval, false); + // by default just try and reconnect if there's an error. + this.tryConnect(); } else { - // custom retry logic + // custom retry logic taking into account status codes. const shouldRetry = this.retryAndHandleError({ status: this.xhr.status, message: this.xhr.responseText, }); if (shouldRetry) { - this.pollAgain(this.pollingInterval, true); + this.tryConnect(); } } } @@ -207,13 +224,6 @@ export default class EventSource { } } - private logDebug(...msg: string[]) { - if (this.debug) { - // eslint-disable-next-line no-console - console.debug(...msg); - } - } - private handleEvent(response: string) { const parts = response.slice(this.lastIndexProcessed).split('\n'); @@ -234,7 +244,8 @@ export default class EventSource { } else if (line.indexOf('retry') === 0) { retry = parseInt(line.replace(/retry:?\s*/, ''), 10); if (!Number.isNaN(retry)) { - this.pollingInterval = retry; + // GOTCHA: Ignore the server retry recommendation. Use our own custom getNextRetryDelay logic. + // this.pollingInterval = retry; } } else if (line.indexOf('data') === 0) { data.push(line.replace(/data:?\s*/, '')); @@ -307,7 +318,7 @@ export default class EventSource { this.onerror(data); break; case 'retry': - this.onretrying({ delayMillis: this.pollingInterval }); + this.onretrying(data); break; default: break; diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts index 1a417a7db..3c828189b 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts @@ -18,6 +18,7 @@ export interface CloseEvent { export interface RetryEvent { type: 'retry'; + delayMillis: number; } export interface TimeoutEvent { @@ -47,13 +48,12 @@ export interface ExceptionEvent { export interface EventSourceOptions { method?: string; timeout?: number; - timeoutBeforeConnection?: number; withCredentials?: boolean; headers?: Record; body?: any; - debug?: boolean; - pollingInterval?: number; retryAndHandleError?: (err: any) => boolean; + initialRetryDelayMillis?: number; + logger?: any; } type BuiltInEventMap = { diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index 6e9e7a81c..7702f0aa1 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -23,10 +23,13 @@ import AsyncStorage from './ConditionalAsyncStorage'; import PlatformCrypto from './crypto'; class PlatformRequests implements Requests { + constructor(private readonly logger: LDLogger) {} + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new RNEventSource(url, { headers: eventSourceInitDict.headers, retryAndHandleError: eventSourceInitDict.errorFilter, + logger: this.logger, }); } @@ -95,7 +98,7 @@ class PlatformStorage implements Storage { const createPlatform = (logger: LDLogger): Platform => ({ crypto: new PlatformCrypto(), info: new PlatformInfo(logger), - requests: new PlatformRequests(), + requests: new PlatformRequests(logger), encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), }); From 50c884c602b35d09a6207c2983f7e00705ce1303 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 30 Jan 2024 16:17:29 -0800 Subject: [PATCH 23/33] chore: Added backoff jitter tests. --- .../react-native-sse/EventSource.test.ts | 90 +++++++++++++++++++ .../react-native-sse/EventSource.ts | 4 +- 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts new file mode 100644 index 000000000..53edc070c --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts @@ -0,0 +1,90 @@ +import { EventName } from '@launchdarkly/js-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; + +import EventSource, { backoff, jitter } from './EventSource'; + +describe('EventSource', () => { + const uri = 'https://mock.events.uri'; + let eventSource: EventSource; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + jest + .spyOn(Math, 'random') + .mockImplementationOnce(() => 0.888) + .mockImplementationOnce(() => 0.999); + + eventSource = new EventSource(uri, { logger }); + eventSource.open = jest.fn(); + eventSource.onretrying = jest.fn(); + }); + + afterEach(() => { + // GOTCHA: Math.random must be reset separately because of a source-map type error + // https://medium.com/orchestrated/updating-react-to-version-17-471bfbe6bfcd + jest.spyOn(Math, 'random').mockRestore(); + + jest.resetAllMocks(); + }); + + test('backoff exponentially', () => { + const delay0 = backoff(1000, 0); + const delay1 = backoff(1000, 1); + const delay2 = backoff(1000, 2); + + expect(delay0).toEqual(1000); + expect(delay1).toEqual(2000); + expect(delay2).toEqual(4000); + }); + + test('backoff returns max delay', () => { + const delay = backoff(1000, 5); + expect(delay).toEqual(30000); + }); + + test('jitter', () => { + const delay0 = jitter(1000); + const delay1 = jitter(2000); + + expect(delay0).toEqual(556); + expect(delay1).toEqual(1001); + }); + + test('getNextRetryDelay', () => { + // @ts-ignore + const delay0 = eventSource.getNextRetryDelay(); + // @ts-ignore + const delay1 = eventSource.getNextRetryDelay(); + + // @ts-ignore + expect(eventSource.retryCount).toEqual(2); + expect(delay0).toEqual(556); + expect(delay1).toEqual(1001); + }); + + test('tryConnect force no delay', () => { + // @ts-ignore + eventSource.tryConnect(true); + jest.runAllTimers(); + + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/new connection in 0 ms/i)); + expect(eventSource.onretrying).toHaveBeenCalledWith({ type: 'retry', delayMillis: 0 }); + expect(eventSource.open).toHaveBeenCalledTimes(2); + }); + + test('tryConnect with delay', () => { + // @ts-ignore + eventSource.tryConnect(); + jest.runAllTimers(); + + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + expect.stringMatching(/new connection in 556 ms/i), + ); + expect(eventSource.onretrying).toHaveBeenCalledWith({ type: 'retry', delayMillis: 556 }); + expect(eventSource.open).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index de8a0739b..b472e0c0d 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -24,12 +24,12 @@ const defaultOptions: EventSourceOptions = { const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds. const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time. -function backoff(base: number, retryCount: number) { +export function backoff(base: number, retryCount: number) { const delay = base * Math.pow(2, retryCount); return delay > maxRetryDelay ? maxRetryDelay : delay; } -function jitter(computedDelayMillis: number) { +export function jitter(computedDelayMillis: number) { return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis); } From aaa7f946bc4d9c73e65fc8cc6b75f4178a7d3912 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 30 Jan 2024 21:02:39 -0800 Subject: [PATCH 24/33] chore: Use Math.min instead of logical operators for backoff. --- .../src/fromExternal/react-native-sse/EventSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index b472e0c0d..06ac9e452 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -26,7 +26,7 @@ const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time. export function backoff(base: number, retryCount: number) { const delay = base * Math.pow(2, retryCount); - return delay > maxRetryDelay ? maxRetryDelay : delay; + return Math.min(delay, maxRetryDelay); } export function jitter(computedDelayMillis: number) { From 704d3f2544a229947db4a65f62d59d6547ed7810 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 30 Jan 2024 21:10:44 -0800 Subject: [PATCH 25/33] chore: Skeletal background foreground detection logic. --- .../react-native/src/provider/LDProvider.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/provider/LDProvider.tsx b/packages/sdk/react-native/src/provider/LDProvider.tsx index 18396bbec..00ec09a54 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.tsx @@ -1,4 +1,5 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { AppState } from 'react-native'; import { type LDContext } from '@launchdarkly/js-client-sdk-common'; @@ -25,6 +26,7 @@ type LDProps = { */ const LDProvider = ({ client, context, children }: PropsWithChildren) => { const [state, setState] = useState({ client }); + const appState = useRef(AppState.currentState); useEffect(() => { setupListeners(client, setState); @@ -36,6 +38,25 @@ const LDProvider = ({ client, context, children }: PropsWithChildren) = client.logger.debug(`LaunchDarkly React Native Sdk identify error: ${e}`), ); } + + const sub = AppState.addEventListener('change', (nextAppState) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + client.logger.debug('App has come to the foreground!'); + + // TODO: queue streamer open + } else { + client.logger.debug('App state', nextAppState); + + if (nextAppState === 'background') { + // TODO: queue streamer close + } + } + appState.current = nextAppState; + }); + + return () => { + sub.remove(); + }; }, []); return {children}; From a2395c5eb0da7c98b681a0e40f7403f35f561890 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 31 Jan 2024 14:08:40 -0800 Subject: [PATCH 26/33] chore: Add debounce function to common utils. --- packages/shared/common/src/utils/debounce.ts | 41 ++++++++++++++++++++ packages/shared/common/src/utils/index.ts | 2 + 2 files changed, 43 insertions(+) create mode 100644 packages/shared/common/src/utils/debounce.ts diff --git a/packages/shared/common/src/utils/debounce.ts b/packages/shared/common/src/utils/debounce.ts new file mode 100644 index 000000000..9525c00e4 --- /dev/null +++ b/packages/shared/common/src/utils/debounce.ts @@ -0,0 +1,41 @@ +/** + * Wait before calling the same function. Useful for expensive calls. + * + * Adapted from: + * https://amitd.co/code/typescript/debounce + * + * + * @return The debounced function. + * + * @example + * + * ```js + * const debouncedFunction = debounce(e => { + * console.log(e); + * }, 5000); + * + * // Console logs 'Hello world again ' after 5 seconds + * debouncedFunction('Hello world'); + * debouncedFunction('Hello world again'); + * ``` + * @param fn + * @param delayMs + */ +const debounce = ReturnType>( + fn?: T, + delayMs: number = 5000, +): ((...args: Parameters) => void) | undefined => { + if (!fn) { + return fn; + } + + let timer: ReturnType; + + return (...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => { + fn(...args); + }, delayMs); + }; +}; +export default debounce; diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index ec5abefdb..2c2130fe3 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,5 +1,6 @@ import clone from './clone'; import { secondsToMillis } from './date'; +import debounce from './debounce'; import deepCompact from './deepCompact'; import fastDeepEqual from './fast-deep-equal'; import { base64UrlEncode, defaultHeaders, httpErrorMessage, LDHeaders, shouldRetry } from './http'; @@ -10,6 +11,7 @@ import { VoidFunction } from './VoidFunction'; export { base64UrlEncode, clone, + debounce, deepCompact, defaultHeaders, fastDeepEqual, From 4a2dcf2b17abc6c629ae12971c959aba09d9079d Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 31 Jan 2024 15:49:54 -0800 Subject: [PATCH 27/33] chore: Added EventSource getStatus. Added useAppState hook. --- .../react-native/src/ReactNativeLDClient.ts | 7 +++- .../react-native-sse/EventSource.ts | 4 ++ .../sdk/react-native/src/platform/index.ts | 9 +++- .../react-native/src/provider/LDProvider.tsx | 26 ++---------- .../react-native/src/provider/useAppState.ts | 42 +++++++++++++++++++ packages/shared/common/src/utils/debounce.ts | 9 ++-- 6 files changed, 66 insertions(+), 31 deletions(-) create mode 100644 packages/sdk/react-native/src/provider/useAppState.ts diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 2b13d9889..5ea5e02b0 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -8,7 +8,7 @@ import { type LDOptions, } from '@launchdarkly/js-client-sdk-common'; -import createPlatform from './platform'; +import createPlatform, { PlatformRequests } from './platform'; /** * The React Native LaunchDarkly client. Instantiate this class to create an @@ -57,6 +57,11 @@ export default class ReactNativeLDClient extends LDClientImpl { ); } + isEventSourceClosed() { + const { eventSource } = this.platform.requests as PlatformRequests; + return eventSource?.getStatus() === eventSource?.CLOSED; + } + override createStreamUriPath(context: LDContext) { return `/meval/${base64UrlEncode(JSON.stringify(context), this.platform.encoding!)}`; } diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index 06ac9e452..2e121f49c 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -335,6 +335,10 @@ export default class EventSource { this.dispatch('close', { type: 'close' }); } + getStatus() { + return this.status; + } + onopen() {} onclose() {} onerror(_err: any) {} diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index 7702f0aa1..fb87893fb 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -22,15 +22,19 @@ import { ldApplication, ldDevice } from './autoEnv'; import AsyncStorage from './ConditionalAsyncStorage'; import PlatformCrypto from './crypto'; -class PlatformRequests implements Requests { +export class PlatformRequests implements Requests { + eventSource?: RNEventSource; + constructor(private readonly logger: LDLogger) {} createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - return new RNEventSource(url, { + this.eventSource = new RNEventSource(url, { headers: eventSourceInitDict.headers, retryAndHandleError: eventSourceInitDict.errorFilter, logger: this.logger, }); + + return this.eventSource; } fetch(url: string, options?: Options): Promise { @@ -38,6 +42,7 @@ class PlatformRequests implements Requests { return fetch(url, options); } } + class PlatformEncoding implements Encoding { btoa(data: string): string { return btoa(data); diff --git a/packages/sdk/react-native/src/provider/LDProvider.tsx b/packages/sdk/react-native/src/provider/LDProvider.tsx index 00ec09a54..0cb3ddb8b 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.tsx @@ -1,11 +1,11 @@ -import React, { PropsWithChildren, useEffect, useRef, useState } from 'react'; -import { AppState } from 'react-native'; +import React, { PropsWithChildren, useEffect, useState } from 'react'; import { type LDContext } from '@launchdarkly/js-client-sdk-common'; import ReactNativeLDClient from '../ReactNativeLDClient'; import { Provider, ReactContext } from './reactContext'; import setupListeners from './setupListeners'; +import useAppState from './useAppState'; type LDProps = { client: ReactNativeLDClient; @@ -26,7 +26,6 @@ type LDProps = { */ const LDProvider = ({ client, context, children }: PropsWithChildren) => { const [state, setState] = useState({ client }); - const appState = useRef(AppState.currentState); useEffect(() => { setupListeners(client, setState); @@ -38,27 +37,10 @@ const LDProvider = ({ client, context, children }: PropsWithChildren) = client.logger.debug(`LaunchDarkly React Native Sdk identify error: ${e}`), ); } - - const sub = AppState.addEventListener('change', (nextAppState) => { - if (appState.current.match(/inactive|background/) && nextAppState === 'active') { - client.logger.debug('App has come to the foreground!'); - - // TODO: queue streamer open - } else { - client.logger.debug('App state', nextAppState); - - if (nextAppState === 'background') { - // TODO: queue streamer close - } - } - appState.current = nextAppState; - }); - - return () => { - sub.remove(); - }; }, []); + useAppState(client); + return {children}; }; diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts new file mode 100644 index 000000000..735a06c98 --- /dev/null +++ b/packages/sdk/react-native/src/provider/useAppState.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; + +import { debounce } from '@launchdarkly/js-client-sdk-common'; + +import ReactNativeLDClient from '../ReactNativeLDClient'; + +const useAppState = (client: ReactNativeLDClient) => { + const appState = useRef(AppState.currentState); + const onChange = (nextAppState: AppStateStatus) => { + client.logger.debug(`App state prev ${appState.current}, next: ${nextAppState}`); + + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + client.logger.debug('App has come to the foreground.'); + + if (client.isEventSourceClosed()) { + client.logger.debug('Starting streamer after transitioning to foreground.'); + client.streamer?.start(); + } else { + client.logger.debug('Not starting streamer because EventSource is already open.'); + } + } else if (nextAppState === 'background') { + client.logger.debug('App state background stopping streamer.'); + client.streamer?.stop(); + } else { + client.logger.debug('No action needed.'); + } + + appState.current = nextAppState; + }; + const debouncedOnChange = debounce(onChange, 5000); + + useEffect(() => { + const sub = AppState.addEventListener('change', debouncedOnChange); + + return () => { + sub.remove(); + }; + }, []); +}; + +export default useAppState; diff --git a/packages/shared/common/src/utils/debounce.ts b/packages/shared/common/src/utils/debounce.ts index 9525c00e4..3d911f57c 100644 --- a/packages/shared/common/src/utils/debounce.ts +++ b/packages/shared/common/src/utils/debounce.ts @@ -22,13 +22,9 @@ * @param delayMs */ const debounce = ReturnType>( - fn?: T, + fn: T, delayMs: number = 5000, -): ((...args: Parameters) => void) | undefined => { - if (!fn) { - return fn; - } - +): ((...args: Parameters) => void) => { let timer: ReturnType; return (...args: Parameters) => { @@ -38,4 +34,5 @@ const debounce = ReturnType>( }, delayMs); }; }; + export default debounce; From 75ca7f65f833ff7ac8899bc93ecec67c68d48dfe Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 31 Jan 2024 15:57:12 -0800 Subject: [PATCH 28/33] chore: Move isEventSourceClosed function to useAppState hook. --- packages/sdk/react-native/src/ReactNativeLDClient.ts | 7 +------ packages/sdk/react-native/src/provider/useAppState.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 5ea5e02b0..2b13d9889 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -8,7 +8,7 @@ import { type LDOptions, } from '@launchdarkly/js-client-sdk-common'; -import createPlatform, { PlatformRequests } from './platform'; +import createPlatform from './platform'; /** * The React Native LaunchDarkly client. Instantiate this class to create an @@ -57,11 +57,6 @@ export default class ReactNativeLDClient extends LDClientImpl { ); } - isEventSourceClosed() { - const { eventSource } = this.platform.requests as PlatformRequests; - return eventSource?.getStatus() === eventSource?.CLOSED; - } - override createStreamUriPath(context: LDContext) { return `/meval/${base64UrlEncode(JSON.stringify(context), this.platform.encoding!)}`; } diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts index 735a06c98..d77e5c0f6 100644 --- a/packages/sdk/react-native/src/provider/useAppState.ts +++ b/packages/sdk/react-native/src/provider/useAppState.ts @@ -3,17 +3,24 @@ import { AppState, AppStateStatus } from 'react-native'; import { debounce } from '@launchdarkly/js-client-sdk-common'; +import { PlatformRequests } from '../platform'; import ReactNativeLDClient from '../ReactNativeLDClient'; const useAppState = (client: ReactNativeLDClient) => { const appState = useRef(AppState.currentState); + + const isEventSourceClosed = () => { + const { eventSource } = client.platform.requests as PlatformRequests; + return eventSource?.getStatus() === eventSource?.CLOSED; + }; + const onChange = (nextAppState: AppStateStatus) => { client.logger.debug(`App state prev ${appState.current}, next: ${nextAppState}`); if (appState.current.match(/inactive|background/) && nextAppState === 'active') { client.logger.debug('App has come to the foreground.'); - if (client.isEventSourceClosed()) { + if (isEventSourceClosed()) { client.logger.debug('Starting streamer after transitioning to foreground.'); client.streamer?.start(); } else { From dff6153b85b0e1ec511abd92c0317b3e59b80bd3 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 31 Jan 2024 16:38:05 -0800 Subject: [PATCH 29/33] chore: Remove explicit debounce delay and use default instead. Remove extraneous logging. --- packages/sdk/react-native/src/provider/useAppState.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts index d77e5c0f6..0ce973ff0 100644 --- a/packages/sdk/react-native/src/provider/useAppState.ts +++ b/packages/sdk/react-native/src/provider/useAppState.ts @@ -18,8 +18,6 @@ const useAppState = (client: ReactNativeLDClient) => { client.logger.debug(`App state prev ${appState.current}, next: ${nextAppState}`); if (appState.current.match(/inactive|background/) && nextAppState === 'active') { - client.logger.debug('App has come to the foreground.'); - if (isEventSourceClosed()) { client.logger.debug('Starting streamer after transitioning to foreground.'); client.streamer?.start(); @@ -35,7 +33,7 @@ const useAppState = (client: ReactNativeLDClient) => { appState.current = nextAppState; }; - const debouncedOnChange = debounce(onChange, 5000); + const debouncedOnChange = debounce(onChange); useEffect(() => { const sub = AppState.addEventListener('change', debouncedOnChange); From cd6e4d3db750d9209ab0f6e6e235908546323ea1 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 31 Jan 2024 23:08:02 -0800 Subject: [PATCH 30/33] chore: Add app state tests. --- packages/sdk/react-native/package.json | 1 + .../src/provider/LDProvider.test.tsx | 3 +- .../src/provider/useAppState.test.ts | 114 ++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/react-native/src/provider/useAppState.test.ts diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index 96476e2fb..e1fb003d8 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -50,6 +50,7 @@ "event-target-shim": "^6.0.2" }, "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@testing-library/react": "^14.1.2", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.11", diff --git a/packages/sdk/react-native/src/provider/LDProvider.test.tsx b/packages/sdk/react-native/src/provider/LDProvider.test.tsx index 548aec1e8..6df9625ce 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.test.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.test.tsx @@ -7,8 +7,9 @@ import ReactNativeLDClient from '../ReactNativeLDClient'; import LDProvider from './LDProvider'; import setupListeners from './setupListeners'; -jest.mock('./setupListeners'); +jest.mock('../provider/useAppState'); jest.mock('../ReactNativeLDClient'); +jest.mock('./setupListeners'); const TestApp = () => { const ldClient = useLDClient(); diff --git a/packages/sdk/react-native/src/provider/useAppState.test.ts b/packages/sdk/react-native/src/provider/useAppState.test.ts new file mode 100644 index 000000000..6bb3d1eca --- /dev/null +++ b/packages/sdk/react-native/src/provider/useAppState.test.ts @@ -0,0 +1,114 @@ +import { renderHook } from '@testing-library/react'; +import React, { useRef } from 'react'; +import { AppState } from 'react-native'; + +import { AutoEnvAttributes, debounce } from '@launchdarkly/js-client-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; + +import EventSource from '../fromExternal/react-native-sse'; +import ReactNativeLDClient from '../ReactNativeLDClient'; +import useAppState from './useAppState'; + +jest.mock('@launchdarkly/js-client-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-client-sdk-common'); + return { + ...actual, + debounce: jest.fn(), + }; +}); + +describe('useAppState', () => { + const eventSourceOpen = 1; + const eventSourceClosed = 2; + + let appStateSpy: jest.SpyInstance; + let ldc: ReactNativeLDClient; + let mockEventSource: Partial; + + beforeEach(() => { + (debounce as jest.Mock).mockImplementation((f) => f); + appStateSpy = jest.spyOn(AppState, 'addEventListener').mockReturnValue({ remove: jest.fn() }); + jest.spyOn(React, 'useRef').mockReturnValue({ + current: 'active', + }); + + ldc = new ReactNativeLDClient('mob-test-key', AutoEnvAttributes.Enabled, { logger }); + + mockEventSource = { + getStatus: jest.fn(() => eventSourceOpen), + OPEN: eventSourceOpen, + CLOSED: eventSourceClosed, + }; + // @ts-ignore + ldc.platform.requests = { eventSource: mockEventSource }; + // @ts-ignore + ldc.streamer = { start: jest.fn().mockName('start'), stop: jest.fn().mockName('stop') }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('stops streamer in background', () => { + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('background'); + + expect(ldc.streamer?.stop).toHaveBeenCalledTimes(1); + }); + + test('starts streamer transitioning from background to active', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'background' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).toHaveBeenCalledTimes(1); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + }); + + test('starts streamer transitioning from inactive to active', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'inactive' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).toHaveBeenCalledTimes(1); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + }); + + test('does not start streamer in foreground because event source is already open', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'background' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceOpen); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).not.toHaveBeenCalled(); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/already open/)); + }); + + test('active state unchanged no action needed', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'active' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).not.toHaveBeenCalled(); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/no action needed/i)); + }); +}); From bcaba56fb0c086bef31d1ee8307995f94847f1ec Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 31 Jan 2024 23:20:10 -0800 Subject: [PATCH 31/33] chore: Improve debug message. Added comments to useAppState hook. --- .../sdk/react-native/src/provider/useAppState.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts index 0ce973ff0..0421881cd 100644 --- a/packages/sdk/react-native/src/provider/useAppState.ts +++ b/packages/sdk/react-native/src/provider/useAppState.ts @@ -6,6 +6,15 @@ import { debounce } from '@launchdarkly/js-client-sdk-common'; import { PlatformRequests } from '../platform'; import ReactNativeLDClient from '../ReactNativeLDClient'; +/** + * Manages streamer connection based on AppState. Debouncing is used to prevent excessive starting + * and stopping of the EventSource which are expensive. + * + * background to active - start streamer. + * active to background - stop streamer. + * + * @param client + */ const useAppState = (client: ReactNativeLDClient) => { const appState = useRef(AppState.currentState); @@ -15,7 +24,7 @@ const useAppState = (client: ReactNativeLDClient) => { }; const onChange = (nextAppState: AppStateStatus) => { - client.logger.debug(`App state prev ${appState.current}, next: ${nextAppState}`); + client.logger.debug(`App state prev: ${appState.current}, next: ${nextAppState}`); if (appState.current.match(/inactive|background/) && nextAppState === 'active') { if (isEventSourceClosed()) { @@ -33,6 +42,8 @@ const useAppState = (client: ReactNativeLDClient) => { appState.current = nextAppState; }; + + // debounce with a default delay of 5 seconds. const debouncedOnChange = debounce(onChange); useEffect(() => { From e92a1d5d0d3272a247106a260949a48cc25557bb Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 1 Feb 2024 12:16:07 -0800 Subject: [PATCH 32/33] chore: Improve debounce comments. --- packages/shared/common/src/utils/debounce.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/shared/common/src/utils/debounce.ts b/packages/shared/common/src/utils/debounce.ts index 3d911f57c..c4639ff3c 100644 --- a/packages/shared/common/src/utils/debounce.ts +++ b/packages/shared/common/src/utils/debounce.ts @@ -1,9 +1,6 @@ /** * Wait before calling the same function. Useful for expensive calls. - * - * Adapted from: - * https://amitd.co/code/typescript/debounce - * + * Adapted from https://amitd.co/code/typescript/debounce. * * @return The debounced function. * @@ -18,8 +15,8 @@ * debouncedFunction('Hello world'); * debouncedFunction('Hello world again'); * ``` - * @param fn - * @param delayMs + * @param fn The function to be debounced. + * @param delayMs Defaults to 5 seconds. */ const debounce = ReturnType>( fn: T, From 8b12c9bd36e876fba98ae33a24746eac54d673a1 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 1 Feb 2024 12:44:10 -0800 Subject: [PATCH 33/33] chore: Add missing export. --- packages/sdk/react-native/src/platform/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index 18b5933a1..fb87893fb 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -22,7 +22,7 @@ import { ldApplication, ldDevice } from './autoEnv'; import AsyncStorage from './ConditionalAsyncStorage'; import PlatformCrypto from './crypto'; -class PlatformRequests implements Requests { +export class PlatformRequests implements Requests { eventSource?: RNEventSource; constructor(private readonly logger: LDLogger) {}