Skip to content

Commit e4d6de4

Browse files
committed
feat: Add FDv2 data system support to NodeClient
1 parent 3fc12dc commit e4d6de4

4 files changed

Lines changed: 451 additions & 13 deletions

File tree

.github/workflows/node-client.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,12 @@ jobs:
4646
with:
4747
test_service_port: 8000
4848
token: ${{ secrets.GITHUB_TOKEN }}
49+
stop_service: 'false'
4950
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/node-client/contract-tests/testharness-suppressions.txt'
51+
- name: Run contract tests (FDv2)
52+
uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b # contract-tests-v1.3.0
53+
with:
54+
test_service_port: 8000
55+
token: ${{ secrets.GITHUB_TOKEN }}
56+
version: v3.1.1-alpha.6
57+
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/node-client/contract-tests/testharness-suppressions-fdv2.txt'
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import * as fs from 'fs/promises';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
5+
import NodeCrypto from '../src/platform/NodeCrypto';
6+
import NodeEncoding from '../src/platform/NodeEncoding';
7+
import NodeInfo from '../src/platform/NodeInfo';
8+
import { resetNodeStorage } from '../src/platform/NodeStorage';
9+
import { createMockLogger } from './testHelpers';
10+
11+
jest.mock('../src/platform/NodePlatform', () => ({
12+
__esModule: true,
13+
default: jest.fn(() => ({
14+
crypto: new NodeCrypto(),
15+
info: new NodeInfo(),
16+
requests: {
17+
createEventSource: jest.fn(),
18+
fetch: jest.fn(),
19+
getEventSourceCapabilities: jest.fn(),
20+
},
21+
encoding: new NodeEncoding(),
22+
storage: {
23+
clear: jest.fn(),
24+
get: jest.fn(),
25+
set: jest.fn(),
26+
},
27+
})),
28+
}));
29+
30+
// Tracks the mode last passed to the mock FDv2 data manager's setConnectionMode.
31+
// Seeded from opts.foregroundMode when createFDv2DataManagerBase is called.
32+
// Used only to verify delegation; NodeClient now tracks requested mode itself.
33+
let mockCurrentMode = 'streaming';
34+
35+
// Captures the opts passed to createFDv2DataManagerBase so tests can invoke
36+
// buildQueryParams directly.
37+
let capturedFDv2Opts:
38+
| { buildQueryParams: (opts?: { hash?: string }) => { key: string; value: string }[] }
39+
| undefined;
40+
41+
const mockSetConnectionMode = jest.fn((mode: string) => {
42+
mockCurrentMode = mode;
43+
});
44+
45+
jest.mock('@launchdarkly/js-client-sdk-common', () => ({
46+
...jest.requireActual('@launchdarkly/js-client-sdk-common'),
47+
createFDv2DataManagerBase: jest.fn((opts: any) => {
48+
capturedFDv2Opts = opts;
49+
mockCurrentMode = opts.foregroundMode ?? 'streaming';
50+
return {
51+
identify: jest.fn(),
52+
close: jest.fn(),
53+
setConnectionMode: mockSetConnectionMode,
54+
getCurrentMode: () => mockCurrentMode,
55+
};
56+
}),
57+
}));
58+
59+
import { createClient } from '../src';
60+
61+
const DEFAULT_INITIAL_CONTEXT = { kind: 'user' as const, key: 'bob' };
62+
let tmpRoot: string;
63+
let logger: ReturnType<typeof createMockLogger>;
64+
65+
beforeAll(() => {
66+
jest.useFakeTimers();
67+
});
68+
69+
afterAll(() => {
70+
jest.useRealTimers();
71+
});
72+
73+
beforeEach(async () => {
74+
jest.clearAllMocks();
75+
capturedFDv2Opts = undefined;
76+
mockCurrentMode = 'streaming';
77+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'node-client-fdv2-test-'));
78+
resetNodeStorage();
79+
logger = createMockLogger();
80+
});
81+
82+
afterEach(async () => {
83+
await fs.rm(tmpRoot, { recursive: true, force: true });
84+
});
85+
86+
it('setConnectionMode does not throw in FDv2 mode', async () => {
87+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
88+
dataSystem: {},
89+
diagnosticOptOut: true,
90+
sendEvents: false,
91+
logger,
92+
localStoragePath: tmpRoot,
93+
});
94+
await expect(client.setConnectionMode('offline')).resolves.toBeUndefined();
95+
});
96+
97+
it('getConnectionMode returns the mode set via setConnectionMode in FDv2 mode', async () => {
98+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
99+
dataSystem: {},
100+
diagnosticOptOut: true,
101+
sendEvents: false,
102+
logger,
103+
localStoragePath: tmpRoot,
104+
});
105+
await client.setConnectionMode('offline');
106+
expect(client.getConnectionMode()).toBe('offline');
107+
expect(client.isOffline()).toBe(true);
108+
});
109+
110+
it('isOffline returns false after switching back to streaming in FDv2 mode', async () => {
111+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
112+
dataSystem: {},
113+
diagnosticOptOut: true,
114+
sendEvents: false,
115+
logger,
116+
localStoragePath: tmpRoot,
117+
});
118+
await client.setConnectionMode('offline');
119+
await client.setConnectionMode('streaming');
120+
expect(client.getConnectionMode()).toBe('streaming');
121+
expect(client.isOffline()).toBe(false);
122+
});
123+
124+
it('setConnectionMode delegates to the FDv2 data manager', async () => {
125+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
126+
dataSystem: {},
127+
diagnosticOptOut: true,
128+
sendEvents: false,
129+
logger,
130+
localStoragePath: tmpRoot,
131+
});
132+
await client.setConnectionMode('offline');
133+
expect(mockSetConnectionMode).toHaveBeenCalledWith('offline');
134+
});
135+
136+
// ------ FDv2 initial mode reconciliation ------
137+
138+
it('isOffline returns true initially when FDv2 dataSystem configures offline mode', () => {
139+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
140+
dataSystem: { automaticModeSwitching: { type: 'manual', initialConnectionMode: 'offline' } },
141+
diagnosticOptOut: true,
142+
sendEvents: false,
143+
logger,
144+
localStoragePath: tmpRoot,
145+
});
146+
expect(client.isOffline()).toBe(true);
147+
expect(client.getConnectionMode()).toBe('offline');
148+
});
149+
150+
it('getConnectionMode reflects FDv2 manual polling mode at construction', () => {
151+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
152+
dataSystem: { automaticModeSwitching: { type: 'manual', initialConnectionMode: 'polling' } },
153+
diagnosticOptOut: true,
154+
sendEvents: false,
155+
logger,
156+
localStoragePath: tmpRoot,
157+
});
158+
expect(client.getConnectionMode()).toBe('polling');
159+
expect(client.isOffline()).toBe(false);
160+
});
161+
162+
// ------ buildQueryParams coverage ------
163+
164+
it('buildQueryParams returns [] in FDv2 mobile key mode', () => {
165+
createClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, {
166+
useMobileKey: true,
167+
dataSystem: {},
168+
diagnosticOptOut: true,
169+
sendEvents: false,
170+
logger,
171+
localStoragePath: tmpRoot,
172+
});
173+
expect(capturedFDv2Opts).toBeDefined();
174+
expect(capturedFDv2Opts!.buildQueryParams()).toEqual([]);
175+
});
176+
177+
it('buildQueryParams ignores and warns when per-identify hash is set in FDv2 mobile key mode', () => {
178+
createClient('mobile-key-123', DEFAULT_INITIAL_CONTEXT, {
179+
useMobileKey: true,
180+
dataSystem: {},
181+
diagnosticOptOut: true,
182+
sendEvents: false,
183+
logger,
184+
localStoragePath: tmpRoot,
185+
});
186+
expect(capturedFDv2Opts).toBeDefined();
187+
expect(capturedFDv2Opts!.buildQueryParams({ hash: 'should-not-leak' })).toEqual([]);
188+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('hash'));
189+
});
190+
191+
it('buildQueryParams returns auth param in FDv2 client-side ID mode', () => {
192+
createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
193+
dataSystem: {},
194+
diagnosticOptOut: true,
195+
sendEvents: false,
196+
logger,
197+
localStoragePath: tmpRoot,
198+
});
199+
expect(capturedFDv2Opts).toBeDefined();
200+
expect(capturedFDv2Opts!.buildQueryParams()).toEqual([{ key: 'auth', value: 'client-side-id' }]);
201+
});
202+
203+
it('buildQueryParams includes hash param when hash is set in client-side ID mode', () => {
204+
createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
205+
dataSystem: {},
206+
hash: 'secure-hash-abc',
207+
diagnosticOptOut: true,
208+
sendEvents: false,
209+
logger,
210+
localStoragePath: tmpRoot,
211+
});
212+
expect(capturedFDv2Opts).toBeDefined();
213+
expect(capturedFDv2Opts!.buildQueryParams()).toEqual([
214+
{ key: 'auth', value: 'client-side-id' },
215+
{ key: 'h', value: 'secure-hash-abc' },
216+
]);
217+
});
218+
219+
it('buildQueryParams per-identify hash overrides construction-time hash in FDv2', () => {
220+
createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
221+
dataSystem: {},
222+
hash: 'construction-hash',
223+
diagnosticOptOut: true,
224+
sendEvents: false,
225+
logger,
226+
localStoragePath: tmpRoot,
227+
});
228+
expect(capturedFDv2Opts).toBeDefined();
229+
expect(capturedFDv2Opts!.buildQueryParams({ hash: 'per-identify-hash' })).toEqual([
230+
{ key: 'auth', value: 'client-side-id' },
231+
{ key: 'h', value: 'per-identify-hash' },
232+
]);
233+
});
234+
235+
it('buildQueryParams uses construction-time hash when no per-identify hash is provided in FDv2', () => {
236+
createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
237+
dataSystem: {},
238+
hash: 'construction-hash',
239+
diagnosticOptOut: true,
240+
sendEvents: false,
241+
logger,
242+
localStoragePath: tmpRoot,
243+
});
244+
expect(capturedFDv2Opts).toBeDefined();
245+
expect(capturedFDv2Opts!.buildQueryParams({})).toEqual([
246+
{ key: 'auth', value: 'client-side-id' },
247+
{ key: 'h', value: 'construction-hash' },
248+
]);
249+
});
250+
251+
// ------ Concurrent setConnectionMode serialization ------
252+
253+
it('concurrent setConnectionMode calls are serialized in call order', async () => {
254+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
255+
dataSystem: {},
256+
diagnosticOptOut: true,
257+
sendEvents: false,
258+
logger,
259+
localStoragePath: tmpRoot,
260+
});
261+
// Fire offline then streaming concurrently. Without serialization the offline
262+
// branch's await flush() lets streaming complete first, reversing the order.
263+
const p1 = client.setConnectionMode('offline');
264+
const p2 = client.setConnectionMode('streaming');
265+
await Promise.all([p1, p2]);
266+
// Serialization ensures the calls execute in issue order: offline then streaming.
267+
expect(mockSetConnectionMode.mock.calls).toEqual([['offline'], ['streaming']]);
268+
expect(client.getConnectionMode()).toBe('streaming');
269+
expect(client.isOffline()).toBe(false);
270+
});
271+
272+
// ------ Idempotency ------
273+
274+
it('setConnectionMode is a no-op when called with the current mode in FDv2', async () => {
275+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
276+
dataSystem: {},
277+
diagnosticOptOut: true,
278+
sendEvents: false,
279+
logger,
280+
localStoragePath: tmpRoot,
281+
});
282+
// Initial mode is streaming; calling streaming again should be a no-op.
283+
await client.setConnectionMode('streaming');
284+
expect(mockSetConnectionMode).not.toHaveBeenCalled();
285+
expect(client.getConnectionMode()).toBe('streaming');
286+
});
287+
288+
// ------ Post-close guard ------
289+
290+
it('setConnectionMode after close is a no-op in FDv2', async () => {
291+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
292+
dataSystem: {},
293+
diagnosticOptOut: true,
294+
sendEvents: false,
295+
logger,
296+
localStoragePath: tmpRoot,
297+
});
298+
await client.close();
299+
await client.setConnectionMode('offline');
300+
expect(mockSetConnectionMode).not.toHaveBeenCalled();
301+
});
302+
303+
// ------ Invalid mode validation ------
304+
305+
it('setConnectionMode with an invalid mode logs a warning and does not delegate in FDv2', async () => {
306+
const client = createClient('client-side-id', DEFAULT_INITIAL_CONTEXT, {
307+
dataSystem: {},
308+
diagnosticOptOut: true,
309+
sendEvents: false,
310+
logger,
311+
localStoragePath: tmpRoot,
312+
});
313+
// @ts-ignore testing JS callers passing an invalid value
314+
await client.setConnectionMode('invalid-mode');
315+
expect(mockSetConnectionMode).not.toHaveBeenCalled();
316+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid-mode'));
317+
});

packages/sdk/node-client/contract-tests/testharness-suppressions-fdv2.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ streaming/fdv2/reconnection state management/saves previously known state
55
streaming/fdv2/reconnection state management/replaces previously known state
66
streaming/fdv2/reconnection state management/updates previously known state
77
streaming/fdv2/can discard partial events on errors
8+
9+
# Looks like an issue in the test harness where client SDKs are assumed to have a
10+
# one-shot polling connection established before establishing a streaming connection.
11+
# Will need to investigate more, but manual testing seems fine.
12+
wrapper/stream requests/wrapper name and version

0 commit comments

Comments
 (0)