Skip to content

Commit 4ff5a24

Browse files
committed
chore: init @launchdarkly/client-testing-plugin
1 parent b8ecf39 commit 4ff5a24

14 files changed

Lines changed: 776 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: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
### Why these options matter
43+
44+
- **`plugins: [td]`** -- registers the testing plugin so it can inject overrides.
45+
- **`sendEvents: false`** -- keeps analytics events off in tests.
46+
- **`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`.
47+
- **`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.
48+
49+
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.
50+
51+
## API
52+
53+
### `TestData`
54+
55+
```ts
56+
class TestData implements LDPlugin {
57+
constructor(initialFlags?: { [key: string]: LDFlagValue });
58+
59+
setBool(key: string, value: boolean): this;
60+
setString(key: string, value: string): this;
61+
setNumber(key: string, value: number): this;
62+
setJson(key: string, value: object | unknown[]): this;
63+
64+
remove(key: string): this;
65+
clear(): this;
66+
}
67+
```
68+
69+
- **`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.
70+
- **`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. Updates dedup by reference equality (`===`); pass a fresh object/array reference if you want a change event after mutating a previous value.
71+
- **`remove(key)`** -- drop the override for a single key. If the SDK is connected, also calls `removeOverride`.
72+
- **`clear()`** -- drop all overrides. Useful in `beforeEach` for shared `TestData` instances.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
describe('TestData', () => {
25+
it('returns correct plugin metadata', () => {
26+
expect(new TestData().getMetadata()).toEqual({ name: 'test-data' });
27+
});
28+
29+
it('register is a no-op', () => {
30+
const td = new TestData();
31+
expect(() =>
32+
td.register(undefined, {
33+
sdk: { name: 'test', version: '0.0.0' },
34+
clientSideId: 'test-key',
35+
} as never),
36+
).not.toThrow();
37+
});
38+
39+
it('seeds initial flags from the constructor and applies them on registerDebug', () => {
40+
const td = new TestData({
41+
'show-banner': true,
42+
greeting: 'Hello',
43+
'max-retries': 3,
44+
config: { theme: 'dark' },
45+
});
46+
47+
const debugOverride = createMockDebugOverride();
48+
td.registerDebug(debugOverride);
49+
50+
expect(debugOverride.overrides['show-banner']).toBe(true);
51+
expect(debugOverride.overrides.greeting).toBe('Hello');
52+
expect(debugOverride.overrides['max-retries']).toBe(3);
53+
expect(debugOverride.overrides.config).toEqual({ theme: 'dark' });
54+
});
55+
56+
it('typed setters chain and apply pre-registration', () => {
57+
const td = new TestData()
58+
.setBool('show-banner', true)
59+
.setString('greeting', 'Hello')
60+
.setNumber('max-retries', 3)
61+
.setJson('config', { theme: 'dark' });
62+
63+
const debugOverride = createMockDebugOverride();
64+
td.registerDebug(debugOverride);
65+
66+
expect(debugOverride.overrides['show-banner']).toBe(true);
67+
expect(debugOverride.overrides.greeting).toBe('Hello');
68+
expect(debugOverride.overrides['max-retries']).toBe(3);
69+
expect(debugOverride.overrides.config).toEqual({ theme: 'dark' });
70+
});
71+
72+
it('typed setters propagate live updates after registration', () => {
73+
const td = new TestData();
74+
const debugOverride = createMockDebugOverride();
75+
td.registerDebug(debugOverride);
76+
77+
td.setBool('show-banner', true);
78+
expect(debugOverride.setOverride).toHaveBeenCalledWith('show-banner', true);
79+
80+
td.setString('greeting', 'Howdy');
81+
expect(debugOverride.setOverride).toHaveBeenCalledWith('greeting', 'Howdy');
82+
83+
td.setNumber('max-retries', 5);
84+
expect(debugOverride.setOverride).toHaveBeenCalledWith('max-retries', 5);
85+
86+
td.setJson('config', [1, 2, 3]);
87+
expect(debugOverride.setOverride).toHaveBeenCalledWith('config', [1, 2, 3]);
88+
});
89+
90+
it('skips setOverride when the same primitive value is set twice', () => {
91+
const td = new TestData();
92+
const debugOverride = createMockDebugOverride();
93+
td.registerDebug(debugOverride);
94+
95+
td.setBool('flag', true);
96+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(1);
97+
98+
td.setBool('flag', true);
99+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(1);
100+
101+
td.setBool('flag', false);
102+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(2);
103+
});
104+
105+
it('dedups by reference equality, so passing a new object always fires', () => {
106+
const td = new TestData();
107+
const debugOverride = createMockDebugOverride();
108+
td.registerDebug(debugOverride);
109+
110+
td.setJson('cfg', { showBanner: true });
111+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(1);
112+
113+
// New object reference -- fires even though structurally identical.
114+
td.setJson('cfg', { showBanner: true });
115+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(2);
116+
117+
// Same reference twice in a row -- deduped.
118+
const same = { showBanner: false };
119+
td.setJson('cfg', same);
120+
td.setJson('cfg', same);
121+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(3);
122+
});
123+
124+
it('remove clears stored state and the active override', () => {
125+
const td = new TestData({ flag: true });
126+
const debugOverride = createMockDebugOverride();
127+
td.registerDebug(debugOverride);
128+
129+
td.remove('flag');
130+
131+
expect(debugOverride.removeOverride).toHaveBeenCalledWith('flag');
132+
expect(debugOverride.overrides.flag).toBeUndefined();
133+
});
134+
135+
it('remove before registerDebug prevents the flag from being applied later', () => {
136+
const td = new TestData({ flag: true });
137+
td.remove('flag');
138+
139+
const debugOverride = createMockDebugOverride();
140+
td.registerDebug(debugOverride);
141+
142+
expect(debugOverride.setOverride).not.toHaveBeenCalled();
143+
expect(debugOverride.overrides.flag).toBeUndefined();
144+
});
145+
146+
it('clear resets all flags and clears the override interface', () => {
147+
const td = new TestData({ a: true, b: 'x' });
148+
const debugOverride = createMockDebugOverride();
149+
td.registerDebug(debugOverride);
150+
151+
td.clear();
152+
153+
expect(debugOverride.clearAllOverrides).toHaveBeenCalledTimes(1);
154+
});
155+
156+
it('clear before registerDebug drops queued flags', () => {
157+
const td = new TestData({ a: true });
158+
td.clear();
159+
160+
const debugOverride = createMockDebugOverride();
161+
td.registerDebug(debugOverride);
162+
163+
expect(debugOverride.setOverride).not.toHaveBeenCalled();
164+
});
165+
166+
it('throws if registerDebug is called twice', () => {
167+
const td = new TestData();
168+
td.registerDebug(createMockDebugOverride());
169+
170+
expect(() => td.registerDebug(createMockDebugOverride())).toThrow(
171+
/already been registered/,
172+
);
173+
});
174+
175+
it('setJson rejects undefined and other non-object values', () => {
176+
const td = new TestData();
177+
expect(() => td.setJson('flag', undefined as unknown as object)).toThrow(TypeError);
178+
expect(() => td.setJson('flag', null as unknown as object)).toThrow(TypeError);
179+
expect(() => td.setJson('flag', 'string' as unknown as object)).toThrow(TypeError);
180+
expect(() => td.setJson('flag', 42 as unknown as object)).toThrow(TypeError);
181+
});
182+
183+
it('dedups NaN values via Object.is semantics', () => {
184+
const td = new TestData();
185+
const debugOverride = createMockDebugOverride();
186+
td.registerDebug(debugOverride);
187+
188+
td.setNumber('flag', NaN);
189+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(1);
190+
191+
td.setNumber('flag', NaN);
192+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(1);
193+
194+
td.setNumber('flag', 0);
195+
expect(debugOverride.setOverride).toHaveBeenCalledTimes(2);
196+
});
197+
198+
it('remove and clear return this for chaining', () => {
199+
const td = new TestData({ a: true, b: 'x' });
200+
expect(td.remove('a')).toBe(td);
201+
expect(td.clear()).toBe(td);
202+
});
203+
204+
it('handles flag keys that collide with Object prototype names safely', () => {
205+
const td = new TestData();
206+
const debugOverride = createMockDebugOverride();
207+
td.registerDebug(debugOverride);
208+
209+
td.setString('toString', 'overridden').setNumber('hasOwnProperty', 42);
210+
211+
expect(debugOverride.setOverride).toHaveBeenCalledWith('toString', 'overridden');
212+
expect(debugOverride.setOverride).toHaveBeenCalledWith('hasOwnProperty', 42);
213+
});
214+
});

0 commit comments

Comments
 (0)