Skip to content

Commit 891ffe2

Browse files
authored
chore: init @launchdarkly/client-testing-plugin (#1373)
This PR pushes the initial implementation of the client test data plugin SDK-2124 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > New dev-only tooling with no production SDK behavior changes; risk is limited to monorepo build/test wiring for the new package. > > **Overview** > Introduces **`@launchdarkly/client-testing-plugin`**, a new workspace package that exposes **`TestData`** as an SDK plugin for tests and local dev. It drives the existing **`registerDebug` / `LDDebugOverride`** path so flag values can be seeded, updated (with **`change:`** events), removed, or cleared **without network I/O**. > > The package ships with README usage docs, **tsup** build output (ESM/CJS + types), Jest setup (jsdom stubs for **EventSource** and **crypto.subtle**), unit tests against a mock debug override, and browser SDK integration tests. The monorepo wires the package into root **`package.json`** workspaces and **`tsconfig.json`** references; **`.gitignore`** swaps **`.claude/stacks`** for **`.claude/tmp`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c2fa0f6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b8ecf39 commit 891ffe2

14 files changed

Lines changed: 740 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,5 @@ stats.html
2828
.env.local
2929
.env.*.local
3030
.claude/worktrees
31-
.claude/stacks
31+
.claude/tmp
3232
.mcp.json

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"packages/store/node-server-sdk-redis",
4141
"packages/store/node-server-sdk-dynamodb",
4242
"packages/telemetry/node-server-sdk-otel",
43+
"packages/tooling/client-testing-plugin",
4344
"packages/tooling/contract-test-utils",
4445
"packages/tooling/jest",
4546
"packages/tooling/jest/example/react-native-example",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# LaunchDarkly Client Testing Plugin
2+
3+
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.
4+
5+
## Install
6+
7+
```bash
8+
yarn add --dev @launchdarkly/client-testing-plugin @launchdarkly/js-client-sdk
9+
```
10+
11+
## Usage
12+
13+
```ts
14+
import { createClient } from '@launchdarkly/js-client-sdk';
15+
import { TestData } from '@launchdarkly/client-testing-plugin';
16+
17+
// Seed with a base set of flag values.
18+
const td = new TestData({
19+
'new-ui': true,
20+
greeting: 'Hello!',
21+
});
22+
23+
const client = createClient(
24+
'<ldClientSideId>', // placeholder -- fill in only for real environments
25+
{ kind: 'user', key: 'tester' },
26+
{
27+
plugins: [td],
28+
sendEvents: false,
29+
streaming: false,
30+
},
31+
);
32+
33+
await client.start({ bootstrap: {} });
34+
35+
client.boolVariation('new-ui', false); // true
36+
client.stringVariation('greeting', '(default)'); // 'Hello!'
37+
38+
// Update flags at any time -- the SDK fires change events. Setters chain.
39+
td.setBool('new-ui', false).setString('greeting', 'Welcome');
40+
```
41+
42+
### Required LD client options
43+
In order to successfully set up a LD client to use the testing plugin, you **MUST** set the following options:
44+
45+
- **`plugins: [td]`** - registers the testing plugin so it can inject overrides.
46+
- **`sendEvents: false`** - keeps analytics events off in tests.
47+
- **`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.
48+
- **`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.
49+
50+
> Refer to the usage example above.
51+
52+
## API
53+
54+
### `TestData`
55+
56+
```ts
57+
class TestData implements LDPlugin {
58+
constructor(initialFlags?: { [key: string]: LDFlagValue });
59+
60+
setBool(key: string, value: boolean): this;
61+
setString(key: string, value: string): this;
62+
setNumber(key: string, value: number): this;
63+
setJson(key: string, value: object | unknown[]): this;
64+
65+
remove(key: string): this;
66+
clear(): this;
67+
}
68+
```
69+
70+
- **`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.
71+
- **`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.
72+
- **`remove(key)`** -- drop the override for a single key. If the SDK is connected, also calls `removeOverride`.
73+
- **`clear()`** -- drop all overrides. Useful in `beforeEach` for shared `TestData` instances.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { LDDebugOverride } from '@launchdarkly/js-client-sdk-common';
2+
3+
import TestData from '../src/TestData';
4+
5+
function createMockDebugOverride(): LDDebugOverride & {
6+
overrides: Record<string, unknown>;
7+
} {
8+
const overrides: Record<string, unknown> = {};
9+
return {
10+
overrides,
11+
setOverride: jest.fn((key: string, value: unknown) => {
12+
overrides[key] = value;
13+
}),
14+
removeOverride: jest.fn((key: string) => {
15+
delete overrides[key];
16+
}),
17+
clearAllOverrides: jest.fn(() => {
18+
Object.keys(overrides).forEach((k) => delete overrides[k]);
19+
}),
20+
getAllOverrides: jest.fn(() => ({})),
21+
};
22+
}
23+
24+
it('returns correct plugin metadata', () => {
25+
expect(new TestData().getMetadata()).toEqual({ name: 'test-data' });
26+
});
27+
28+
it('register is a no-op', () => {
29+
const td = new TestData();
30+
expect(() =>
31+
td.register(undefined, {
32+
sdk: { name: 'test', version: '0.0.0' },
33+
clientSideId: 'test-key',
34+
} as never),
35+
).not.toThrow();
36+
});
37+
38+
it('seeds initial flags from the constructor and applies them on registerDebug', () => {
39+
const td = new TestData({
40+
'show-banner': true,
41+
greeting: 'Hello',
42+
'max-retries': 3,
43+
config: { theme: 'dark' },
44+
});
45+
46+
const debugOverride = createMockDebugOverride();
47+
td.registerDebug(debugOverride);
48+
49+
expect(debugOverride.overrides['show-banner']).toBe(true);
50+
expect(debugOverride.overrides.greeting).toBe('Hello');
51+
expect(debugOverride.overrides['max-retries']).toBe(3);
52+
expect(debugOverride.overrides.config).toEqual({ theme: 'dark' });
53+
});
54+
55+
it('typed setters chain and apply pre-registration', () => {
56+
const td = new TestData()
57+
.setBool('show-banner', true)
58+
.setString('greeting', 'Hello')
59+
.setNumber('max-retries', 3)
60+
.setJson('config', { theme: 'dark' });
61+
62+
const debugOverride = createMockDebugOverride();
63+
td.registerDebug(debugOverride);
64+
65+
expect(debugOverride.overrides['show-banner']).toBe(true);
66+
expect(debugOverride.overrides.greeting).toBe('Hello');
67+
expect(debugOverride.overrides['max-retries']).toBe(3);
68+
expect(debugOverride.overrides.config).toEqual({ theme: 'dark' });
69+
});
70+
71+
it('typed setters propagate live updates after registration', () => {
72+
const td = new TestData();
73+
const debugOverride = createMockDebugOverride();
74+
td.registerDebug(debugOverride);
75+
76+
td.setBool('show-banner', true);
77+
expect(debugOverride.setOverride).toHaveBeenCalledWith('show-banner', true);
78+
79+
td.setString('greeting', 'Howdy');
80+
expect(debugOverride.setOverride).toHaveBeenCalledWith('greeting', 'Howdy');
81+
82+
td.setNumber('max-retries', 5);
83+
expect(debugOverride.setOverride).toHaveBeenCalledWith('max-retries', 5);
84+
85+
td.setJson('config', [1, 2, 3]);
86+
expect(debugOverride.setOverride).toHaveBeenCalledWith('config', [1, 2, 3]);
87+
});
88+
89+
it('fires setOverride on every write, including repeated identical values', () => {
90+
const td = new TestData();
91+
const debugOverride = createMockDebugOverride();
92+
td.registerDebug(debugOverride);
93+
94+
td.setBool('flag', true);
95+
td.setBool('flag', true);
96+
td.setBool('flag', true);
97+
98+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(3);
99+
expect(debugOverride.setOverride).toHaveBeenNthCalledWith(1, 'flag', true);
100+
expect(debugOverride.setOverride).toHaveBeenNthCalledWith(3, 'flag', true);
101+
});
102+
103+
it('fires setOverride for repeated NaN and object writes', () => {
104+
const td = new TestData();
105+
const debugOverride = createMockDebugOverride();
106+
td.registerDebug(debugOverride);
107+
108+
td.setNumber('n', NaN);
109+
td.setNumber('n', NaN);
110+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(2);
111+
112+
const same = { showBanner: true };
113+
td.setJson('cfg', same);
114+
td.setJson('cfg', same);
115+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(4);
116+
});
117+
118+
it('remove clears stored state and the active override', () => {
119+
const td = new TestData({ flag: true });
120+
const debugOverride = createMockDebugOverride();
121+
td.registerDebug(debugOverride);
122+
123+
td.remove('flag');
124+
125+
expect(debugOverride.removeOverride).toHaveBeenCalledWith('flag');
126+
expect(debugOverride.overrides.flag).toBeUndefined();
127+
});
128+
129+
it('remove before registerDebug prevents the flag from being applied later', () => {
130+
const td = new TestData({ flag: true });
131+
td.remove('flag');
132+
133+
const debugOverride = createMockDebugOverride();
134+
td.registerDebug(debugOverride);
135+
136+
expect(debugOverride.setOverride).not.toHaveBeenCalled();
137+
expect(debugOverride.overrides.flag).toBeUndefined();
138+
});
139+
140+
it('clear resets all flags and clears the override interface', () => {
141+
const td = new TestData({ a: true, b: 'x' });
142+
const debugOverride = createMockDebugOverride();
143+
td.registerDebug(debugOverride);
144+
145+
td.clear();
146+
147+
expect(debugOverride.clearAllOverrides).toHaveBeenCalledTimes(1);
148+
});
149+
150+
it('clear before registerDebug drops queued flags', () => {
151+
const td = new TestData({ a: true });
152+
td.clear();
153+
154+
const debugOverride = createMockDebugOverride();
155+
td.registerDebug(debugOverride);
156+
157+
expect(debugOverride.setOverride).not.toHaveBeenCalled();
158+
});
159+
160+
it('throws if registerDebug is called twice', () => {
161+
const td = new TestData();
162+
td.registerDebug(createMockDebugOverride());
163+
164+
expect(() => td.registerDebug(createMockDebugOverride())).toThrow(
165+
/already been registered/,
166+
);
167+
});
168+
169+
it('setJson rejects undefined and other non-object values', () => {
170+
const td = new TestData();
171+
expect(() => td.setJson('flag', undefined as unknown as object)).toThrow(TypeError);
172+
expect(() => td.setJson('flag', null as unknown as object)).toThrow(TypeError);
173+
expect(() => td.setJson('flag', 'string' as unknown as object)).toThrow(TypeError);
174+
expect(() => td.setJson('flag', 42 as unknown as object)).toThrow(TypeError);
175+
});
176+
177+
it('remove and clear return this for chaining', () => {
178+
const td = new TestData({ a: true, b: 'x' });
179+
expect(td.remove('a')).toBe(td);
180+
expect(td.clear()).toBe(td);
181+
});
182+
183+
it('handles flag keys that collide with Object prototype names safely', () => {
184+
const td = new TestData();
185+
const debugOverride = createMockDebugOverride();
186+
td.registerDebug(debugOverride);
187+
188+
td.setString('toString', 'overridden').setNumber('hasOwnProperty', 42);
189+
190+
expect(debugOverride.setOverride).toHaveBeenCalledWith('toString', 'overridden');
191+
expect(debugOverride.setOverride).toHaveBeenCalledWith('hasOwnProperty', 42);
192+
});

0 commit comments

Comments
 (0)