Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ stats.html
.env.local
.env.*.local
.claude/worktrees
.claude/stacks
.claude/tmp
.mcp.json
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions packages/tooling/client-testing-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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(
'<ldClientSideId>', // 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');
```

### 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`** - (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.

> Refer to the usage example above.

## 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:<key>` 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.
192 changes: 192 additions & 0 deletions packages/tooling/client-testing-plugin/__tests__/TestData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import type { LDDebugOverride } from '@launchdarkly/js-client-sdk-common';

import TestData from '../src/TestData';

function createMockDebugOverride(): LDDebugOverride & {
overrides: Record<string, unknown>;
} {
const overrides: Record<string, unknown> = {};
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(() => ({})),
};
}

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('fires setOverride on every write, including repeated identical values', () => {
const td = new TestData();
const debugOverride = createMockDebugOverride();
td.registerDebug(debugOverride);

td.setBool('flag', true);
td.setBool('flag', true);
td.setBool('flag', true);

expect(debugOverride.setOverride).toHaveBeenCalledTimes(3);
expect(debugOverride.setOverride).toHaveBeenNthCalledWith(1, 'flag', true);
expect(debugOverride.setOverride).toHaveBeenNthCalledWith(3, 'flag', true);
});

it('fires setOverride for repeated NaN and object writes', () => {
const td = new TestData();
const debugOverride = createMockDebugOverride();
td.registerDebug(debugOverride);

td.setNumber('n', NaN);
td.setNumber('n', NaN);
expect(debugOverride.setOverride).toHaveBeenCalledTimes(2);

const same = { showBanner: true };
td.setJson('cfg', same);
td.setJson('cfg', same);
expect(debugOverride.setOverride).toHaveBeenCalledTimes(4);
});

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('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);
});
Loading
Loading