-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
8cd0cdc
commit 160e7c7
Showing
10 changed files
with
617 additions
and
6 deletions.
There are no files selected for viewing
227 changes: 227 additions & 0 deletions
227
packages/shared/sdk-client/__tests__/HookRunner.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common'; | ||
|
||
import { Hook, IdentifyResult } from '../src/api/integrations/Hooks'; | ||
import HookRunner from '../src/HookRunner'; | ||
|
||
describe('given a hook runner and test hook', () => { | ||
let logger: LDLogger; | ||
let testHook: Hook; | ||
let hookRunner: HookRunner; | ||
|
||
beforeEach(() => { | ||
logger = { | ||
error: jest.fn(), | ||
warn: jest.fn(), | ||
info: jest.fn(), | ||
debug: jest.fn(), | ||
}; | ||
|
||
testHook = { | ||
getMetadata: jest.fn().mockReturnValue({ name: 'Test Hook' }), | ||
beforeEvaluation: jest.fn(), | ||
afterEvaluation: jest.fn(), | ||
beforeIdentify: jest.fn(), | ||
afterIdentify: jest.fn(), | ||
}; | ||
|
||
hookRunner = new HookRunner(logger, [testHook]); | ||
}); | ||
|
||
describe('when evaluating flags', () => { | ||
it('should execute hooks and return the evaluation result', () => { | ||
const key = 'test-flag'; | ||
const context: LDContext = { kind: 'user', key: 'user-123' }; | ||
const defaultValue = false; | ||
const evaluationResult: LDEvaluationDetail = { | ||
value: true, | ||
variationIndex: 1, | ||
reason: { kind: 'OFF' }, | ||
}; | ||
|
||
const method = jest.fn().mockReturnValue(evaluationResult); | ||
|
||
const result = hookRunner.withEvaluation(key, context, defaultValue, method); | ||
|
||
expect(testHook.beforeEvaluation).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
flagKey: key, | ||
context, | ||
defaultValue, | ||
}), | ||
{}, | ||
); | ||
|
||
expect(method).toHaveBeenCalled(); | ||
|
||
expect(testHook.afterEvaluation).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
flagKey: key, | ||
context, | ||
defaultValue, | ||
}), | ||
{}, | ||
evaluationResult, | ||
); | ||
|
||
expect(result).toEqual(evaluationResult); | ||
}); | ||
|
||
it('should handle errors in hooks', () => { | ||
const errorHook: Hook = { | ||
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), | ||
beforeEvaluation: jest.fn().mockImplementation(() => { | ||
throw new Error('Hook error'); | ||
}), | ||
afterEvaluation: jest.fn(), | ||
}; | ||
|
||
const errorHookRunner = new HookRunner(logger, [errorHook]); | ||
|
||
const method = jest | ||
.fn() | ||
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } }); | ||
|
||
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method); | ||
|
||
expect(logger.error).toHaveBeenCalledWith( | ||
expect.stringContaining( | ||
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error', | ||
), | ||
); | ||
}); | ||
|
||
it('should skip hook execution if there are no hooks', () => { | ||
const emptyHookRunner = new HookRunner(logger, []); | ||
const method = jest | ||
.fn() | ||
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } }); | ||
|
||
emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method); | ||
|
||
expect(method).toHaveBeenCalled(); | ||
expect(logger.error).not.toHaveBeenCalled(); | ||
}); | ||
}); | ||
|
||
describe('when handling an identifcation', () => { | ||
it('should execute identify hooks', () => { | ||
const context: LDContext = { kind: 'user', key: 'user-123' }; | ||
const timeout = 10; | ||
const identifyResult: IdentifyResult = 'completed'; | ||
|
||
const identifyCallback = hookRunner.identify(context, timeout); | ||
identifyCallback(identifyResult); | ||
|
||
expect(testHook.beforeIdentify).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
context, | ||
timeout, | ||
}), | ||
{}, | ||
); | ||
|
||
expect(testHook.afterIdentify).toHaveBeenCalledWith( | ||
expect.objectContaining({ | ||
context, | ||
timeout, | ||
}), | ||
{}, | ||
identifyResult, | ||
); | ||
}); | ||
|
||
it('should handle errors in identify hooks', () => { | ||
const errorHook: Hook = { | ||
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), | ||
beforeIdentify: jest.fn().mockImplementation(() => { | ||
throw new Error('Hook error'); | ||
}), | ||
afterIdentify: jest.fn(), | ||
}; | ||
|
||
const errorHookRunner = new HookRunner(logger, [errorHook]); | ||
|
||
const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-123' }, 1000); | ||
identifyCallback('error'); | ||
|
||
expect(logger.error).toHaveBeenCalledWith( | ||
expect.stringContaining( | ||
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error', | ||
), | ||
); | ||
}); | ||
}); | ||
|
||
it('should use the added hook in future invocations', () => { | ||
const newHook: Hook = { | ||
getMetadata: jest.fn().mockReturnValue({ name: 'New Hook' }), | ||
beforeEvaluation: jest.fn(), | ||
afterEvaluation: jest.fn(), | ||
}; | ||
|
||
hookRunner.addHook(newHook); | ||
|
||
const method = jest | ||
.fn() | ||
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } }); | ||
|
||
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method); | ||
|
||
expect(newHook.beforeEvaluation).toHaveBeenCalled(); | ||
expect(newHook.afterEvaluation).toHaveBeenCalled(); | ||
}); | ||
|
||
it('should log "unknown hook" when getMetadata throws an error', () => { | ||
const errorHook: Hook = { | ||
getMetadata: jest.fn().mockImplementation(() => { | ||
throw new Error('Metadata error'); | ||
}), | ||
beforeEvaluation: jest.fn().mockImplementation(() => { | ||
throw new Error('Test error in beforeEvaluation'); | ||
}), | ||
afterEvaluation: jest.fn(), | ||
}; | ||
|
||
const errorHookRunner = new HookRunner(logger, [errorHook]); | ||
|
||
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({ | ||
value: true, | ||
variationIndex: 1, | ||
reason: { kind: 'OFF' }, | ||
})); | ||
|
||
expect(logger.error).toHaveBeenCalledWith( | ||
'Exception thrown getting metadata for hook. Unable to get hook name.', | ||
); | ||
|
||
// Verify that the error was logged with the correct hook name | ||
expect(logger.error).toHaveBeenCalledWith( | ||
expect.stringContaining( | ||
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation', | ||
), | ||
); | ||
}); | ||
|
||
it('should log the correct hook name when an error occurs', () => { | ||
// Modify the testHook to throw an error in beforeEvaluation | ||
testHook.beforeEvaluation = jest.fn().mockImplementation(() => { | ||
throw new Error('Test error in beforeEvaluation'); | ||
}); | ||
|
||
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({ | ||
value: true, | ||
variationIndex: 1, | ||
reason: { kind: 'OFF' }, | ||
})); | ||
|
||
// Verify that getMetadata was called to get the hook name | ||
expect(testHook.getMetadata).toHaveBeenCalled(); | ||
|
||
// Verify that the error was logged with the correct hook name | ||
expect(logger.error).toHaveBeenCalledWith( | ||
expect.stringContaining( | ||
'An error was encountered in "beforeEvaluation" of the "Test Hook" hook: Error: Test error in beforeEvaluation', | ||
), | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; | ||
|
||
import { | ||
EvaluationSeriesContext, | ||
EvaluationSeriesData, | ||
Hook, | ||
IdentifyResult, | ||
IdentifySeriesContext, | ||
IdentifySeriesData, | ||
} from './api/integrations/Hooks'; | ||
import { LDEvaluationDetail } from './api/LDEvaluationDetail'; | ||
|
||
const UNKNOWN_HOOK_NAME = 'unknown hook'; | ||
const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; | ||
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; | ||
|
||
function tryExecuteStage<TData>( | ||
logger: LDLogger, | ||
method: string, | ||
hookName: string, | ||
stage: () => TData, | ||
def: TData, | ||
): TData { | ||
try { | ||
return stage(); | ||
} catch (err) { | ||
logger?.error(`An error was encountered in "${method}" of the "${hookName}" hook: ${err}`); | ||
return def; | ||
} | ||
} | ||
|
||
function getHookName(logger: LDLogger, hook?: Hook): string { | ||
try { | ||
return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME; | ||
} catch { | ||
logger.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); | ||
return UNKNOWN_HOOK_NAME; | ||
} | ||
} | ||
|
||
function executeBeforeEvaluation( | ||
logger: LDLogger, | ||
hooks: Hook[], | ||
hookContext: EvaluationSeriesContext, | ||
): EvaluationSeriesData[] { | ||
return hooks.map((hook) => | ||
tryExecuteStage( | ||
logger, | ||
BEFORE_EVALUATION_STAGE_NAME, | ||
getHookName(logger, hook), | ||
() => hook?.beforeEvaluation?.(hookContext, {}) ?? {}, | ||
{}, | ||
), | ||
); | ||
} | ||
|
||
function executeAfterEvaluation( | ||
logger: LDLogger, | ||
hooks: Hook[], | ||
hookContext: EvaluationSeriesContext, | ||
updatedData: (EvaluationSeriesData | undefined)[], | ||
result: LDEvaluationDetail, | ||
) { | ||
// This iterates in reverse, versus reversing a shallow copy of the hooks, | ||
// for efficiency. | ||
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { | ||
const hook = hooks[hookIndex]; | ||
const data = updatedData[hookIndex] ?? {}; | ||
tryExecuteStage( | ||
logger, | ||
AFTER_EVALUATION_STAGE_NAME, | ||
getHookName(logger, hook), | ||
() => hook?.afterEvaluation?.(hookContext, data, result) ?? {}, | ||
{}, | ||
); | ||
} | ||
} | ||
|
||
function executeBeforeIdentify( | ||
logger: LDLogger, | ||
hooks: Hook[], | ||
hookContext: IdentifySeriesContext, | ||
): IdentifySeriesData[] { | ||
return hooks.map((hook) => | ||
tryExecuteStage( | ||
logger, | ||
BEFORE_EVALUATION_STAGE_NAME, | ||
getHookName(logger, hook), | ||
() => hook?.beforeIdentify?.(hookContext, {}) ?? {}, | ||
{}, | ||
), | ||
); | ||
} | ||
|
||
function executeAfterIdentify( | ||
logger: LDLogger, | ||
hooks: Hook[], | ||
hookContext: IdentifySeriesContext, | ||
updatedData: (IdentifySeriesData | undefined)[], | ||
result: IdentifyResult, | ||
) { | ||
// This iterates in reverse, versus reversing a shallow copy of the hooks, | ||
// for efficiency. | ||
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { | ||
const hook = hooks[hookIndex]; | ||
const data = updatedData[hookIndex] ?? {}; | ||
tryExecuteStage( | ||
logger, | ||
AFTER_EVALUATION_STAGE_NAME, | ||
getHookName(logger, hook), | ||
() => hook?.afterIdentify?.(hookContext, data, result) ?? {}, | ||
{}, | ||
); | ||
} | ||
} | ||
|
||
export default class HookRunner { | ||
private readonly hooks: Hook[] = []; | ||
|
||
constructor( | ||
private readonly logger: LDLogger, | ||
initialHooks: Hook[], | ||
) { | ||
this.hooks.push(...initialHooks); | ||
} | ||
|
||
withEvaluation( | ||
key: string, | ||
context: LDContext | undefined, | ||
defaultValue: unknown, | ||
method: () => LDEvaluationDetail, | ||
): LDEvaluationDetail { | ||
if (this.hooks.length === 0) { | ||
return method(); | ||
} | ||
const hooks: Hook[] = [...this.hooks]; | ||
const hookContext: EvaluationSeriesContext = { | ||
flagKey: key, | ||
context, | ||
defaultValue, | ||
}; | ||
|
||
const hookData = executeBeforeEvaluation(this.logger, hooks, hookContext); | ||
const result = method(); | ||
executeAfterEvaluation(this.logger, hooks, hookContext, hookData, result); | ||
return result; | ||
} | ||
|
||
identify(context: LDContext, timeout: number | undefined): (result: IdentifyResult) => void { | ||
const hooks: Hook[] = [...this.hooks]; | ||
const hookContext: IdentifySeriesContext = { | ||
context, | ||
timeout, | ||
}; | ||
const hookData = executeBeforeIdentify(this.logger, hooks, hookContext); | ||
return (result) => { | ||
executeAfterIdentify(this.logger, hooks, hookContext, hookData, result); | ||
}; | ||
} | ||
|
||
addHook(hook: Hook): void { | ||
this.hooks.push(hook); | ||
} | ||
} |
Oops, something went wrong.