Skip to content

Commit 04d347b

Browse files
authored
feat: Add support for hooks. (#605)
1 parent da31436 commit 04d347b

16 files changed

+1010
-8
lines changed

packages/sdk/browser/__tests__/BrowserDataManager.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
9393
pollInterval: 1000,
9494
userAgentHeaderName: 'user-agent',
9595
trackEventModifier: (event) => event,
96+
hooks: [],
9697
};
9798
const mockedFetch = mockFetch('{"flagA": true}', 200);
9899
platform = {

packages/sdk/browser/src/index.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import {
22
AutoEnvAttributes,
3+
EvaluationSeriesContext,
4+
EvaluationSeriesData,
5+
Hook,
6+
HookMetadata,
7+
IdentifySeriesContext,
8+
IdentifySeriesData,
9+
IdentifySeriesResult,
10+
IdentifySeriesStatus,
311
LDContext,
412
LDContextCommon,
513
LDContextMeta,
@@ -19,7 +27,7 @@ import { BrowserClient, LDClient } from './BrowserClient';
1927
import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions';
2028
import { BrowserOptions as LDOptions } from './options';
2129

22-
export {
30+
export type {
2331
LDClient,
2432
LDFlagSet,
2533
LDContext,
@@ -34,6 +42,14 @@ export {
3442
LDEvaluationDetailTyped,
3543
LDEvaluationReason,
3644
LDIdentifyOptions,
45+
Hook,
46+
HookMetadata,
47+
EvaluationSeriesContext,
48+
EvaluationSeriesData,
49+
IdentifySeriesContext,
50+
IdentifySeriesData,
51+
IdentifySeriesResult,
52+
IdentifySeriesStatus,
3753
};
3854

3955
export function init(clientSideId: string, options?: LDOptions): LDClient {

packages/sdk/react-native/__tests__/MobileDataManager.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ describe('given a MobileDataManager with mocked dependencies', () => {
8181
pollInterval: 1000,
8282
userAgentHeaderName: 'user-agent',
8383
trackEventModifier: (event) => event,
84+
hooks: [],
8485
};
8586
const mockedFetch = mockFetch('{"flagA": true}', 200);
8687
platform = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common';
2+
3+
import { Hook, IdentifySeriesResult } from '../src/api/integrations/Hooks';
4+
import HookRunner from '../src/HookRunner';
5+
6+
describe('given a hook runner and test hook', () => {
7+
let logger: LDLogger;
8+
let testHook: Hook;
9+
let hookRunner: HookRunner;
10+
11+
beforeEach(() => {
12+
logger = {
13+
error: jest.fn(),
14+
warn: jest.fn(),
15+
info: jest.fn(),
16+
debug: jest.fn(),
17+
};
18+
19+
testHook = {
20+
getMetadata: jest.fn().mockReturnValue({ name: 'Test Hook' }),
21+
beforeEvaluation: jest.fn(),
22+
afterEvaluation: jest.fn(),
23+
beforeIdentify: jest.fn(),
24+
afterIdentify: jest.fn(),
25+
};
26+
27+
hookRunner = new HookRunner(logger, [testHook]);
28+
});
29+
30+
describe('when evaluating flags', () => {
31+
it('should execute hooks and return the evaluation result', () => {
32+
const key = 'test-flag';
33+
const context: LDContext = { kind: 'user', key: 'user-123' };
34+
const defaultValue = false;
35+
const evaluationResult: LDEvaluationDetail = {
36+
value: true,
37+
variationIndex: 1,
38+
reason: { kind: 'OFF' },
39+
};
40+
41+
const method = jest.fn().mockReturnValue(evaluationResult);
42+
43+
const result = hookRunner.withEvaluation(key, context, defaultValue, method);
44+
45+
expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
46+
expect.objectContaining({
47+
flagKey: key,
48+
context,
49+
defaultValue,
50+
}),
51+
{},
52+
);
53+
54+
expect(method).toHaveBeenCalled();
55+
56+
expect(testHook.afterEvaluation).toHaveBeenCalledWith(
57+
expect.objectContaining({
58+
flagKey: key,
59+
context,
60+
defaultValue,
61+
}),
62+
{},
63+
evaluationResult,
64+
);
65+
66+
expect(result).toEqual(evaluationResult);
67+
});
68+
69+
it('should handle errors in hooks', () => {
70+
const errorHook: Hook = {
71+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
72+
beforeEvaluation: jest.fn().mockImplementation(() => {
73+
throw new Error('Hook error');
74+
}),
75+
afterEvaluation: jest.fn(),
76+
};
77+
78+
const errorHookRunner = new HookRunner(logger, [errorHook]);
79+
80+
const method = jest
81+
.fn()
82+
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });
83+
84+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
85+
86+
expect(logger.error).toHaveBeenCalledWith(
87+
expect.stringContaining(
88+
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error',
89+
),
90+
);
91+
});
92+
93+
it('should skip hook execution if there are no hooks', () => {
94+
const emptyHookRunner = new HookRunner(logger, []);
95+
const method = jest
96+
.fn()
97+
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });
98+
99+
emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
100+
101+
expect(method).toHaveBeenCalled();
102+
expect(logger.error).not.toHaveBeenCalled();
103+
});
104+
105+
it('should pass evaluation series data from before to after hooks', () => {
106+
const key = 'test-flag';
107+
const context: LDContext = { kind: 'user', key: 'user-123' };
108+
const defaultValue = false;
109+
const evaluationResult: LDEvaluationDetail = {
110+
value: true,
111+
variationIndex: 1,
112+
reason: { kind: 'OFF' },
113+
};
114+
115+
testHook.beforeEvaluation = jest
116+
.fn()
117+
.mockImplementation((_, series) => ({ ...series, testData: 'before data' }));
118+
119+
testHook.afterEvaluation = jest.fn();
120+
121+
const method = jest.fn().mockReturnValue(evaluationResult);
122+
123+
hookRunner.withEvaluation(key, context, defaultValue, method);
124+
125+
expect(testHook.beforeEvaluation).toHaveBeenCalled();
126+
expect(testHook.afterEvaluation).toHaveBeenCalledWith(
127+
expect.anything(),
128+
expect.objectContaining({ testData: 'before data' }),
129+
evaluationResult,
130+
);
131+
});
132+
});
133+
134+
describe('when handling an identification', () => {
135+
it('should execute identify hooks', () => {
136+
const context: LDContext = { kind: 'user', key: 'user-123' };
137+
const timeout = 10;
138+
const identifyResult: IdentifySeriesResult = { status: 'completed' };
139+
140+
const identifyCallback = hookRunner.identify(context, timeout);
141+
identifyCallback(identifyResult);
142+
143+
expect(testHook.beforeIdentify).toHaveBeenCalledWith(
144+
expect.objectContaining({
145+
context,
146+
timeout,
147+
}),
148+
{},
149+
);
150+
151+
expect(testHook.afterIdentify).toHaveBeenCalledWith(
152+
expect.objectContaining({
153+
context,
154+
timeout,
155+
}),
156+
{},
157+
identifyResult,
158+
);
159+
});
160+
161+
it('should handle errors in identify hooks', () => {
162+
const errorHook: Hook = {
163+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
164+
beforeIdentify: jest.fn().mockImplementation(() => {
165+
throw new Error('Hook error');
166+
}),
167+
afterIdentify: jest.fn(),
168+
};
169+
170+
const errorHookRunner = new HookRunner(logger, [errorHook]);
171+
172+
const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-123' }, 1000);
173+
identifyCallback({ status: 'error' });
174+
175+
expect(logger.error).toHaveBeenCalledWith(
176+
expect.stringContaining(
177+
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error',
178+
),
179+
);
180+
});
181+
182+
it('should pass identify series data from before to after hooks', () => {
183+
const context: LDContext = { kind: 'user', key: 'user-123' };
184+
const timeout = 10;
185+
const identifyResult: IdentifySeriesResult = { status: 'completed' };
186+
187+
testHook.beforeIdentify = jest
188+
.fn()
189+
.mockImplementation((_, series) => ({ ...series, testData: 'before identify data' }));
190+
191+
testHook.afterIdentify = jest.fn();
192+
193+
const identifyCallback = hookRunner.identify(context, timeout);
194+
identifyCallback(identifyResult);
195+
196+
expect(testHook.beforeIdentify).toHaveBeenCalled();
197+
expect(testHook.afterIdentify).toHaveBeenCalledWith(
198+
expect.anything(),
199+
expect.objectContaining({ testData: 'before identify data' }),
200+
identifyResult,
201+
);
202+
});
203+
});
204+
205+
it('should use the added hook in future invocations', () => {
206+
const newHook: Hook = {
207+
getMetadata: jest.fn().mockReturnValue({ name: 'New Hook' }),
208+
beforeEvaluation: jest.fn(),
209+
afterEvaluation: jest.fn(),
210+
};
211+
212+
hookRunner.addHook(newHook);
213+
214+
const method = jest
215+
.fn()
216+
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });
217+
218+
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
219+
220+
expect(newHook.beforeEvaluation).toHaveBeenCalled();
221+
expect(newHook.afterEvaluation).toHaveBeenCalled();
222+
});
223+
224+
it('should log "unknown hook" when getMetadata throws an error', () => {
225+
const errorHook: Hook = {
226+
getMetadata: jest.fn().mockImplementation(() => {
227+
throw new Error('Metadata error');
228+
}),
229+
beforeEvaluation: jest.fn().mockImplementation(() => {
230+
throw new Error('Test error in beforeEvaluation');
231+
}),
232+
afterEvaluation: jest.fn(),
233+
};
234+
235+
const errorHookRunner = new HookRunner(logger, [errorHook]);
236+
237+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
238+
value: true,
239+
variationIndex: 1,
240+
reason: { kind: 'OFF' },
241+
}));
242+
243+
expect(logger.error).toHaveBeenCalledWith(
244+
'Exception thrown getting metadata for hook. Unable to get hook name.',
245+
);
246+
247+
// Verify that the error was logged with the correct hook name
248+
expect(logger.error).toHaveBeenCalledWith(
249+
expect.stringContaining(
250+
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation',
251+
),
252+
);
253+
});
254+
255+
it('should log "unknown hook" when getMetadata returns an empty name', () => {
256+
const errorHook: Hook = {
257+
getMetadata: jest.fn().mockImplementation(() => ({
258+
name: '',
259+
})),
260+
beforeEvaluation: jest.fn().mockImplementation(() => {
261+
throw new Error('Test error in beforeEvaluation');
262+
}),
263+
afterEvaluation: jest.fn(),
264+
};
265+
266+
const errorHookRunner = new HookRunner(logger, [errorHook]);
267+
268+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
269+
value: true,
270+
variationIndex: 1,
271+
reason: { kind: 'OFF' },
272+
}));
273+
274+
// Verify that the error was logged with the correct hook name
275+
expect(logger.error).toHaveBeenCalledWith(
276+
expect.stringContaining(
277+
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation',
278+
),
279+
);
280+
});
281+
282+
it('should log the correct hook name when an error occurs', () => {
283+
// Modify the testHook to throw an error in beforeEvaluation
284+
testHook.beforeEvaluation = jest.fn().mockImplementation(() => {
285+
throw new Error('Test error in beforeEvaluation');
286+
});
287+
288+
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
289+
value: true,
290+
variationIndex: 1,
291+
reason: { kind: 'OFF' },
292+
}));
293+
294+
// Verify that getMetadata was called to get the hook name
295+
expect(testHook.getMetadata).toHaveBeenCalled();
296+
297+
// Verify that the error was logged with the correct hook name
298+
expect(logger.error).toHaveBeenCalledWith(
299+
expect.stringContaining(
300+
'An error was encountered in "beforeEvaluation" of the "Test Hook" hook: Error: Test error in beforeEvaluation',
301+
),
302+
);
303+
});
304+
});

0 commit comments

Comments
 (0)