From 4ff5a2434f0197548cc7053d6dfd4e958fac425e Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 8 May 2026 09:56:56 -0500 Subject: [PATCH 1/3] chore: init `@launchdarkly/client-testing-plugin` --- .gitignore | 2 +- package.json | 1 + .../tooling/client-testing-plugin/README.md | 72 ++++++ .../__tests__/TestData.test.ts | 214 ++++++++++++++++++ .../__tests__/e2e/browser.test.ts | 149 ++++++++++++ .../client-testing-plugin/jest.config.json | 10 + .../client-testing-plugin/package.json | 54 +++++ .../client-testing-plugin/setup-jest.js | 40 ++++ .../client-testing-plugin/src/TestData.ts | 189 ++++++++++++++++ .../client-testing-plugin/src/index.ts | 1 + .../client-testing-plugin/tsconfig.json | 22 ++ .../client-testing-plugin/tsconfig.ref.json | 7 + .../client-testing-plugin/tsup.config.js | 13 ++ tsconfig.json | 3 + 14 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 packages/tooling/client-testing-plugin/README.md create mode 100644 packages/tooling/client-testing-plugin/__tests__/TestData.test.ts create mode 100644 packages/tooling/client-testing-plugin/__tests__/e2e/browser.test.ts create mode 100644 packages/tooling/client-testing-plugin/jest.config.json create mode 100644 packages/tooling/client-testing-plugin/package.json create mode 100644 packages/tooling/client-testing-plugin/setup-jest.js create mode 100644 packages/tooling/client-testing-plugin/src/TestData.ts create mode 100644 packages/tooling/client-testing-plugin/src/index.ts create mode 100644 packages/tooling/client-testing-plugin/tsconfig.json create mode 100644 packages/tooling/client-testing-plugin/tsconfig.ref.json create mode 100644 packages/tooling/client-testing-plugin/tsup.config.js diff --git a/.gitignore b/.gitignore index 8254734ee7..209973cfdd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,5 @@ stats.html .env.local .env.*.local .claude/worktrees -.claude/stacks +.claude/tmp .mcp.json diff --git a/package.json b/package.json index 61c1ebf768..e5d9bf7775 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "packages/store/node-server-sdk-redis", "packages/store/node-server-sdk-dynamodb", "packages/telemetry/node-server-sdk-otel", + "packages/tooling/client-testing-plugin", "packages/tooling/contract-test-utils", "packages/tooling/jest", "packages/tooling/jest/example/react-native-example", diff --git a/packages/tooling/client-testing-plugin/README.md b/packages/tooling/client-testing-plugin/README.md new file mode 100644 index 0000000000..9b56b4da8b --- /dev/null +++ b/packages/tooling/client-testing-plugin/README.md @@ -0,0 +1,72 @@ +# LaunchDarkly Client Testing Plugin + +A testing plugin for LaunchDarkly client-side JavaScript SDKs. Use it to inject deterministic flag values into a real SDK client during unit tests, integration tests, and local development. + +## Install + +```bash +yarn add --dev @launchdarkly/client-testing-plugin @launchdarkly/js-client-sdk +``` + +## Usage + +```ts +import { createClient } from '@launchdarkly/js-client-sdk'; +import { TestData } from '@launchdarkly/client-testing-plugin'; + +// Seed with a base set of flag values. +const td = new TestData({ + 'new-ui': true, + greeting: 'Hello!', +}); + +const client = createClient( + '', // placeholder -- fill in only for real environments + { kind: 'user', key: 'tester' }, + { + plugins: [td], + sendEvents: false, + streaming: false, + }, +); + +await client.start({ bootstrap: {} }); + +client.boolVariation('new-ui', false); // true +client.stringVariation('greeting', '(default)'); // 'Hello!' + +// Update flags at any time -- the SDK fires change events. Setters chain. +td.setBool('new-ui', false).setString('greeting', 'Welcome'); +``` + +### Why these options matter + +- **`plugins: [td]`** -- registers the testing plugin so it can inject overrides. +- **`sendEvents: false`** -- keeps analytics events off in tests. +- **`streaming: false`** -- prevents the SDK from auto-starting a streaming connection when a `change` listener is registered. Without this, the React SDK provider (and any other code that registers `change` listeners) will trigger a real network call to `clientstream.launchdarkly.com`. +- **`bootstrap: {}` (passed to `start()`)** -- gives the SDK an empty initial flag set so it does not block on a network identify call. The plugin's overrides are applied immediately afterward. + +If you forget any of these, the SDK may attempt to fetch flags from LaunchDarkly during initialization and produce real network traffic, console errors, or stray evaluation events. + +## API + +### `TestData` + +```ts +class TestData implements LDPlugin { + constructor(initialFlags?: { [key: string]: LDFlagValue }); + + setBool(key: string, value: boolean): this; + setString(key: string, value: string): this; + setNumber(key: string, value: number): this; + setJson(key: string, value: object | unknown[]): this; + + remove(key: string): this; + clear(): this; +} +``` + +- **`new TestData(initialFlags?)`** -- seed the instance with a base map of flag keys to values. The values are applied to the SDK client when it initializes. +- **`setBool` / `setString` / `setNumber` / `setJson`** -- set or update a single flag. If the SDK is already running, the change propagates immediately and listeners receive a `change:` event. Updates dedup by reference equality (`===`); pass a fresh object/array reference if you want a change event after mutating a previous value. +- **`remove(key)`** -- drop the override for a single key. If the SDK is connected, also calls `removeOverride`. +- **`clear()`** -- drop all overrides. Useful in `beforeEach` for shared `TestData` instances. diff --git a/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts b/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts new file mode 100644 index 0000000000..baa173d63e --- /dev/null +++ b/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts @@ -0,0 +1,214 @@ +import type { LDDebugOverride } from '@launchdarkly/js-client-sdk-common'; + +import TestData from '../src/TestData'; + +function createMockDebugOverride(): LDDebugOverride & { + overrides: Record; +} { + const overrides: Record = {}; + return { + overrides, + setOverride: jest.fn((key: string, value: unknown) => { + overrides[key] = value; + }), + removeOverride: jest.fn((key: string) => { + delete overrides[key]; + }), + clearAllOverrides: jest.fn(() => { + Object.keys(overrides).forEach((k) => delete overrides[k]); + }), + getAllOverrides: jest.fn(() => ({})), + }; +} + +describe('TestData', () => { + it('returns correct plugin metadata', () => { + expect(new TestData().getMetadata()).toEqual({ name: 'test-data' }); + }); + + it('register is a no-op', () => { + const td = new TestData(); + expect(() => + td.register(undefined, { + sdk: { name: 'test', version: '0.0.0' }, + clientSideId: 'test-key', + } as never), + ).not.toThrow(); + }); + + it('seeds initial flags from the constructor and applies them on registerDebug', () => { + const td = new TestData({ + 'show-banner': true, + greeting: 'Hello', + 'max-retries': 3, + config: { theme: 'dark' }, + }); + + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + expect(debugOverride.overrides['show-banner']).toBe(true); + expect(debugOverride.overrides.greeting).toBe('Hello'); + expect(debugOverride.overrides['max-retries']).toBe(3); + expect(debugOverride.overrides.config).toEqual({ theme: 'dark' }); + }); + + it('typed setters chain and apply pre-registration', () => { + const td = new TestData() + .setBool('show-banner', true) + .setString('greeting', 'Hello') + .setNumber('max-retries', 3) + .setJson('config', { theme: 'dark' }); + + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + expect(debugOverride.overrides['show-banner']).toBe(true); + expect(debugOverride.overrides.greeting).toBe('Hello'); + expect(debugOverride.overrides['max-retries']).toBe(3); + expect(debugOverride.overrides.config).toEqual({ theme: 'dark' }); + }); + + it('typed setters propagate live updates after registration', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.setBool('show-banner', true); + expect(debugOverride.setOverride).toHaveBeenCalledWith('show-banner', true); + + td.setString('greeting', 'Howdy'); + expect(debugOverride.setOverride).toHaveBeenCalledWith('greeting', 'Howdy'); + + td.setNumber('max-retries', 5); + expect(debugOverride.setOverride).toHaveBeenCalledWith('max-retries', 5); + + td.setJson('config', [1, 2, 3]); + expect(debugOverride.setOverride).toHaveBeenCalledWith('config', [1, 2, 3]); + }); + + it('skips setOverride when the same primitive value is set twice', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.setBool('flag', true); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + + td.setBool('flag', true); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + + td.setBool('flag', false); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); + }); + + it('dedups by reference equality, so passing a new object always fires', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.setJson('cfg', { showBanner: true }); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + + // New object reference -- fires even though structurally identical. + td.setJson('cfg', { showBanner: true }); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); + + // Same reference twice in a row -- deduped. + const same = { showBanner: false }; + td.setJson('cfg', same); + td.setJson('cfg', same); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(3); + }); + + it('remove clears stored state and the active override', () => { + const td = new TestData({ flag: true }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.remove('flag'); + + expect(debugOverride.removeOverride).toHaveBeenCalledWith('flag'); + expect(debugOverride.overrides.flag).toBeUndefined(); + }); + + it('remove before registerDebug prevents the flag from being applied later', () => { + const td = new TestData({ flag: true }); + td.remove('flag'); + + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + expect(debugOverride.setOverride).not.toHaveBeenCalled(); + expect(debugOverride.overrides.flag).toBeUndefined(); + }); + + it('clear resets all flags and clears the override interface', () => { + const td = new TestData({ a: true, b: 'x' }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.clear(); + + expect(debugOverride.clearAllOverrides).toHaveBeenCalledTimes(1); + }); + + it('clear before registerDebug drops queued flags', () => { + const td = new TestData({ a: true }); + td.clear(); + + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + expect(debugOverride.setOverride).not.toHaveBeenCalled(); + }); + + it('throws if registerDebug is called twice', () => { + const td = new TestData(); + td.registerDebug(createMockDebugOverride()); + + expect(() => td.registerDebug(createMockDebugOverride())).toThrow( + /already been registered/, + ); + }); + + it('setJson rejects undefined and other non-object values', () => { + const td = new TestData(); + expect(() => td.setJson('flag', undefined as unknown as object)).toThrow(TypeError); + expect(() => td.setJson('flag', null as unknown as object)).toThrow(TypeError); + expect(() => td.setJson('flag', 'string' as unknown as object)).toThrow(TypeError); + expect(() => td.setJson('flag', 42 as unknown as object)).toThrow(TypeError); + }); + + it('dedups NaN values via Object.is semantics', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.setNumber('flag', NaN); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + + td.setNumber('flag', NaN); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + + td.setNumber('flag', 0); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); + }); + + it('remove and clear return this for chaining', () => { + const td = new TestData({ a: true, b: 'x' }); + expect(td.remove('a')).toBe(td); + expect(td.clear()).toBe(td); + }); + + it('handles flag keys that collide with Object prototype names safely', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); + + td.setString('toString', 'overridden').setNumber('hasOwnProperty', 42); + + expect(debugOverride.setOverride).toHaveBeenCalledWith('toString', 'overridden'); + expect(debugOverride.setOverride).toHaveBeenCalledWith('hasOwnProperty', 42); + }); +}); diff --git a/packages/tooling/client-testing-plugin/__tests__/e2e/browser.test.ts b/packages/tooling/client-testing-plugin/__tests__/e2e/browser.test.ts new file mode 100644 index 0000000000..f2bb80f44b --- /dev/null +++ b/packages/tooling/client-testing-plugin/__tests__/e2e/browser.test.ts @@ -0,0 +1,149 @@ +/** + * @jest-environment jsdom + * + * E2E integration test: creates a real browser SDK client with TestData + * and verifies flag evaluation and dynamic updates work end-to-end. + * + * This demonstrates the exact usage pattern that library consumers would use + * when writing unit tests for their applications. + */ +import { createClient } from '@launchdarkly/js-client-sdk'; + +import { TestData } from '../../src/index'; + +describe('Browser SDK integration', () => { + let td: TestData; + let client: ReturnType; + + beforeEach(async () => { + td = new TestData({ + 'bool-flag': true, + 'string-flag': 'hello', + 'number-flag': 42, + 'json-flag': { key: 'value' }, + 'multi-variation': 'green', + }); + + client = createClient( + 'test-client-id', + { kind: 'user', key: 'test-user' }, + { + plugins: [td], + sendEvents: false, + diagnosticOptOut: true, + streaming: false, + }, + ); + + await client.start({ bootstrap: {} }); + }); + + afterEach(async () => { + await client.close(); + }); + + it('evaluates boolean flags', () => { + expect(client.boolVariation('bool-flag', false)).toBe(true); + }); + + it('evaluates boolean flags via boolVariationDetail', () => { + const detail = client.boolVariationDetail('bool-flag', false); + expect(detail.value).toBe(true); + // Override descriptors carry no variation index and no reason -- assert the + // shape the SDK actually produces so future regressions are caught. + expect(detail.variationIndex).toBeNull(); + expect(detail.reason).toBeUndefined(); + }); + + it('evaluates string flags', () => { + expect(client.stringVariation('string-flag', 'default')).toBe('hello'); + }); + + it('evaluates string flags via stringVariationDetail', () => { + const detail = client.stringVariationDetail('string-flag', 'default'); + expect(detail.value).toBe('hello'); + expect(detail.variationIndex).toBeNull(); + expect(detail.reason).toBeUndefined(); + }); + + it('evaluates number flags', () => { + expect(client.numberVariation('number-flag', 0)).toBe(42); + }); + + it('evaluates number flags via numberVariationDetail', () => { + const detail = client.numberVariationDetail('number-flag', 0); + expect(detail.value).toBe(42); + expect(detail.variationIndex).toBeNull(); + expect(detail.reason).toBeUndefined(); + }); + + it('evaluates json flags', () => { + expect(client.jsonVariation('json-flag', null)).toEqual({ key: 'value' }); + }); + + it('evaluates json flags via jsonVariationDetail', () => { + const detail = client.jsonVariationDetail('json-flag', null); + expect(detail.value).toEqual({ key: 'value' }); + expect(detail.variationIndex).toBeNull(); + expect(detail.reason).toBeUndefined(); + }); + + it('evaluates string flags overridden to one of several possible values', () => { + expect(client.stringVariation('multi-variation', 'default')).toBe('green'); + }); + + it('returns all flags via allFlags()', () => { + const flags = client.allFlags(); + expect(flags['bool-flag']).toBe(true); + expect(flags['string-flag']).toBe('hello'); + expect(flags['number-flag']).toBe(42); + expect(flags['multi-variation']).toBe('green'); + }); + + it('returns default when flag is not defined', () => { + expect(client.boolVariation('nonexistent', false)).toBe(false); + expect(client.stringVariation('nonexistent', 'fallback')).toBe('fallback'); + }); + + it('dynamically updates a flag and fires change event', async () => { + expect(client.boolVariation('bool-flag', false)).toBe(true); + + const changed = new Promise((resolve) => { + client.on('change:bool-flag', () => resolve()); + }); + + td.setBool('bool-flag', false); + + await changed; + + expect(client.boolVariation('bool-flag', true)).toBe(false); + }); + + it('adds a new flag dynamically after initialization', async () => { + expect(client.stringVariation('new-flag', 'default')).toBe('default'); + + const changed = new Promise((resolve) => { + client.on('change:new-flag', () => resolve()); + }); + + td.setString('new-flag', 'surprise'); + + await changed; + + expect(client.stringVariation('new-flag', 'default')).toBe('surprise'); + }); + + it('updates a string flag to a different value', async () => { + expect(client.stringVariation('multi-variation', 'default')).toBe('green'); + + const changed = new Promise((resolve) => { + client.on('change:multi-variation', () => resolve()); + }); + + td.setString('multi-variation', 'blue'); + + await changed; + + expect(client.stringVariation('multi-variation', 'default')).toBe('blue'); + }); +}); diff --git a/packages/tooling/client-testing-plugin/jest.config.json b/packages/tooling/client-testing-plugin/jest.config.json new file mode 100644 index 0000000000..e949d99750 --- /dev/null +++ b/packages/tooling/client-testing-plugin/jest.config.json @@ -0,0 +1,10 @@ +{ + "transform": { "^.+\\.tsx?$": ["ts-jest", { "tsconfig": { "esModuleInterop": true } }] }, + "testMatch": ["**/*.test.ts?(x)"], + "testPathIgnorePatterns": ["node_modules", "dist", "/example/"], + "modulePathIgnorePatterns": ["dist", "/example/"], + "testEnvironment": "jsdom", + "setupFiles": ["./setup-jest.js"], + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], + "collectCoverageFrom": ["src/**/*.ts?(x)"] +} diff --git a/packages/tooling/client-testing-plugin/package.json b/packages/tooling/client-testing-plugin/package.json new file mode 100644 index 0000000000..bafeda681a --- /dev/null +++ b/packages/tooling/client-testing-plugin/package.json @@ -0,0 +1,54 @@ +{ + "name": "@launchdarkly/client-testing-plugin", + "version": "0.0.1", + "description": "Testing plugin for LaunchDarkly client-side JavaScript SDKs. Uses the experimental plugin debug-override mechanism to inject flag values for unit tests and local development.", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/tooling/client-testing-plugin", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "packageManager": "yarn@4.2.2", + "keywords": [ + "launchdarkly", + "test", + "mock", + "testing", + "feature-flags" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "tsup", + "test": "npx jest --ci", + "lint": "eslint . --ext .ts,.tsx" + }, + "dependencies": { + "@launchdarkly/js-client-sdk-common": "workspace:^" + }, + "devDependencies": { + "@launchdarkly/js-client-sdk": "workspace:^", + "@types/jest": "^29.5.0", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "eslint": "^8.45.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.0.0", + "rimraf": "6.0.1", + "tsup": "^8.5.1", + "typescript": "5.1.6" + } +} diff --git a/packages/tooling/client-testing-plugin/setup-jest.js b/packages/tooling/client-testing-plugin/setup-jest.js new file mode 100644 index 0000000000..a085dd76dd --- /dev/null +++ b/packages/tooling/client-testing-plugin/setup-jest.js @@ -0,0 +1,40 @@ +const { TextEncoder, TextDecoder } = require('node:util'); +const crypto = require('node:crypto'); + +global.TextEncoder = TextEncoder; + +Object.assign(window, { TextDecoder, TextEncoder }); + +// Stub EventSource for tests that register change listeners (which triggers +// automatic streaming in the browser SDK). The real EventSource isn't available +// in jsdom. +if (typeof global.EventSource === 'undefined') { + global.EventSource = class EventSource { + constructor() { + // no-op + } + + addEventListener() {} + + removeEventListener() {} + + close() {} + }; +} + +// jsdom doesn't provide crypto.subtle, which the SDK needs for context hashing. +// Based on: https://stackoverflow.com/a/71750830 +Object.defineProperty(global.self, 'crypto', { + value: { + getRandomValues: (arr) => crypto.randomBytes(arr.length), + subtle: { + digest: (algorithm, data) => { + return new Promise((resolve) => + resolve( + crypto.createHash(algorithm.toLowerCase().replace('-', '')).update(data).digest(), + ), + ); + }, + }, + }, +}); diff --git a/packages/tooling/client-testing-plugin/src/TestData.ts b/packages/tooling/client-testing-plugin/src/TestData.ts new file mode 100644 index 0000000000..d2bf593e19 --- /dev/null +++ b/packages/tooling/client-testing-plugin/src/TestData.ts @@ -0,0 +1,189 @@ +import type { + LDDebugOverride, + LDFlagValue, + LDPluginBase, + LDPluginEnvironmentMetadata, + LDPluginMetadata, +} from '@launchdarkly/js-client-sdk-common'; + +const PLUGIN_NAME = 'test-data'; + +const hasOwn = (obj: object, key: string): boolean => + Object.prototype.hasOwnProperty.call(obj, key); + +/** + * A mechanism for providing dynamically updatable feature flag values to an + * SDK client in test scenarios. + * + * `TestData` integrates with the SDK as a plugin and uses the + * debug override mechanism to inject flag values. Unlike streaming or polling + * data sources that connect to LaunchDarkly services, `TestData` lets you + * define flag values in code and update them during test execution without + * any network I/O. + * + * **Primary use cases:** + * - Unit tests that need predictable flag evaluation behavior + * - Integration tests that simulate various flag configurations + * - Local development environments without LaunchDarkly connectivity + * + * **Important:** `TestData` is intended exclusively for testing and + * development scenarios. It must not be used in production environments. + * + * @example + * ```typescript + * import { TestData } from '@launchdarkly/client-testing-plugin'; + * import { createClient } from '@launchdarkly/js-client-sdk'; + * + * const td = new TestData({ + * 'show-banner': true, + * greeting: 'Hello', + * }); + * + * const client = createClient('test-key', context, { + * plugins: [td], + * sendEvents: false, + * streaming: false, + * }); + * await client.start({ bootstrap: {} }); + * + * // Update flag values at any time: + * td.setBool('show-banner', false).setString('greeting', 'Welcome'); + * ``` + */ +export default class TestData implements LDPluginBase { + private _values: Record = Object.create(null); + private _debugOverride?: LDDebugOverride; + + /** + * Creates a new TestData instance, optionally seeded with a base set of + * flag values. The seed values are applied to the SDK client when it + * initializes. + * + * @param initialFlags optional map of flag keys to values + */ + constructor(initialFlags?: { [key: string]: LDFlagValue }) { + if (initialFlags) { + Object.entries(initialFlags).forEach(([key, value]) => { + this._values[key] = value; + }); + } + } + + /** + * Sets a boolean flag value. + * + * @returns this TestData for chaining + */ + setBool(key: string, value: boolean): this { + return this._set(key, value); + } + + /** + * Sets a string flag value. + * + * @returns this TestData for chaining + */ + setString(key: string, value: string): this { + return this._set(key, value); + } + + /** + * Sets a numeric flag value. + * + * @returns this TestData for chaining + */ + setNumber(key: string, value: number): this { + return this._set(key, value); + } + + /** + * Sets a JSON flag value (object or array). + * + * Updates dedup by reference equality. Pass a fresh object/array reference + * if you want a `change` event to fire after mutating the previously-set + * value. + * + * @returns this TestData for chaining + */ + setJson(key: string, value: object | unknown[]): this { + if (value === null || typeof value !== 'object') { + throw new TypeError( + `setJson("${key}", ...) requires an object or array; got ${value === null ? 'null' : typeof value}`, + ); + } + return this._set(key, value); + } + + /** + * Removes the flag with the given key. If the SDK client is connected, + * the override for this flag is also cleared. + * + * @returns this TestData for chaining + */ + remove(key: string): this { + delete this._values[key]; + this._debugOverride?.removeOverride(key); + return this; + } + + /** + * Removes all flags. If the SDK client is connected, all overrides are + * cleared. Useful for test isolation in `beforeEach`. + * + * @returns this TestData for chaining + */ + clear(): this { + this._values = Object.create(null); + this._debugOverride?.clearAllOverrides(); + return this; + } + + getMetadata(): LDPluginMetadata { + return { name: PLUGIN_NAME }; + } + + register(_client: unknown, _environmentMetadata: LDPluginEnvironmentMetadata): void { + // No-op: this plugin only needs the LDDebugOverride handed to registerDebug. + } + + /** + * A given `TestData` instance must be paired with at most one client. + * Calling `registerDebug` a second time throws. The SDK plugin runner + * catches this throw and logs an error rather than failing initialization, + * so the second client will silently still work but will not receive + * subsequent flag updates from this `TestData`. + */ + registerDebug(debugOverride: LDDebugOverride): void { + if (this._debugOverride) { + throw new Error( + 'TestData has already been registered with a client. ' + + 'Construct a separate TestData instance for each client.', + ); + } + this._debugOverride = debugOverride; + + Object.keys(this._values).forEach((key) => { + debugOverride.setOverride(key, this._values[key]); + }); + } + + /** + * @internal + * + * Shared write path for the typed setters. Stores the value, then fires + * `setOverride` unless this is a no-op primitive write (same key, same + * primitive value as before). Object/array writes always fire. + */ + private _set(key: string, value: LDFlagValue): this { + const hadPrevious = hasOwn(this._values, key); + const previous = hadPrevious ? this._values[key] : undefined; + + this._values[key] = value; + + const isNoop = hadPrevious && Object.is(previous, value); + if (this._debugOverride && !isNoop) { + this._debugOverride.setOverride(key, value); + } + return this; + } +} diff --git a/packages/tooling/client-testing-plugin/src/index.ts b/packages/tooling/client-testing-plugin/src/index.ts new file mode 100644 index 0000000000..fcf26360ce --- /dev/null +++ b/packages/tooling/client-testing-plugin/src/index.ts @@ -0,0 +1 @@ +export { default as TestData } from './TestData'; diff --git a/packages/tooling/client-testing-plugin/tsconfig.json b/packages/tooling/client-testing-plugin/tsconfig.json new file mode 100644 index 0000000000..978c6c3ac1 --- /dev/null +++ b/packages/tooling/client-testing-plugin/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "lib": ["es6", "dom"], + "module": "ESNext", + "moduleResolution": "node", + "noImplicitOverride": true, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": ".", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "stripInternal": true, + "target": "ES2017", + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.test.tsx", "dist", "node_modules", "__tests__"] +} diff --git a/packages/tooling/client-testing-plugin/tsconfig.ref.json b/packages/tooling/client-testing-plugin/tsconfig.ref.json new file mode 100644 index 0000000000..34a1cb607a --- /dev/null +++ b/packages/tooling/client-testing-plugin/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/tooling/client-testing-plugin/tsup.config.js b/packages/tooling/client-testing-plugin/tsup.config.js new file mode 100644 index 0000000000..fb954052d9 --- /dev/null +++ b/packages/tooling/client-testing-plugin/tsup.config.js @@ -0,0 +1,13 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: { index: 'src/index.ts' }, + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: true, + minify: false, + }, +]); diff --git a/tsconfig.json b/tsconfig.json index 0d86dcbb09..2d1f02f893 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,6 +52,9 @@ { "path": "./packages/telemetry/node-server-sdk-otel/tsconfig.ref.json" }, + { + "path": "./packages/tooling/client-testing-plugin/tsconfig.ref.json" + }, { "path": "./packages/tooling/jest/tsconfig.ref.json" }, From a1694eb1cb59bf75bc0e341cd9206384091b4726 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 8 May 2026 11:35:13 -0500 Subject: [PATCH 2/3] chore: bot comment --- packages/tooling/client-testing-plugin/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tooling/client-testing-plugin/package.json b/packages/tooling/client-testing-plugin/package.json index bafeda681a..fb764a7e76 100644 --- a/packages/tooling/client-testing-plugin/package.json +++ b/packages/tooling/client-testing-plugin/package.json @@ -48,6 +48,7 @@ "jest": "^30.2.0", "jest-environment-jsdom": "^30.0.0", "rimraf": "6.0.1", + "ts-jest": "^29.1.1", "tsup": "^8.5.1", "typescript": "5.1.6" } From c2fa0f634b6376c338d3aa9ccf360591b9db11fc Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 2 Jun 2026 14:32:12 -0400 Subject: [PATCH 3/3] chore: pr comments --- .../tooling/client-testing-plugin/README.md | 13 +- .../__tests__/TestData.test.ts | 282 ++++++++---------- .../client-testing-plugin/setup-jest.js | 3 - .../client-testing-plugin/src/TestData.ts | 23 +- .../client-testing-plugin/tsconfig.json | 2 +- 5 files changed, 143 insertions(+), 180 deletions(-) diff --git a/packages/tooling/client-testing-plugin/README.md b/packages/tooling/client-testing-plugin/README.md index 9b56b4da8b..9a3fd94617 100644 --- a/packages/tooling/client-testing-plugin/README.md +++ b/packages/tooling/client-testing-plugin/README.md @@ -39,14 +39,15 @@ client.stringVariation('greeting', '(default)'); // 'Hello!' td.setBool('new-ui', false).setString('greeting', 'Welcome'); ``` -### Why these options matter +### Required LD client options +In order to successfully set up a LD client to use the testing plugin, you **MUST** set the following options: -- **`plugins: [td]`** -- registers the testing plugin so it can inject overrides. -- **`sendEvents: false`** -- keeps analytics events off in tests. -- **`streaming: false`** -- prevents the SDK from auto-starting a streaming connection when a `change` listener is registered. Without this, the React SDK provider (and any other code that registers `change` listeners) will trigger a real network call to `clientstream.launchdarkly.com`. +- **`plugins: [td]`** - registers the testing plugin so it can inject overrides. +- **`sendEvents: false`** - keeps analytics events off in tests. +- **`streaming: false`** - (required for `js-client-sdk` and its derivativs, eg `react-sdk`), having streaming on will cause the `js-client-sdk` to automatically open a streaming connection. - **`bootstrap: {}` (passed to `start()`)** -- gives the SDK an empty initial flag set so it does not block on a network identify call. The plugin's overrides are applied immediately afterward. -If you forget any of these, the SDK may attempt to fetch flags from LaunchDarkly during initialization and produce real network traffic, console errors, or stray evaluation events. +> Refer to the usage example above. ## API @@ -67,6 +68,6 @@ class TestData implements LDPlugin { ``` - **`new TestData(initialFlags?)`** -- seed the instance with a base map of flag keys to values. The values are applied to the SDK client when it initializes. -- **`setBool` / `setString` / `setNumber` / `setJson`** -- set or update a single flag. If the SDK is already running, the change propagates immediately and listeners receive a `change:` event. Updates dedup by reference equality (`===`); pass a fresh object/array reference if you want a change event after mutating a previous value. +- **`setBool` / `setString` / `setNumber` / `setJson`** -- set or update a single flag. If the SDK is already running, the change propagates immediately and listeners receive a `change:` event. Every write applies the override, even when the value is unchanged -- mirroring a real connection that can re-deliver a flag and fire a `change` event without the value differing. - **`remove(key)`** -- drop the override for a single key. If the SDK is connected, also calls `removeOverride`. - **`clear()`** -- drop all overrides. Useful in `beforeEach` for shared `TestData` instances. diff --git a/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts b/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts index baa173d63e..6e97c67f61 100644 --- a/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts +++ b/packages/tooling/client-testing-plugin/__tests__/TestData.test.ts @@ -21,194 +21,172 @@ function createMockDebugOverride(): LDDebugOverride & { }; } -describe('TestData', () => { - it('returns correct plugin metadata', () => { - expect(new TestData().getMetadata()).toEqual({ name: 'test-data' }); - }); - - it('register is a no-op', () => { - const td = new TestData(); - expect(() => - td.register(undefined, { - sdk: { name: 'test', version: '0.0.0' }, - clientSideId: 'test-key', - } as never), - ).not.toThrow(); - }); - - it('seeds initial flags from the constructor and applies them on registerDebug', () => { - const td = new TestData({ - 'show-banner': true, - greeting: 'Hello', - 'max-retries': 3, - config: { theme: 'dark' }, - }); - - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); - - expect(debugOverride.overrides['show-banner']).toBe(true); - expect(debugOverride.overrides.greeting).toBe('Hello'); - expect(debugOverride.overrides['max-retries']).toBe(3); - expect(debugOverride.overrides.config).toEqual({ theme: 'dark' }); - }); - - it('typed setters chain and apply pre-registration', () => { - const td = new TestData() - .setBool('show-banner', true) - .setString('greeting', 'Hello') - .setNumber('max-retries', 3) - .setJson('config', { theme: 'dark' }); +it('returns correct plugin metadata', () => { + expect(new TestData().getMetadata()).toEqual({ name: 'test-data' }); +}); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); +it('register is a no-op', () => { + const td = new TestData(); + expect(() => + td.register(undefined, { + sdk: { name: 'test', version: '0.0.0' }, + clientSideId: 'test-key', + } as never), + ).not.toThrow(); +}); - expect(debugOverride.overrides['show-banner']).toBe(true); - expect(debugOverride.overrides.greeting).toBe('Hello'); - expect(debugOverride.overrides['max-retries']).toBe(3); - expect(debugOverride.overrides.config).toEqual({ theme: 'dark' }); +it('seeds initial flags from the constructor and applies them on registerDebug', () => { + const td = new TestData({ + 'show-banner': true, + greeting: 'Hello', + 'max-retries': 3, + config: { theme: 'dark' }, }); - it('typed setters propagate live updates after registration', () => { - const td = new TestData(); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); - - td.setBool('show-banner', true); - expect(debugOverride.setOverride).toHaveBeenCalledWith('show-banner', true); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - td.setString('greeting', 'Howdy'); - expect(debugOverride.setOverride).toHaveBeenCalledWith('greeting', 'Howdy'); + expect(debugOverride.overrides['show-banner']).toBe(true); + expect(debugOverride.overrides.greeting).toBe('Hello'); + expect(debugOverride.overrides['max-retries']).toBe(3); + expect(debugOverride.overrides.config).toEqual({ theme: 'dark' }); +}); - td.setNumber('max-retries', 5); - expect(debugOverride.setOverride).toHaveBeenCalledWith('max-retries', 5); +it('typed setters chain and apply pre-registration', () => { + const td = new TestData() + .setBool('show-banner', true) + .setString('greeting', 'Hello') + .setNumber('max-retries', 3) + .setJson('config', { theme: 'dark' }); - td.setJson('config', [1, 2, 3]); - expect(debugOverride.setOverride).toHaveBeenCalledWith('config', [1, 2, 3]); - }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - it('skips setOverride when the same primitive value is set twice', () => { - const td = new TestData(); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); + expect(debugOverride.overrides['show-banner']).toBe(true); + expect(debugOverride.overrides.greeting).toBe('Hello'); + expect(debugOverride.overrides['max-retries']).toBe(3); + expect(debugOverride.overrides.config).toEqual({ theme: 'dark' }); +}); - td.setBool('flag', true); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); +it('typed setters propagate live updates after registration', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - td.setBool('flag', true); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + td.setBool('show-banner', true); + expect(debugOverride.setOverride).toHaveBeenCalledWith('show-banner', true); - td.setBool('flag', false); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); - }); + td.setString('greeting', 'Howdy'); + expect(debugOverride.setOverride).toHaveBeenCalledWith('greeting', 'Howdy'); - it('dedups by reference equality, so passing a new object always fires', () => { - const td = new TestData(); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); + td.setNumber('max-retries', 5); + expect(debugOverride.setOverride).toHaveBeenCalledWith('max-retries', 5); - td.setJson('cfg', { showBanner: true }); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + td.setJson('config', [1, 2, 3]); + expect(debugOverride.setOverride).toHaveBeenCalledWith('config', [1, 2, 3]); +}); - // New object reference -- fires even though structurally identical. - td.setJson('cfg', { showBanner: true }); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); +it('fires setOverride on every write, including repeated identical values', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - // Same reference twice in a row -- deduped. - const same = { showBanner: false }; - td.setJson('cfg', same); - td.setJson('cfg', same); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(3); - }); + td.setBool('flag', true); + td.setBool('flag', true); + td.setBool('flag', true); - it('remove clears stored state and the active override', () => { - const td = new TestData({ flag: true }); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(3); + expect(debugOverride.setOverride).toHaveBeenNthCalledWith(1, 'flag', true); + expect(debugOverride.setOverride).toHaveBeenNthCalledWith(3, 'flag', true); +}); - td.remove('flag'); +it('fires setOverride for repeated NaN and object writes', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - expect(debugOverride.removeOverride).toHaveBeenCalledWith('flag'); - expect(debugOverride.overrides.flag).toBeUndefined(); - }); + td.setNumber('n', NaN); + td.setNumber('n', NaN); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); - it('remove before registerDebug prevents the flag from being applied later', () => { - const td = new TestData({ flag: true }); - td.remove('flag'); + const same = { showBanner: true }; + td.setJson('cfg', same); + td.setJson('cfg', same); + expect(debugOverride.setOverride).toHaveBeenCalledTimes(4); +}); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); +it('remove clears stored state and the active override', () => { + const td = new TestData({ flag: true }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - expect(debugOverride.setOverride).not.toHaveBeenCalled(); - expect(debugOverride.overrides.flag).toBeUndefined(); - }); + td.remove('flag'); - it('clear resets all flags and clears the override interface', () => { - const td = new TestData({ a: true, b: 'x' }); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); + expect(debugOverride.removeOverride).toHaveBeenCalledWith('flag'); + expect(debugOverride.overrides.flag).toBeUndefined(); +}); - td.clear(); +it('remove before registerDebug prevents the flag from being applied later', () => { + const td = new TestData({ flag: true }); + td.remove('flag'); - expect(debugOverride.clearAllOverrides).toHaveBeenCalledTimes(1); - }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - it('clear before registerDebug drops queued flags', () => { - const td = new TestData({ a: true }); - td.clear(); + expect(debugOverride.setOverride).not.toHaveBeenCalled(); + expect(debugOverride.overrides.flag).toBeUndefined(); +}); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); +it('clear resets all flags and clears the override interface', () => { + const td = new TestData({ a: true, b: 'x' }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - expect(debugOverride.setOverride).not.toHaveBeenCalled(); - }); + td.clear(); - it('throws if registerDebug is called twice', () => { - const td = new TestData(); - td.registerDebug(createMockDebugOverride()); + expect(debugOverride.clearAllOverrides).toHaveBeenCalledTimes(1); +}); - expect(() => td.registerDebug(createMockDebugOverride())).toThrow( - /already been registered/, - ); - }); +it('clear before registerDebug drops queued flags', () => { + const td = new TestData({ a: true }); + td.clear(); - it('setJson rejects undefined and other non-object values', () => { - const td = new TestData(); - expect(() => td.setJson('flag', undefined as unknown as object)).toThrow(TypeError); - expect(() => td.setJson('flag', null as unknown as object)).toThrow(TypeError); - expect(() => td.setJson('flag', 'string' as unknown as object)).toThrow(TypeError); - expect(() => td.setJson('flag', 42 as unknown as object)).toThrow(TypeError); - }); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - it('dedups NaN values via Object.is semantics', () => { - const td = new TestData(); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); + expect(debugOverride.setOverride).not.toHaveBeenCalled(); +}); - td.setNumber('flag', NaN); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); +it('throws if registerDebug is called twice', () => { + const td = new TestData(); + td.registerDebug(createMockDebugOverride()); - td.setNumber('flag', NaN); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(1); + expect(() => td.registerDebug(createMockDebugOverride())).toThrow( + /already been registered/, + ); +}); - td.setNumber('flag', 0); - expect(debugOverride.setOverride).toHaveBeenCalledTimes(2); - }); +it('setJson rejects undefined and other non-object values', () => { + const td = new TestData(); + expect(() => td.setJson('flag', undefined as unknown as object)).toThrow(TypeError); + expect(() => td.setJson('flag', null as unknown as object)).toThrow(TypeError); + expect(() => td.setJson('flag', 'string' as unknown as object)).toThrow(TypeError); + expect(() => td.setJson('flag', 42 as unknown as object)).toThrow(TypeError); +}); - it('remove and clear return this for chaining', () => { - const td = new TestData({ a: true, b: 'x' }); - expect(td.remove('a')).toBe(td); - expect(td.clear()).toBe(td); - }); +it('remove and clear return this for chaining', () => { + const td = new TestData({ a: true, b: 'x' }); + expect(td.remove('a')).toBe(td); + expect(td.clear()).toBe(td); +}); - it('handles flag keys that collide with Object prototype names safely', () => { - const td = new TestData(); - const debugOverride = createMockDebugOverride(); - td.registerDebug(debugOverride); +it('handles flag keys that collide with Object prototype names safely', () => { + const td = new TestData(); + const debugOverride = createMockDebugOverride(); + td.registerDebug(debugOverride); - td.setString('toString', 'overridden').setNumber('hasOwnProperty', 42); + td.setString('toString', 'overridden').setNumber('hasOwnProperty', 42); - expect(debugOverride.setOverride).toHaveBeenCalledWith('toString', 'overridden'); - expect(debugOverride.setOverride).toHaveBeenCalledWith('hasOwnProperty', 42); - }); + expect(debugOverride.setOverride).toHaveBeenCalledWith('toString', 'overridden'); + expect(debugOverride.setOverride).toHaveBeenCalledWith('hasOwnProperty', 42); }); diff --git a/packages/tooling/client-testing-plugin/setup-jest.js b/packages/tooling/client-testing-plugin/setup-jest.js index a085dd76dd..c932c6829d 100644 --- a/packages/tooling/client-testing-plugin/setup-jest.js +++ b/packages/tooling/client-testing-plugin/setup-jest.js @@ -13,11 +13,8 @@ if (typeof global.EventSource === 'undefined') { constructor() { // no-op } - addEventListener() {} - removeEventListener() {} - close() {} }; } diff --git a/packages/tooling/client-testing-plugin/src/TestData.ts b/packages/tooling/client-testing-plugin/src/TestData.ts index d2bf593e19..e9d4324d16 100644 --- a/packages/tooling/client-testing-plugin/src/TestData.ts +++ b/packages/tooling/client-testing-plugin/src/TestData.ts @@ -8,9 +8,6 @@ import type { const PLUGIN_NAME = 'test-data'; -const hasOwn = (obj: object, key: string): boolean => - Object.prototype.hasOwnProperty.call(obj, key); - /** * A mechanism for providing dynamically updatable feature flag values to an * SDK client in test scenarios. @@ -99,10 +96,6 @@ export default class TestData implements LDPluginBase { /** * Sets a JSON flag value (object or array). * - * Updates dedup by reference equality. Pass a fresh object/array reference - * if you want a `change` event to fire after mutating the previously-set - * value. - * * @returns this TestData for chaining */ setJson(key: string, value: object | unknown[]): this { @@ -170,20 +163,14 @@ export default class TestData implements LDPluginBase { /** * @internal * - * Shared write path for the typed setters. Stores the value, then fires - * `setOverride` unless this is a no-op primitive write (same key, same - * primitive value as before). Object/array writes always fire. + * Shared write path for the typed setters. Stores the value and, if the SDK + * client is connected, applies the override. Every write fires + * `setOverride`, mirroring a real connection that can re-deliver a flag and + * fire a `change` event even when the value is unchanged. */ private _set(key: string, value: LDFlagValue): this { - const hadPrevious = hasOwn(this._values, key); - const previous = hadPrevious ? this._values[key] : undefined; - this._values[key] = value; - - const isNoop = hadPrevious && Object.is(previous, value); - if (this._debugOverride && !isNoop) { - this._debugOverride.setOverride(key, value); - } + this._debugOverride?.setOverride(key, value); return this; } } diff --git a/packages/tooling/client-testing-plugin/tsconfig.json b/packages/tooling/client-testing-plugin/tsconfig.json index 978c6c3ac1..f083ccc43d 100644 --- a/packages/tooling/client-testing-plugin/tsconfig.json +++ b/packages/tooling/client-testing-plugin/tsconfig.json @@ -5,7 +5,7 @@ "declarationMap": true, "lib": ["es6", "dom"], "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "Bundler", "noImplicitOverride": true, "outDir": "dist", "resolveJsonModule": true,