Skip to content

Commit 6704bca

Browse files
authored
chore(vue-sdk): Add LDVueClient hardening, provider, plugin, and base composables (#1769)
1 parent 5f7bf59 commit 6704bca

14 files changed

Lines changed: 869 additions & 26 deletions

File tree

packages/sdk/vue/__tests__/client/LDVueClient.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,83 @@ it('does not notify context subscribers when identify does not complete', async
105105

106106
expect(onContext).not.toHaveBeenCalled();
107107
});
108+
109+
it('transitions to failed state and resolves (not rejects) when the base start() rejects', async () => {
110+
const error = new Error('network failure');
111+
createBaseClientMock.mockReturnValue(
112+
makeBaseClient({ start: jest.fn(() => Promise.reject(error)) }),
113+
);
114+
const client = createClient('env-id', { kind: 'user', key: 'k' });
115+
116+
const subscriber = jest.fn();
117+
client.onInitializationStatusChange(subscriber);
118+
119+
const result = await client.start();
120+
121+
expect(result).toEqual({ status: 'failed', error });
122+
expect(client.getInitializationState()).toBe('failed');
123+
expect(client.getInitializationError()).toBe(error);
124+
expect(subscriber).toHaveBeenCalledWith({ status: 'failed', error });
125+
126+
// Late subscriber should also receive the cached failed result.
127+
const lateSubscriber = jest.fn();
128+
client.onInitializationStatusChange(lateSubscriber);
129+
expect(lateSubscriber).toHaveBeenCalledWith({ status: 'failed', error });
130+
});
131+
132+
it('start() notifies init-status subscribers once even if called multiple times', async () => {
133+
const startMock = jest.fn(() => Promise.resolve<Result>({ status: 'complete' }));
134+
createBaseClientMock.mockReturnValue(makeBaseClient({ start: startMock }));
135+
const client = createClient('env-id', { kind: 'user', key: 'k' });
136+
137+
const earlySubscriber = jest.fn();
138+
client.onInitializationStatusChange(earlySubscriber);
139+
140+
await client.start();
141+
await client.start();
142+
143+
expect(startMock).toHaveBeenCalledTimes(2);
144+
// The second start() bypasses the wrapper's then handler, so early subscribers are only notified once.
145+
expect(earlySubscriber).toHaveBeenCalledTimes(1);
146+
expect(earlySubscriber).toHaveBeenCalledWith({ status: 'complete' });
147+
148+
// A subscriber registered after both starts should still receive the cached result.
149+
const lateSubscriber = jest.fn();
150+
client.onInitializationStatusChange(lateSubscriber);
151+
expect(lateSubscriber).toHaveBeenCalledWith({ status: 'complete' });
152+
});
153+
154+
it('notifies context subscribers when start resolves with timeout', async () => {
155+
createBaseClientMock.mockReturnValue(
156+
makeBaseClient({ start: jest.fn(() => Promise.resolve<Result>({ status: 'timeout' })) }),
157+
);
158+
const client = createClient('env-id', { kind: 'user', key: 'k' });
159+
160+
const onContext = jest.fn();
161+
client.onContextChange(onContext);
162+
163+
await client.start();
164+
165+
expect(client.isReady()).toBe(true);
166+
expect(client.getInitializationState()).toBe('timeout');
167+
expect(onContext).toHaveBeenCalledWith({ kind: 'user', key: 'context-key' });
168+
});
169+
170+
it('does not let a getContext() throw affect the initialization result', async () => {
171+
const boom = new Error('getContext boom');
172+
createBaseClientMock.mockReturnValue(
173+
makeBaseClient({
174+
start: jest.fn(() => Promise.resolve<Result>({ status: 'complete' })),
175+
getContext: jest.fn(() => { throw boom; }),
176+
}),
177+
);
178+
const client = createClient('env-id', { kind: 'user', key: 'k' });
179+
const subscriber = jest.fn();
180+
client.onInitializationStatusChange(subscriber);
181+
182+
const result = await client.start();
183+
184+
expect(result.status).toBe('complete');
185+
expect(client.getInitializationState()).toBe('complete');
186+
expect(subscriber).toHaveBeenCalledWith({ status: 'complete' });
187+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { LDEvaluationDetailTyped } from '@launchdarkly/js-client-sdk';
2+
3+
import type { LDVueClient } from '../../src/client/LDClient';
4+
5+
export interface MockControls {
6+
setBool: (v: boolean) => void;
7+
emitChange: (key: string) => void;
8+
handlerCount: (event: string) => number;
9+
emitInitStatus: (r: { status: string; error?: Error }) => void;
10+
emitContextChange: (c: unknown) => void;
11+
subscriberCount: () => number;
12+
}
13+
14+
/**
15+
* A controllable in-memory LDVueClient stand-in for component tests. Returns the client plus a
16+
* `controls` object used to drive events from tests.
17+
*/
18+
export function makeMockClient(initial?: {
19+
ready?: boolean;
20+
initializedState?: string;
21+
boolValue?: boolean;
22+
}): { client: LDVueClient; controls: MockControls } {
23+
let ready = initial?.ready ?? true;
24+
let initializedState = initial?.initializedState ?? 'complete';
25+
let boolValue = initial?.boolValue ?? true;
26+
let initError: Error | undefined;
27+
28+
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
29+
const initStatusSubs = new Set<(r: { status: string; error?: Error }) => void>();
30+
const contextSubs = new Set<(c: unknown) => void>();
31+
32+
const addHandler = (event: string, h: (...args: unknown[]) => void) => {
33+
if (!handlers.has(event)) {
34+
handlers.set(event, new Set());
35+
}
36+
handlers.get(event)!.add(h);
37+
};
38+
39+
const notReadyDetail = <T>(def: T): LDEvaluationDetailTyped<T> => ({
40+
value: def,
41+
variationIndex: null,
42+
reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' },
43+
});
44+
45+
const client = {
46+
getContext: () => ({ kind: 'user', key: 'context-key' }),
47+
getInitializationState: () => initializedState,
48+
getInitializationError: () => initError,
49+
onInitializationStatusChange: (cb: (r: { status: string; error?: Error }) => void) => {
50+
initStatusSubs.add(cb);
51+
return () => initStatusSubs.delete(cb);
52+
},
53+
onContextChange: (cb: (c: unknown) => void) => {
54+
contextSubs.add(cb);
55+
return () => contextSubs.delete(cb);
56+
},
57+
isReady: jest.fn(() => ready),
58+
boolVariation: jest.fn(() => boolValue),
59+
stringVariation: jest.fn((_key: string, def: string) => def),
60+
numberVariation: jest.fn((_key: string, def: number) => def),
61+
jsonVariation: jest.fn((_key: string, def: unknown) => def),
62+
boolVariationDetail: jest.fn((_key: string, def: boolean) => notReadyDetail(def)),
63+
stringVariationDetail: jest.fn((_key: string, def: string) => notReadyDetail(def)),
64+
numberVariationDetail: jest.fn((_key: string, def: number) => notReadyDetail(def)),
65+
jsonVariationDetail: jest.fn((_key: string, def: unknown) => notReadyDetail(def)),
66+
allFlags: jest.fn(() => ({})),
67+
variation: jest.fn(),
68+
identify: jest.fn(),
69+
track: jest.fn(),
70+
flush: jest.fn(),
71+
start: jest.fn(),
72+
close: jest.fn(),
73+
on: jest.fn((event: string, h: (...args: unknown[]) => void) => addHandler(event, h)),
74+
off: jest.fn((event: string, h: (...args: unknown[]) => void) => handlers.get(event)?.delete(h)),
75+
};
76+
77+
const controls: MockControls = {
78+
setBool: (v: boolean) => {
79+
boolValue = v;
80+
},
81+
emitChange: (key: string) =>
82+
handlers.get(`change:${key}`)?.forEach((h) => h({ kind: 'user', key: 'context-key' })),
83+
handlerCount: (event: string) => handlers.get(event)?.size ?? 0,
84+
emitInitStatus: (r: { status: string; error?: Error }) => {
85+
ready = true;
86+
initializedState = r.status;
87+
initError = r.error;
88+
initStatusSubs.forEach((cb) => cb(r));
89+
},
90+
emitContextChange: (c: unknown) => contextSubs.forEach((cb) => cb(c)),
91+
subscriberCount: () => initStatusSubs.size + contextSubs.size,
92+
};
93+
94+
return { client: client as unknown as LDVueClient, controls };
95+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { mount } from '@vue/test-utils';
5+
import { defineComponent, h } from 'vue';
6+
7+
import { useLDClient } from '../../../src/client/composables';
8+
import { createLDProvider } from '../../../src/client/provider/LDProvider';
9+
import { makeMockClient } from '../mockClient';
10+
11+
jest.mock('../../../src/client/LDVueClient', () => ({
12+
createClient: jest.fn(),
13+
}));
14+
15+
import { createClient } from '../../../src/client/LDVueClient';
16+
17+
const createClientMock = createClient as jest.Mock;
18+
19+
beforeEach(() => {
20+
createClientMock.mockReset();
21+
});
22+
23+
it('creates a client internally and provides it to children', () => {
24+
const { client } = makeMockClient();
25+
createClientMock.mockReturnValue(client);
26+
27+
let injected: unknown;
28+
const Child = defineComponent({
29+
setup() {
30+
injected = useLDClient();
31+
return () => h('div');
32+
},
33+
});
34+
35+
const Provider = createLDProvider('env-id', { kind: 'user', key: 'k' });
36+
mount(Provider, { slots: { default: () => h(Child) } });
37+
38+
expect(injected).toBe(client);
39+
expect(createClientMock).toHaveBeenCalledWith('env-id', { kind: 'user', key: 'k' }, undefined);
40+
});
41+
42+
it('starts the client automatically by default', () => {
43+
const { client } = makeMockClient();
44+
createClientMock.mockReturnValue(client);
45+
46+
const Provider = createLDProvider('env-id', { kind: 'user', key: 'k' });
47+
mount(Provider, { slots: { default: () => h('div') } });
48+
49+
expect(client.start).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it('does not start the client when deferInitialization is true', () => {
53+
const { client } = makeMockClient();
54+
createClientMock.mockReturnValue(client);
55+
56+
const Provider = createLDProvider('env-id', { kind: 'user', key: 'k' }, { deferInitialization: true });
57+
mount(Provider, { slots: { default: () => h('div') } });
58+
59+
expect(client.start).not.toHaveBeenCalled();
60+
});
61+
62+
it('merges top-level bootstrap into startOptions', () => {
63+
const { client } = makeMockClient();
64+
createClientMock.mockReturnValue(client);
65+
const bootstrap = { 'my-flag': true };
66+
67+
const Provider = createLDProvider('env-id', { kind: 'user', key: 'k' }, { bootstrap });
68+
mount(Provider, { slots: { default: () => h('div') } });
69+
70+
expect(client.start).toHaveBeenCalledWith(expect.objectContaining({ bootstrap }));
71+
});
72+
73+
it('top-level bootstrap takes precedence over startOptions.bootstrap', () => {
74+
const { client } = makeMockClient();
75+
createClientMock.mockReturnValue(client);
76+
const topLevel = { 'my-flag': true };
77+
const startLevel = { 'my-flag': false };
78+
79+
const Provider = createLDProvider(
80+
'env-id',
81+
{ kind: 'user', key: 'k' },
82+
{ bootstrap: topLevel, startOptions: { bootstrap: startLevel } },
83+
);
84+
mount(Provider, { slots: { default: () => h('div') } });
85+
86+
expect(client.start).toHaveBeenCalledWith(expect.objectContaining({ bootstrap: topLevel }));
87+
});
88+
89+
it('passes ldOptions to the client factory', () => {
90+
const { client } = makeMockClient();
91+
createClientMock.mockReturnValue(client);
92+
const ldOptions = { wrapperName: 'my-wrapper' };
93+
94+
const Provider = createLDProvider('env-id', { kind: 'user', key: 'k' }, { ldOptions });
95+
mount(Provider, { slots: { default: () => h('div') } });
96+
97+
expect(createClientMock).toHaveBeenCalledWith('env-id', { kind: 'user', key: 'k' }, ldOptions);
98+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { mount } from '@vue/test-utils';
5+
import { h, nextTick } from 'vue';
6+
7+
import { createLDProviderWithClient } from '../../../src/client/provider/LDProvider';
8+
import { makeMockClient } from '../mockClient';
9+
10+
it('renders the default slot when no gating slots are provided', () => {
11+
const { client } = makeMockClient({ ready: false, initializedState: 'initializing' });
12+
const Provider = createLDProviderWithClient(client);
13+
14+
const wrapper = mount(Provider, { slots: { default: () => h('div', 'app') } });
15+
16+
expect(wrapper.text()).toBe('app');
17+
});
18+
19+
it('renders the #initializing slot while initializing, then the default slot once complete', async () => {
20+
const { client, controls } = makeMockClient({ ready: false, initializedState: 'initializing' });
21+
const Provider = createLDProviderWithClient(client);
22+
23+
const wrapper = mount(Provider, {
24+
slots: {
25+
default: () => h('div', 'app'),
26+
initializing: () => h('div', 'loading'),
27+
},
28+
});
29+
30+
expect(wrapper.text()).toBe('loading');
31+
32+
controls.emitInitStatus({ status: 'complete' });
33+
await nextTick();
34+
35+
expect(wrapper.text()).toBe('app');
36+
});
37+
38+
it('renders the #failed slot with the error when initialization fails', async () => {
39+
const { client, controls } = makeMockClient({ ready: false, initializedState: 'initializing' });
40+
const Provider = createLDProviderWithClient(client);
41+
42+
const wrapper = mount(Provider, {
43+
slots: {
44+
default: () => h('div', 'app'),
45+
failed: (props: { error?: Error }) => h('div', `failed:${props.error?.message}`),
46+
},
47+
});
48+
49+
controls.emitInitStatus({ status: 'failed', error: new Error('bad-key') });
50+
await nextTick();
51+
52+
expect(wrapper.text()).toBe('failed:bad-key');
53+
});
54+
55+
it('mounts without crashing when getContext() throws during setup', () => {
56+
const { client } = makeMockClient();
57+
// @ts-ignore -- simulates a context with a circular reference that clone() would throw on
58+
client.getContext = jest.fn(() => { throw new Error('circular reference'); });
59+
60+
const Provider = createLDProviderWithClient(client);
61+
expect(() => mount(Provider, { slots: { default: () => h('div', 'app') } })).not.toThrow();
62+
});
63+
64+
it('unsubscribes from the client on unmount', () => {
65+
const { client, controls } = makeMockClient();
66+
const Provider = createLDProviderWithClient(client);
67+
68+
const wrapper = mount(Provider, { slots: { default: () => h('div', 'app') } });
69+
expect(controls.subscriberCount()).toBe(2);
70+
71+
wrapper.unmount();
72+
73+
expect(controls.subscriberCount()).toBe(0);
74+
});

0 commit comments

Comments
 (0)