launchdarkly-js-sdk-common 5.5.0-beta.3 → 5.5.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/EventProcessor.js +5 -5
- package/src/HookRunner.js +113 -19
- package/src/__tests__/EventProcessor-test.js +47 -11
- package/src/__tests__/HookRunner-test.js +331 -0
- package/src/__tests__/LDClient-hooks-test.js +228 -0
- package/src/__tests__/utils-test.js +31 -1
- package/src/index.js +4 -4
- package/src/utils.js +21 -0
- package/typings.d.ts +3 -3
package/package.json
CHANGED
package/src/EventProcessor.js
CHANGED
|
@@ -47,16 +47,16 @@ function EventProcessor(
|
|
|
47
47
|
// Transform an event from its internal format to the format we use when sending a payload.
|
|
48
48
|
function makeOutputEvent(e) {
|
|
49
49
|
const ret = utils.extend({}, e);
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
|
|
51
|
+
// Identify, feature, and custom events should all include the full context.
|
|
52
|
+
// Debug events do as well, but are not handled by this code path.
|
|
53
|
+
if (e.kind === 'identify' || e.kind === 'feature' || e.kind === 'custom') {
|
|
52
54
|
ret.context = contextFilter.filter(e.context);
|
|
53
|
-
} else if (e.kind === 'feature') {
|
|
54
|
-
// feature events always have an inline context
|
|
55
|
-
ret.context = contextFilter.filter(e.context, true);
|
|
56
55
|
} else {
|
|
57
56
|
ret.contextKeys = getContextKeysFromEvent(e);
|
|
58
57
|
delete ret['context'];
|
|
59
58
|
}
|
|
59
|
+
|
|
60
60
|
if (e.kind === 'feature') {
|
|
61
61
|
delete ret['trackEvents'];
|
|
62
62
|
delete ret['debugEventsUntilDate'];
|
package/src/HookRunner.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
const UNKNOWN_HOOK_NAME = 'unknown hook';
|
|
2
2
|
const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
|
|
3
3
|
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
|
|
4
|
+
const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
|
|
5
|
+
const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Safely executes a hook stage function, logging any errors.
|
|
9
|
+
* @param {{ error: (message: string) => void } | undefined} logger The logger instance.
|
|
10
|
+
* @param {string} method The name of the hook stage being executed (e.g., 'beforeEvaluation').
|
|
11
|
+
* @param {string} hookName The name of the hook.
|
|
12
|
+
* @param {() => any} stage The function representing the hook stage to execute.
|
|
13
|
+
* @param {any} def The default value to return if the stage function throws an error.
|
|
14
|
+
* @returns {any} The result of the stage function, or the default value if an error occurred.
|
|
15
|
+
*/
|
|
5
16
|
function tryExecuteStage(logger, method, hookName, stage, def) {
|
|
6
17
|
try {
|
|
7
18
|
return stage();
|
|
@@ -11,6 +22,12 @@ function tryExecuteStage(logger, method, hookName, stage, def) {
|
|
|
11
22
|
}
|
|
12
23
|
}
|
|
13
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Safely gets the name of a hook from its metadata.
|
|
27
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
28
|
+
* @param {{ getMetadata: () => { name?: string } }} hook The hook instance.
|
|
29
|
+
* @returns {string} The name of the hook, or 'unknown hook' if unable to retrieve it.
|
|
30
|
+
*/
|
|
14
31
|
function getHookName(logger, hook) {
|
|
15
32
|
try {
|
|
16
33
|
return hook.getMetadata().name || UNKNOWN_HOOK_NAME;
|
|
@@ -20,6 +37,13 @@ function getHookName(logger, hook) {
|
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Executes the 'beforeEvaluation' stage for all registered hooks.
|
|
42
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
43
|
+
* @param {Array<{ beforeEvaluation?: (hookContext: object, data: object) => object }>} hooks The array of hook instances.
|
|
44
|
+
* @param {{ flagKey: string, context: object, defaultValue: any }} hookContext The context for the evaluation series.
|
|
45
|
+
* @returns {Array<object>} An array containing the data returned by each hook's 'beforeEvaluation' stage.
|
|
46
|
+
*/
|
|
23
47
|
function executeBeforeEvaluation(logger, hooks, hookContext) {
|
|
24
48
|
return hooks.map(hook =>
|
|
25
49
|
tryExecuteStage(
|
|
@@ -32,6 +56,15 @@ function executeBeforeEvaluation(logger, hooks, hookContext) {
|
|
|
32
56
|
);
|
|
33
57
|
}
|
|
34
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Executes the 'afterEvaluation' stage for all registered hooks in reverse order.
|
|
61
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
62
|
+
* @param {Array<{ afterEvaluation?: (hookContext: object, data: object, result: object) => object }>} hooks The array of hook instances.
|
|
63
|
+
* @param {{ flagKey: string, context: object, defaultValue: any }} hookContext The context for the evaluation series.
|
|
64
|
+
* @param {Array<object>} updatedData The data collected from the 'beforeEvaluation' stages.
|
|
65
|
+
* @param {{ value: any, variationIndex?: number, reason?: object }} result The result of the flag evaluation.
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
35
68
|
function executeAfterEvaluation(logger, hooks, hookContext, updatedData, result) {
|
|
36
69
|
// This iterates in reverse, versus reversing a shallow copy of the hooks,
|
|
37
70
|
// for efficiency.
|
|
@@ -48,11 +81,18 @@ function executeAfterEvaluation(logger, hooks, hookContext, updatedData, result)
|
|
|
48
81
|
}
|
|
49
82
|
}
|
|
50
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Executes the 'beforeIdentify' stage for all registered hooks.
|
|
86
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
87
|
+
* @param {Array<{ beforeIdentify?: (hookContext: object, data: object) => object }>} hooks The array of hook instances.
|
|
88
|
+
* @param {{ context: object, timeout?: number }} hookContext The context for the identify series.
|
|
89
|
+
* @returns {Array<object>} An array containing the data returned by each hook's 'beforeIdentify' stage.
|
|
90
|
+
*/
|
|
51
91
|
function executeBeforeIdentify(logger, hooks, hookContext) {
|
|
52
92
|
return hooks.map(hook =>
|
|
53
93
|
tryExecuteStage(
|
|
54
94
|
logger,
|
|
55
|
-
|
|
95
|
+
BEFORE_IDENTIFY_STAGE_NAME,
|
|
56
96
|
getHookName(logger, hook),
|
|
57
97
|
() => hook?.beforeIdentify?.(hookContext, {}) ?? {},
|
|
58
98
|
{}
|
|
@@ -60,6 +100,15 @@ function executeBeforeIdentify(logger, hooks, hookContext) {
|
|
|
60
100
|
);
|
|
61
101
|
}
|
|
62
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Executes the 'afterIdentify' stage for all registered hooks in reverse order.
|
|
105
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
106
|
+
* @param {Array<{ afterIdentify?: (hookContext: object, data: object, result: object) => object }>} hooks The array of hook instances.
|
|
107
|
+
* @param {{ context: object, timeout?: number }} hookContext The context for the identify series.
|
|
108
|
+
* @param {Array<object>} updatedData The data collected from the 'beforeIdentify' stages.
|
|
109
|
+
* @param {{ status: string }} result The result of the identify operation.
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
63
112
|
function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
|
|
64
113
|
// This iterates in reverse, versus reversing a shallow copy of the hooks,
|
|
65
114
|
// for efficiency.
|
|
@@ -68,7 +117,7 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
|
|
|
68
117
|
const data = updatedData[hookIndex];
|
|
69
118
|
tryExecuteStage(
|
|
70
119
|
logger,
|
|
71
|
-
|
|
120
|
+
AFTER_IDENTIFY_STAGE_NAME,
|
|
72
121
|
getHookName(logger, hook),
|
|
73
122
|
() => hook?.afterIdentify?.(hookContext, data, result) ?? {},
|
|
74
123
|
{}
|
|
@@ -76,44 +125,89 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
|
|
|
76
125
|
}
|
|
77
126
|
}
|
|
78
127
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Factory function to create a HookRunner instance.
|
|
130
|
+
* Manages the execution of hooks for flag evaluations and identify operations.
|
|
131
|
+
* @param {{ error: (message: string) => void }} logger The logger instance.
|
|
132
|
+
* @param {Array<object> | undefined} initialHooks An optional array of hooks to initialize with.
|
|
133
|
+
* @returns {{
|
|
134
|
+
* withEvaluation: (key: string, context: object, defaultValue: any, method: () => { value: any, variationIndex?: number, reason?: object }) => { value: any, variationIndex?: number, reason?: object },
|
|
135
|
+
* identify: (context: object, timeout?: number) => (result: { status: string }) => void,
|
|
136
|
+
* addHook: (hook: object) => void
|
|
137
|
+
* }} The hook runner object with methods to manage and execute hooks.
|
|
138
|
+
*/
|
|
139
|
+
function createHookRunner(logger, initialHooks) {
|
|
140
|
+
// Use local variable instead of instance property
|
|
141
|
+
const hooksInternal = initialHooks ? [...initialHooks] : [];
|
|
84
142
|
|
|
85
|
-
|
|
86
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Wraps a flag evaluation method with before/after hook stages.
|
|
145
|
+
* @param {string} key The flag key.
|
|
146
|
+
* @param {object} context The evaluation context.
|
|
147
|
+
* @param {any} defaultValue The default value for the flag.
|
|
148
|
+
* @param {() => { value: any, variationIndex?: number, reason?: object }} method The function that performs the actual flag evaluation.
|
|
149
|
+
* @returns {{ value: any, variationIndex?: number, reason?: object }} The result of the flag evaluation.
|
|
150
|
+
*/
|
|
151
|
+
function withEvaluation(key, context, defaultValue, method) {
|
|
152
|
+
if (hooksInternal.length === 0) {
|
|
87
153
|
return method();
|
|
88
154
|
}
|
|
89
|
-
const hooks = [...
|
|
155
|
+
const hooks = [...hooksInternal];
|
|
156
|
+
/** @type {{ flagKey: string, context: object, defaultValue: any }} */
|
|
90
157
|
const hookContext = {
|
|
91
158
|
flagKey: key,
|
|
92
159
|
context,
|
|
93
160
|
defaultValue,
|
|
94
161
|
};
|
|
95
162
|
|
|
96
|
-
|
|
163
|
+
// Use the logger passed into the factory
|
|
164
|
+
const hookData = executeBeforeEvaluation(logger, hooks, hookContext);
|
|
97
165
|
const result = method();
|
|
98
|
-
executeAfterEvaluation(
|
|
166
|
+
executeAfterEvaluation(logger, hooks, hookContext, hookData, result);
|
|
99
167
|
return result;
|
|
100
168
|
}
|
|
101
169
|
|
|
102
|
-
|
|
103
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Wraps the identify operation with before/after hook stages.
|
|
172
|
+
* Executes the 'beforeIdentify' stage immediately and returns a function
|
|
173
|
+
* to execute the 'afterIdentify' stage later.
|
|
174
|
+
* @param {object} context The context being identified.
|
|
175
|
+
* @param {number | undefined} timeout Optional timeout for the identify operation.
|
|
176
|
+
* @returns {(result: { status: string }) => void} A function to call after the identify operation completes.
|
|
177
|
+
*/
|
|
178
|
+
function identify(context, timeout) {
|
|
179
|
+
const hooks = [...hooksInternal];
|
|
180
|
+
/** @type {{ context: object, timeout?: number }} */
|
|
104
181
|
const hookContext = {
|
|
105
182
|
context,
|
|
106
183
|
timeout,
|
|
107
184
|
};
|
|
108
|
-
|
|
185
|
+
// Use the logger passed into the factory
|
|
186
|
+
const hookData = executeBeforeIdentify(logger, hooks, hookContext);
|
|
187
|
+
/**
|
|
188
|
+
* Executes the 'afterIdentify' hook stage.
|
|
189
|
+
* @param {{ status: string }} result The result of the identify operation.
|
|
190
|
+
*/
|
|
109
191
|
return result => {
|
|
110
|
-
executeAfterIdentify(
|
|
192
|
+
executeAfterIdentify(logger, hooks, hookContext, hookData, result);
|
|
111
193
|
};
|
|
112
194
|
}
|
|
113
195
|
|
|
114
|
-
|
|
115
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Adds a new hook to the runner.
|
|
198
|
+
* @param {object} hook The hook instance to add.
|
|
199
|
+
* @returns {void}
|
|
200
|
+
*/
|
|
201
|
+
function addHook(hook) {
|
|
202
|
+
// Mutate the internal hooks array
|
|
203
|
+
hooksInternal.push(hook);
|
|
116
204
|
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
withEvaluation,
|
|
208
|
+
identify,
|
|
209
|
+
addHook,
|
|
210
|
+
};
|
|
117
211
|
}
|
|
118
212
|
|
|
119
|
-
module.exports =
|
|
213
|
+
module.exports = createHookRunner;
|
|
@@ -59,16 +59,6 @@ describe.each([
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function checkUserInline(e, source, inlineUser) {
|
|
63
|
-
if (inlineUser) {
|
|
64
|
-
expect(e.context).toEqual(inlineUser);
|
|
65
|
-
expect(e.contextKeys).toBeUndefined();
|
|
66
|
-
} else {
|
|
67
|
-
expect(e.contextKeys).toEqual({ user: source.context.key || source.context.user.key });
|
|
68
|
-
expect(e.context).toBeUndefined();
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
function checkFeatureEvent(e, source, debug, inlineUser) {
|
|
73
63
|
expect(e.kind).toEqual(debug ? 'debug' : 'feature');
|
|
74
64
|
expect(e.creationDate).toEqual(source.creationDate);
|
|
@@ -87,7 +77,7 @@ describe.each([
|
|
|
87
77
|
expect(e.key).toEqual(source.key);
|
|
88
78
|
expect(e.data).toEqual(source.data);
|
|
89
79
|
expect(e.metricValue).toEqual(source.metricValue);
|
|
90
|
-
|
|
80
|
+
expect(e.context).toEqual(source.context);
|
|
91
81
|
}
|
|
92
82
|
|
|
93
83
|
function checkSummaryEvent(e) {
|
|
@@ -219,6 +209,31 @@ describe.each([
|
|
|
219
209
|
});
|
|
220
210
|
});
|
|
221
211
|
|
|
212
|
+
it('filters context in feature event', async () => {
|
|
213
|
+
const config = { ...defaultConfig, allAttributesPrivate: true };
|
|
214
|
+
await withProcessorAndSender(config, async (ep, mockEventSender) => {
|
|
215
|
+
const e = {
|
|
216
|
+
kind: 'feature',
|
|
217
|
+
creationDate: 1000,
|
|
218
|
+
context: eventContext,
|
|
219
|
+
key: 'flagkey',
|
|
220
|
+
version: 11,
|
|
221
|
+
variation: 1,
|
|
222
|
+
value: 'value',
|
|
223
|
+
default: 'default',
|
|
224
|
+
trackEvents: true,
|
|
225
|
+
};
|
|
226
|
+
ep.enqueue(e);
|
|
227
|
+
await ep.flush();
|
|
228
|
+
|
|
229
|
+
expect(mockEventSender.calls.length()).toEqual(1);
|
|
230
|
+
const output = (await mockEventSender.calls.take()).events;
|
|
231
|
+
expect(output.length).toEqual(2);
|
|
232
|
+
checkFeatureEvent(output[0], e, false, filteredContext);
|
|
233
|
+
checkSummaryEvent(output[1]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
222
237
|
it('can both track and debug an event', async () => {
|
|
223
238
|
await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => {
|
|
224
239
|
const futureTime = new Date().getTime() + 1000000;
|
|
@@ -380,6 +395,27 @@ describe.each([
|
|
|
380
395
|
});
|
|
381
396
|
});
|
|
382
397
|
|
|
398
|
+
it('filters context in custom event', async () => {
|
|
399
|
+
const config = { ...defaultConfig, allAttributesPrivate: true };
|
|
400
|
+
await withProcessorAndSender(config, async (ep, mockEventSender) => {
|
|
401
|
+
const e = {
|
|
402
|
+
kind: 'custom',
|
|
403
|
+
creationDate: 1000,
|
|
404
|
+
context: eventContext,
|
|
405
|
+
key: 'eventkey',
|
|
406
|
+
data: { thing: 'stuff' },
|
|
407
|
+
metricValue: 1.5,
|
|
408
|
+
};
|
|
409
|
+
ep.enqueue(e);
|
|
410
|
+
await ep.flush();
|
|
411
|
+
|
|
412
|
+
expect(mockEventSender.calls.length()).toEqual(1);
|
|
413
|
+
const output = (await mockEventSender.calls.take()).events;
|
|
414
|
+
expect(output.length).toEqual(1);
|
|
415
|
+
checkCustomEvent(output[0], { ...e, context: filteredContext });
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
383
419
|
it('enforces event capacity', async () => {
|
|
384
420
|
const config = { ...defaultConfig, eventCapacity: 1, logger: stubPlatform.logger() };
|
|
385
421
|
const e0 = { kind: 'custom', creationDate: 1000, context: eventContext, key: 'key0' };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// The HookRunner factory function under test
|
|
2
|
+
const createHookRunner = require('../HookRunner');
|
|
3
|
+
|
|
4
|
+
// Mock the logger functions we expect to be called
|
|
5
|
+
const mockLogger = () => ({
|
|
6
|
+
error: jest.fn(),
|
|
7
|
+
warn: jest.fn(),
|
|
8
|
+
info: jest.fn(),
|
|
9
|
+
debug: jest.fn(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Define a basic Hook structure for tests
|
|
13
|
+
const createTestHook = (name = 'Test Hook') => ({
|
|
14
|
+
getMetadata: jest.fn().mockReturnValue({ name }),
|
|
15
|
+
beforeEvaluation: jest.fn(),
|
|
16
|
+
afterEvaluation: jest.fn(),
|
|
17
|
+
beforeIdentify: jest.fn(),
|
|
18
|
+
afterIdentify: jest.fn(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Given a logger, runner, and hook', () => {
|
|
22
|
+
let logger;
|
|
23
|
+
let testHook;
|
|
24
|
+
let hookRunner;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
// Reset mocks and create fresh instances for each test
|
|
28
|
+
logger = mockLogger();
|
|
29
|
+
testHook = createTestHook();
|
|
30
|
+
// Initialize the runner with the test hook
|
|
31
|
+
hookRunner = createHookRunner(logger, [testHook]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('evaluation: should execute hooks and return the evaluation result', () => {
|
|
35
|
+
const key = 'test-flag';
|
|
36
|
+
const context = { kind: 'user', key: 'user-123' };
|
|
37
|
+
const defaultValue = false;
|
|
38
|
+
const evaluationResult = {
|
|
39
|
+
value: true,
|
|
40
|
+
variationIndex: 1,
|
|
41
|
+
reason: { kind: 'OFF' },
|
|
42
|
+
};
|
|
43
|
+
// Mock the core evaluation method
|
|
44
|
+
const method = jest.fn().mockReturnValue(evaluationResult);
|
|
45
|
+
|
|
46
|
+
// The data expected to be passed between stages initially is empty
|
|
47
|
+
const initialData = {};
|
|
48
|
+
|
|
49
|
+
const result = hookRunner.withEvaluation(key, context, defaultValue, method);
|
|
50
|
+
|
|
51
|
+
// Check if beforeEvaluation was called correctly
|
|
52
|
+
expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
|
|
53
|
+
expect.objectContaining({
|
|
54
|
+
flagKey: key,
|
|
55
|
+
context,
|
|
56
|
+
defaultValue,
|
|
57
|
+
}),
|
|
58
|
+
initialData // Initial data passed to beforeEvaluation
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Check if the original evaluation method was called
|
|
62
|
+
expect(method).toHaveBeenCalled();
|
|
63
|
+
|
|
64
|
+
// Check if afterEvaluation was called correctly
|
|
65
|
+
expect(testHook.afterEvaluation).toHaveBeenCalledWith(
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
flagKey: key,
|
|
68
|
+
context,
|
|
69
|
+
defaultValue,
|
|
70
|
+
}),
|
|
71
|
+
initialData, // Data returned from (mocked) beforeEvaluation
|
|
72
|
+
evaluationResult
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Verify the final result matches the evaluation result
|
|
76
|
+
expect(result).toEqual(evaluationResult);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('evaluation: should handle errors in beforeEvaluation hook', () => {
|
|
80
|
+
const errorHook = createTestHook('Error Hook');
|
|
81
|
+
const testError = new Error('Hook error in before');
|
|
82
|
+
errorHook.beforeEvaluation.mockImplementation(() => {
|
|
83
|
+
throw testError;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const errorHookRunner = createHookRunner(logger, [errorHook]);
|
|
87
|
+
const method = jest.fn().mockReturnValue({ value: 'default', reason: { kind: 'ERROR' } });
|
|
88
|
+
const initialData = {}; // Data returned by the failing hook (default)
|
|
89
|
+
|
|
90
|
+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
|
|
91
|
+
|
|
92
|
+
// Error should be logged
|
|
93
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
94
|
+
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error in before'
|
|
95
|
+
);
|
|
96
|
+
// Method should still be called
|
|
97
|
+
expect(method).toHaveBeenCalled();
|
|
98
|
+
// After evaluation should still be called, passing the default data ({}) because before failed
|
|
99
|
+
expect(errorHook.afterEvaluation).toHaveBeenCalledWith(expect.anything(), initialData, expect.anything());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('evaluation: should handle errors in afterEvaluation hook', () => {
|
|
103
|
+
const errorHook = createTestHook('Error Hook');
|
|
104
|
+
const testError = new Error('Hook error in after');
|
|
105
|
+
errorHook.afterEvaluation.mockImplementation(() => {
|
|
106
|
+
throw testError;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const errorHookRunner = createHookRunner(logger, [errorHook]);
|
|
110
|
+
const method = jest.fn().mockReturnValue({ value: 'default', reason: { kind: 'FALLTHROUGH' } });
|
|
111
|
+
|
|
112
|
+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
|
|
113
|
+
|
|
114
|
+
// Before evaluation should be called normally
|
|
115
|
+
expect(errorHook.beforeEvaluation).toHaveBeenCalled();
|
|
116
|
+
// Method should be called normally
|
|
117
|
+
expect(method).toHaveBeenCalled();
|
|
118
|
+
// Error should be logged for afterEvaluation
|
|
119
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
120
|
+
'An error was encountered in "afterEvaluation" of the "Error Hook" hook: Error: Hook error in after'
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('evaluation: should skip hook execution if no hooks are provided', () => {
|
|
125
|
+
const emptyHookRunner = createHookRunner(logger, []); // No initial hooks
|
|
126
|
+
const method = jest.fn().mockReturnValue({ value: true });
|
|
127
|
+
|
|
128
|
+
emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
|
|
129
|
+
|
|
130
|
+
// Only the method should be called
|
|
131
|
+
expect(method).toHaveBeenCalled();
|
|
132
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
133
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('evaluation: should pass data from beforeEvaluation to afterEvaluation', () => {
|
|
137
|
+
const key = 'test-flag';
|
|
138
|
+
const context = { kind: 'user', key: 'user-123' };
|
|
139
|
+
const defaultValue = false;
|
|
140
|
+
const evaluationResult = { value: true };
|
|
141
|
+
const seriesData = { testData: 'before data' };
|
|
142
|
+
|
|
143
|
+
// Mock beforeEvaluation to return specific data
|
|
144
|
+
testHook.beforeEvaluation.mockReturnValue(seriesData);
|
|
145
|
+
const method = jest.fn().mockReturnValue(evaluationResult);
|
|
146
|
+
|
|
147
|
+
hookRunner.withEvaluation(key, context, defaultValue, method);
|
|
148
|
+
|
|
149
|
+
expect(testHook.beforeEvaluation).toHaveBeenCalled();
|
|
150
|
+
// afterEvaluation should receive the data returned by beforeEvaluation
|
|
151
|
+
expect(testHook.afterEvaluation).toHaveBeenCalledWith(
|
|
152
|
+
expect.anything(),
|
|
153
|
+
expect.objectContaining(seriesData), // Check if the passed data includes seriesData
|
|
154
|
+
evaluationResult
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('identify: should execute identify hooks', () => {
|
|
159
|
+
const context = { kind: 'user', key: 'user-123' };
|
|
160
|
+
const timeout = 10;
|
|
161
|
+
const identifyResult = { status: 'completed' }; // Example result structure
|
|
162
|
+
const initialData = {};
|
|
163
|
+
|
|
164
|
+
// Call identify to get the callback
|
|
165
|
+
const identifyCallback = hookRunner.identify(context, timeout);
|
|
166
|
+
|
|
167
|
+
// Check if beforeIdentify was called immediately
|
|
168
|
+
expect(testHook.beforeIdentify).toHaveBeenCalledWith(
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
context,
|
|
171
|
+
timeout,
|
|
172
|
+
}),
|
|
173
|
+
initialData // Initial data passed to beforeIdentify
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Now invoke the callback returned by identify
|
|
177
|
+
identifyCallback(identifyResult);
|
|
178
|
+
|
|
179
|
+
// Check if afterIdentify was called with the correct arguments
|
|
180
|
+
expect(testHook.afterIdentify).toHaveBeenCalledWith(
|
|
181
|
+
expect.objectContaining({
|
|
182
|
+
context,
|
|
183
|
+
timeout,
|
|
184
|
+
}),
|
|
185
|
+
initialData, // Data returned from (mocked) beforeIdentify
|
|
186
|
+
identifyResult
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('identify: should handle errors in beforeIdentify hook', () => {
|
|
191
|
+
const errorHook = createTestHook('Error Hook');
|
|
192
|
+
const testError = new Error('Hook error in before identify');
|
|
193
|
+
errorHook.beforeIdentify.mockImplementation(() => {
|
|
194
|
+
throw testError;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const errorHookRunner = createHookRunner(logger, [errorHook]);
|
|
198
|
+
const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-456' }, 1000);
|
|
199
|
+
|
|
200
|
+
// Error should be logged immediately from beforeIdentify
|
|
201
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
202
|
+
'An error was encountered in "beforeIdentify" of the "Error Hook" hook: Error: Hook error in before identify'
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Execute the callback - afterIdentify should still be called
|
|
206
|
+
identifyCallback({ status: 'error' }); // Example result
|
|
207
|
+
|
|
208
|
+
// Check afterIdentify was called, receiving default data {}
|
|
209
|
+
expect(errorHook.afterIdentify).toHaveBeenCalledWith(expect.anything(), {}, expect.anything());
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('identify: should handle errors in afterIdentify hook', () => {
|
|
213
|
+
const errorHook = createTestHook('Error Hook');
|
|
214
|
+
const testError = new Error('Hook error in after identify');
|
|
215
|
+
errorHook.afterIdentify.mockImplementation(() => {
|
|
216
|
+
throw testError;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const errorHookRunner = createHookRunner(logger, [errorHook]);
|
|
220
|
+
const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-456' }, 1000);
|
|
221
|
+
|
|
222
|
+
// Before should run fine
|
|
223
|
+
expect(errorHook.beforeIdentify).toHaveBeenCalled();
|
|
224
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
225
|
+
|
|
226
|
+
// Execute the callback - this should trigger the error in afterIdentify
|
|
227
|
+
identifyCallback({ status: 'completed' }); // Example result
|
|
228
|
+
|
|
229
|
+
// Error should be logged from afterIdentify
|
|
230
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
231
|
+
'An error was encountered in "afterIdentify" of the "Error Hook" hook: Error: Hook error in after identify'
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('identify: should pass data from beforeIdentify to afterIdentify', () => {
|
|
236
|
+
const context = { kind: 'user', key: 'user-789' };
|
|
237
|
+
const timeout = 50;
|
|
238
|
+
const identifyResult = { status: 'completed' };
|
|
239
|
+
const seriesData = { testData: 'before identify data' };
|
|
240
|
+
|
|
241
|
+
// Mock beforeIdentify to return specific data
|
|
242
|
+
testHook.beforeIdentify.mockReturnValue(seriesData);
|
|
243
|
+
|
|
244
|
+
const identifyCallback = hookRunner.identify(context, timeout);
|
|
245
|
+
identifyCallback(identifyResult);
|
|
246
|
+
|
|
247
|
+
expect(testHook.beforeIdentify).toHaveBeenCalled();
|
|
248
|
+
// afterIdentify should receive the data returned by beforeIdentify
|
|
249
|
+
expect(testHook.afterIdentify).toHaveBeenCalledWith(
|
|
250
|
+
expect.anything(),
|
|
251
|
+
expect.objectContaining(seriesData), // Check if the passed data includes seriesData
|
|
252
|
+
identifyResult
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('addHook: should use the added hook in future invocations', () => {
|
|
257
|
+
const newHook = createTestHook('New Hook');
|
|
258
|
+
hookRunner.addHook(newHook);
|
|
259
|
+
|
|
260
|
+
const method = jest.fn().mockReturnValue({ value: true });
|
|
261
|
+
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
|
|
262
|
+
|
|
263
|
+
// Both the original and the new hook should have been called
|
|
264
|
+
expect(testHook.beforeEvaluation).toHaveBeenCalled();
|
|
265
|
+
expect(testHook.afterEvaluation).toHaveBeenCalled();
|
|
266
|
+
expect(newHook.beforeEvaluation).toHaveBeenCalled();
|
|
267
|
+
expect(newHook.afterEvaluation).toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('error handling: should log "unknown hook" when getMetadata throws', () => {
|
|
271
|
+
const errorMetadataHook = {
|
|
272
|
+
getMetadata: jest.fn().mockImplementation(() => {
|
|
273
|
+
throw new Error('Metadata error');
|
|
274
|
+
}),
|
|
275
|
+
beforeEvaluation: jest.fn().mockImplementation(() => {
|
|
276
|
+
throw new Error('Eval error'); // Add an error here to trigger logging
|
|
277
|
+
}),
|
|
278
|
+
afterEvaluation: jest.fn(),
|
|
279
|
+
// Add other methods if needed for completeness, mocked simply
|
|
280
|
+
beforeIdentify: jest.fn(),
|
|
281
|
+
afterIdentify: jest.fn(),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const errorRunner = createHookRunner(logger, [errorMetadataHook]);
|
|
285
|
+
errorRunner.withEvaluation('flag', {}, false, () => ({ value: null }));
|
|
286
|
+
|
|
287
|
+
// First error: getting metadata
|
|
288
|
+
expect(logger.error).toHaveBeenCalledWith('Exception thrown getting metadata for hook. Unable to get hook name.');
|
|
289
|
+
// Second error: executing the stage with 'unknown hook' name
|
|
290
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
291
|
+
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Eval error'
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('error handling: should log "unknown hook" when getMetadata returns empty name', () => {
|
|
296
|
+
const emptyNameHook = createTestHook(''); // Create hook with empty name
|
|
297
|
+
emptyNameHook.beforeEvaluation.mockImplementation(() => {
|
|
298
|
+
throw new Error('Eval error'); // Add an error here to trigger logging
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const errorRunner = createHookRunner(logger, [emptyNameHook]);
|
|
302
|
+
errorRunner.withEvaluation('flag', {}, false, () => ({ value: null }));
|
|
303
|
+
|
|
304
|
+
// Verify getMetadata was called (even though name is empty)
|
|
305
|
+
expect(emptyNameHook.getMetadata).toHaveBeenCalled();
|
|
306
|
+
|
|
307
|
+
// Verify the error uses 'unknown hook'
|
|
308
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
309
|
+
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Eval error'
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('error handling: should log the correct hook name when an error occurs', () => {
|
|
314
|
+
const hookName = 'Specific Error Hook';
|
|
315
|
+
const errorHook = createTestHook(hookName);
|
|
316
|
+
const testError = new Error('Specific test error');
|
|
317
|
+
errorHook.beforeEvaluation.mockImplementation(() => {
|
|
318
|
+
throw testError;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const specificRunner = createHookRunner(logger, [errorHook]);
|
|
322
|
+
specificRunner.withEvaluation('flag', {}, false, () => ({ value: null }));
|
|
323
|
+
|
|
324
|
+
// Verify getMetadata was called
|
|
325
|
+
expect(errorHook.getMetadata).toHaveBeenCalled();
|
|
326
|
+
// Verify the error message includes the correct hook name
|
|
327
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
328
|
+
`An error was encountered in "beforeEvaluation" of the "${hookName}" hook: Error: Specific test error`
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
const { initialize } = require('../index');
|
|
2
|
+
const stubPlatform = require('./stubPlatform');
|
|
3
|
+
const { respondJson } = require('./mockHttp');
|
|
4
|
+
|
|
5
|
+
// Mock the logger functions
|
|
6
|
+
const mockLogger = () => ({
|
|
7
|
+
error: jest.fn(),
|
|
8
|
+
warn: jest.fn(),
|
|
9
|
+
info: jest.fn(),
|
|
10
|
+
debug: jest.fn(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Define a basic Hook structure for tests
|
|
14
|
+
const createTestHook = (name = 'Test Hook') => ({
|
|
15
|
+
getMetadata: jest.fn().mockReturnValue({ name }),
|
|
16
|
+
beforeEvaluation: jest.fn(),
|
|
17
|
+
afterEvaluation: jest.fn(),
|
|
18
|
+
beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data), // Pass data through
|
|
19
|
+
afterIdentify: jest.fn(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Helper to initialize the client for tests
|
|
23
|
+
// Disables network requests and event sending by default
|
|
24
|
+
async function withClient(initialContext, configOverrides = {}, hooks = [], testFn) {
|
|
25
|
+
const platform = stubPlatform.defaults();
|
|
26
|
+
const server = platform.testing.http.newServer();
|
|
27
|
+
|
|
28
|
+
const logger = mockLogger();
|
|
29
|
+
|
|
30
|
+
// Disable streaming and event sending unless overridden
|
|
31
|
+
// Configure client to use the mock server's URL
|
|
32
|
+
const defaults = {
|
|
33
|
+
baseUrl: server.url, // Use mock server URL
|
|
34
|
+
streaming: false,
|
|
35
|
+
sendEvents: false,
|
|
36
|
+
useLdd: false,
|
|
37
|
+
logger: logger,
|
|
38
|
+
hooks: hooks, // Pass initial hooks here
|
|
39
|
+
};
|
|
40
|
+
const config = { ...defaults, ...configOverrides };
|
|
41
|
+
const { client, start } = initialize('env', initialContext, config, platform);
|
|
42
|
+
|
|
43
|
+
// Set the mock server to return an empty flag set by default
|
|
44
|
+
server.byDefault(respondJson({})); // Correct way to provide initial flags
|
|
45
|
+
|
|
46
|
+
start(); // Start the client components
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Wait briefly for initialization (client will hit the mock server)
|
|
50
|
+
await client.waitForInitialization(10); // Use a short timeout
|
|
51
|
+
await testFn(client, logger, platform); // Pass client, logger, platform to the test
|
|
52
|
+
} finally {
|
|
53
|
+
await client.close();
|
|
54
|
+
server.close(); // Close the mock server
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('LDClient Hooks Integration', () => {
|
|
59
|
+
const initialContext = { kind: 'user', key: 'user-key-initial' };
|
|
60
|
+
const flagKey = 'test-flag';
|
|
61
|
+
const flagDefaultValue = false;
|
|
62
|
+
// Expected result when flag is not found (as it will be with empty flags)
|
|
63
|
+
const flagNotFoundDetail = {
|
|
64
|
+
value: flagDefaultValue,
|
|
65
|
+
variationIndex: null,
|
|
66
|
+
reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
it('should use hooks registered during configuration', async () => {
|
|
70
|
+
const testHook = createTestHook('Initial Hook');
|
|
71
|
+
const initialData = {}; // Hooks start with empty data
|
|
72
|
+
|
|
73
|
+
await withClient(initialContext, {}, [testHook], async client => {
|
|
74
|
+
// Call variation
|
|
75
|
+
await client.variation(flagKey, flagDefaultValue);
|
|
76
|
+
|
|
77
|
+
// Check identify hooks
|
|
78
|
+
expect(testHook.beforeIdentify).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(testHook.beforeIdentify).toHaveBeenCalledWith(
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
context: initialContext,
|
|
82
|
+
// timeout will be undefined unless explicitly passed to identify
|
|
83
|
+
}),
|
|
84
|
+
initialData
|
|
85
|
+
);
|
|
86
|
+
expect(testHook.afterIdentify).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(testHook.afterIdentify).toHaveBeenCalledWith(
|
|
88
|
+
expect.objectContaining({
|
|
89
|
+
context: initialContext,
|
|
90
|
+
}),
|
|
91
|
+
initialData, // Assumes beforeIdentify just returned the initial data
|
|
92
|
+
{ status: 'completed' }
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Check evaluation hooks (context from identify is now current)
|
|
96
|
+
expect(testHook.beforeEvaluation).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
flagKey: flagKey,
|
|
100
|
+
context: initialContext,
|
|
101
|
+
defaultValue: flagDefaultValue,
|
|
102
|
+
}),
|
|
103
|
+
initialData
|
|
104
|
+
);
|
|
105
|
+
expect(testHook.afterEvaluation).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(testHook.afterEvaluation).toHaveBeenCalledWith(
|
|
107
|
+
expect.objectContaining({
|
|
108
|
+
flagKey: flagKey,
|
|
109
|
+
context: initialContext,
|
|
110
|
+
defaultValue: flagDefaultValue,
|
|
111
|
+
}),
|
|
112
|
+
initialData, // Assumes beforeEvaluation just returned the initial data
|
|
113
|
+
flagNotFoundDetail // Using the default flag not found result
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should execute hooks that are added using addHook', async () => {
|
|
119
|
+
const addedHook = createTestHook('Added Hook');
|
|
120
|
+
const identifyContext = { kind: 'user', key: 'user-key-added' };
|
|
121
|
+
const initialData = {};
|
|
122
|
+
|
|
123
|
+
// Initialize client *without* the hook initially
|
|
124
|
+
await withClient(initialContext, {}, [], async client => {
|
|
125
|
+
// Add the hook dynamically
|
|
126
|
+
client.addHook(addedHook);
|
|
127
|
+
|
|
128
|
+
// Call identify and variation
|
|
129
|
+
await client.identify(identifyContext);
|
|
130
|
+
await client.variation(flagKey, flagDefaultValue);
|
|
131
|
+
|
|
132
|
+
// Check identify hooks
|
|
133
|
+
expect(addedHook.beforeIdentify).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(addedHook.beforeIdentify).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({ context: identifyContext }),
|
|
136
|
+
initialData
|
|
137
|
+
);
|
|
138
|
+
expect(addedHook.afterIdentify).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(addedHook.afterIdentify).toHaveBeenCalledWith(
|
|
140
|
+
expect.objectContaining({ context: identifyContext }),
|
|
141
|
+
initialData,
|
|
142
|
+
{ status: 'completed' }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Check evaluation hooks
|
|
146
|
+
expect(addedHook.beforeEvaluation).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(addedHook.beforeEvaluation).toHaveBeenCalledWith(
|
|
148
|
+
expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
|
|
149
|
+
initialData
|
|
150
|
+
);
|
|
151
|
+
expect(addedHook.afterEvaluation).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(addedHook.afterEvaluation).toHaveBeenCalledWith(
|
|
153
|
+
expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
|
|
154
|
+
initialData,
|
|
155
|
+
flagNotFoundDetail
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should execute both initial hooks and hooks added using addHook', async () => {
|
|
161
|
+
const initialHook = createTestHook('Initial Hook For Both');
|
|
162
|
+
const addedHook = createTestHook('Added Hook For Both');
|
|
163
|
+
const identifyContext = { kind: 'user', key: 'user-key-both' };
|
|
164
|
+
const initialData = {};
|
|
165
|
+
|
|
166
|
+
// Initialize client *with* the initial hook
|
|
167
|
+
await withClient(initialContext, {}, [initialHook], async client => {
|
|
168
|
+
// Add the second hook dynamically
|
|
169
|
+
client.addHook(addedHook);
|
|
170
|
+
|
|
171
|
+
await client.identify(identifyContext);
|
|
172
|
+
await client.variation(flagKey, flagDefaultValue);
|
|
173
|
+
|
|
174
|
+
expect(initialHook.beforeIdentify).toHaveBeenCalledTimes(2);
|
|
175
|
+
expect(initialHook.beforeIdentify).toHaveBeenNthCalledWith(
|
|
176
|
+
1,
|
|
177
|
+
expect.objectContaining({ context: initialContext }),
|
|
178
|
+
initialData
|
|
179
|
+
);
|
|
180
|
+
expect(initialHook.beforeIdentify).toHaveBeenNthCalledWith(
|
|
181
|
+
2,
|
|
182
|
+
expect.objectContaining({ context: identifyContext }),
|
|
183
|
+
initialData
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(initialHook.afterIdentify).toHaveBeenCalledTimes(2);
|
|
187
|
+
expect(initialHook.afterIdentify).toHaveBeenNthCalledWith(
|
|
188
|
+
1,
|
|
189
|
+
expect.objectContaining({ context: initialContext }),
|
|
190
|
+
initialData, // Assuming pass-through
|
|
191
|
+
{ status: 'completed' }
|
|
192
|
+
);
|
|
193
|
+
expect(initialHook.afterIdentify).toHaveBeenNthCalledWith(
|
|
194
|
+
2,
|
|
195
|
+
expect.objectContaining({ context: identifyContext }),
|
|
196
|
+
initialData, // Assuming pass-through
|
|
197
|
+
{ status: 'completed' }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(addedHook.beforeIdentify).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(addedHook.beforeIdentify).toHaveBeenCalledWith(
|
|
202
|
+
expect.objectContaining({ context: identifyContext }),
|
|
203
|
+
initialData
|
|
204
|
+
);
|
|
205
|
+
expect(addedHook.afterIdentify).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(addedHook.afterIdentify).toHaveBeenCalledWith(
|
|
207
|
+
expect.objectContaining({ context: identifyContext }),
|
|
208
|
+
initialData, // Assuming pass-through
|
|
209
|
+
{ status: 'completed' }
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Check evaluation hooks for BOTH hooks
|
|
213
|
+
[initialHook, addedHook].forEach(hook => {
|
|
214
|
+
expect(hook.beforeEvaluation).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(hook.beforeEvaluation).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
|
|
217
|
+
initialData
|
|
218
|
+
);
|
|
219
|
+
expect(hook.afterEvaluation).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(hook.afterEvaluation).toHaveBeenCalledWith(
|
|
221
|
+
expect.objectContaining({ flagKey, context: identifyContext, defaultValue: flagDefaultValue }),
|
|
222
|
+
initialData, // Assuming pass-through
|
|
223
|
+
flagNotFoundDetail
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendUrlPath, getLDUserAgentString, wrapPromiseCallback } from '../utils';
|
|
1
|
+
import { appendUrlPath, getLDUserAgentString, wrapPromiseCallback, once } from '../utils';
|
|
2
2
|
|
|
3
3
|
import * as stubPlatform from './stubPlatform';
|
|
4
4
|
|
|
@@ -63,4 +63,34 @@ describe('utils', () => {
|
|
|
63
63
|
expect(ua).toEqual('stubClient/7.8.9');
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
it('when using once the original function is only called once', () => {
|
|
68
|
+
let count = 0;
|
|
69
|
+
const fn = once(() => {
|
|
70
|
+
count++;
|
|
71
|
+
return count;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(fn()).toBe(1);
|
|
75
|
+
expect(fn()).toBe(1);
|
|
76
|
+
expect(fn()).toBe(1);
|
|
77
|
+
expect(count).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('once works with async functions', async () => {
|
|
81
|
+
let count = 0;
|
|
82
|
+
const fn = once(async () => {
|
|
83
|
+
count++;
|
|
84
|
+
return count;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result1 = await fn();
|
|
88
|
+
const result2 = await fn();
|
|
89
|
+
const result3 = await fn();
|
|
90
|
+
|
|
91
|
+
expect(result1).toBe(1);
|
|
92
|
+
expect(result2).toBe(1);
|
|
93
|
+
expect(result3).toBe(1);
|
|
94
|
+
expect(count).toBe(1);
|
|
95
|
+
});
|
|
66
96
|
});
|
package/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ const messages = require('./messages');
|
|
|
17
17
|
const { checkContext, getContextKeys } = require('./context');
|
|
18
18
|
const { InspectorTypes, InspectorManager } = require('./InspectorManager');
|
|
19
19
|
const timedPromise = require('./timedPromise');
|
|
20
|
-
const
|
|
20
|
+
const createHookRunner = require('./HookRunner');
|
|
21
21
|
|
|
22
22
|
const changeEvent = 'change';
|
|
23
23
|
const internalChangeEvent = 'internal-change';
|
|
@@ -41,7 +41,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
|
|
|
41
41
|
const sendEvents = options.sendEvents;
|
|
42
42
|
let environment = env;
|
|
43
43
|
let hash = options.hash;
|
|
44
|
-
const hookRunner =
|
|
44
|
+
const hookRunner = createHookRunner(logger, options.hooks);
|
|
45
45
|
|
|
46
46
|
const persistentStorage = PersistentStorage(platform.localStorage, logger);
|
|
47
47
|
|
|
@@ -265,7 +265,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
|
|
|
265
265
|
.then(() => anonymousContextProcessor.processContext(context))
|
|
266
266
|
.then(verifyContext)
|
|
267
267
|
.then(context => {
|
|
268
|
-
afterIdentify = hookRunner.identify(context, undefined);
|
|
268
|
+
afterIdentify = utils.once(hookRunner.identify(context, undefined));
|
|
269
269
|
return context;
|
|
270
270
|
})
|
|
271
271
|
.then(validatedContext =>
|
|
@@ -687,7 +687,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
|
|
|
687
687
|
.processContext(context)
|
|
688
688
|
.then(verifyContext)
|
|
689
689
|
.then(context => {
|
|
690
|
-
afterIdentify = hookRunner.identify(context, undefined);
|
|
690
|
+
afterIdentify = utils.once(hookRunner.identify(context, undefined));
|
|
691
691
|
return context;
|
|
692
692
|
})
|
|
693
693
|
.then(validatedContext => {
|
package/src/utils.js
CHANGED
|
@@ -144,6 +144,26 @@ function sanitizeContext(context) {
|
|
|
144
144
|
return newContext || context;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Creates a function that will invoke the provided function only once.
|
|
149
|
+
*
|
|
150
|
+
* If the function returns a value, then that returned value will be re-used for subsequent invocations.
|
|
151
|
+
*
|
|
152
|
+
* @param {Function} func The function to restrict.
|
|
153
|
+
* @returns {Function} Returns the new restricted function.
|
|
154
|
+
*/
|
|
155
|
+
function once(func) {
|
|
156
|
+
let called = false;
|
|
157
|
+
let result;
|
|
158
|
+
return function(...args) {
|
|
159
|
+
if (!called) {
|
|
160
|
+
called = true;
|
|
161
|
+
result = func.apply(this, args);
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
147
167
|
module.exports = {
|
|
148
168
|
appendUrlPath,
|
|
149
169
|
base64URLEncode,
|
|
@@ -158,4 +178,5 @@ module.exports = {
|
|
|
158
178
|
transformValuesToVersionedValues,
|
|
159
179
|
transformVersionedValuesToValues,
|
|
160
180
|
wrapPromiseCallback,
|
|
181
|
+
once,
|
|
161
182
|
};
|
package/typings.d.ts
CHANGED
|
@@ -183,7 +183,7 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
183
183
|
* This method is called during the execution of the identify process before the operation
|
|
184
184
|
* completes, but after any context modifications are performed.
|
|
185
185
|
*
|
|
186
|
-
* @param hookContext Contains information about the
|
|
186
|
+
* @param hookContext Contains information about the identify operation being performed. This is not
|
|
187
187
|
* mutable.
|
|
188
188
|
* @param data A record associated with each stage of hook invocations. Each stage is called with
|
|
189
189
|
* the data of the previous stage for a series. The input record should not be modified.
|
|
@@ -200,7 +200,7 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
200
200
|
* This method is called during the execution of the identify process before the operation
|
|
201
201
|
* completes, but after any context modifications are performed.
|
|
202
202
|
*
|
|
203
|
-
* @param hookContext Contains information about the
|
|
203
|
+
* @param hookContext Contains information about the identify operation being performed. This is not
|
|
204
204
|
* mutable.
|
|
205
205
|
* @param data A record associated with each stage of hook invocations. Each stage is called with
|
|
206
206
|
* the data of the previous stage for a series. The input record should not be modified.
|
|
@@ -467,7 +467,7 @@ declare module 'launchdarkly-js-sdk-common' {
|
|
|
467
467
|
*
|
|
468
468
|
* Example:
|
|
469
469
|
* ```typescript
|
|
470
|
-
* import {
|
|
470
|
+
* import { initialize } from 'launchdarkly-js-client-sdk';
|
|
471
471
|
* import { TheHook } from '@launchdarkly/some-hook';
|
|
472
472
|
*
|
|
473
473
|
* const client = init('my-sdk-key', { hooks: [new TheHook()] });
|