Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/__tests__/LDClient-plugins-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
const { initialize } = require('../index');
const stubPlatform = require('./stubPlatform');
const { respondJson } = require('./mockHttp');

// Mock the logger functions
const mockLogger = () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
});

// Define a basic Hook structure for tests
const createTestHook = (name = 'Test Hook') => ({
getMetadata: jest.fn().mockReturnValue({ name }),
beforeEvaluation: jest.fn().mockImplementation((_ctx, data) => data),
afterEvaluation: jest.fn().mockImplementation((_ctx, data) => data),
beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data),
afterIdentify: jest.fn().mockImplementation((_ctx, data) => data),
afterTrack: jest.fn().mockImplementation((_ctx, data) => data),
});

// Define a basic Plugin structure for tests
const createTestPlugin = (name = 'Test Plugin', hooks = []) => ({
getMetadata: jest.fn().mockReturnValue({ name }),
register: jest.fn(),
getHooks: jest.fn().mockReturnValue(hooks),
});

// Helper to initialize the client for tests
async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) {
const platform = stubPlatform.defaults();
const server = platform.testing.http.newServer();
const logger = mockLogger();

// Disable streaming and event sending unless overridden
const defaults = {
baseUrl: server.url,
streaming: false,
sendEvents: false,
useLdd: false,
logger: logger,
plugins: plugins,
};
const config = { ...defaults, ...configOverrides };
const { client, start } = initialize('env', initialContext, config, platform);

server.byDefault(respondJson({}));
start();

try {
await client.waitForInitialization(10);
await testFn(client, logger, platform);
} finally {
await client.close();
server.close();
}
}

it('registers plugins and executes hooks during initialization', async () => {
const mockHook = createTestHook('test-hook');
const mockPlugin = createTestPlugin('test-plugin', [mockHook]);

await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => {
// Verify the plugin was registered
expect(mockPlugin.register).toHaveBeenCalled();

// Test identify hook
await client.identify({ key: 'user-key', kind: 'user' });
expect(mockHook.beforeIdentify).toHaveBeenCalledWith(
{ context: { key: 'user-key', kind: 'user' }, timeout: undefined },
{}
);
expect(mockHook.afterIdentify).toHaveBeenCalledWith(
{ context: { key: 'user-key', kind: 'user' }, timeout: undefined },
{},
{ status: 'completed' }
);

// Test variation hook
client.variation('flag-key', false);
expect(mockHook.beforeEvaluation).toHaveBeenCalledWith(
{
context: { key: 'user-key', kind: 'user' },
defaultValue: false,
flagKey: 'flag-key',
},
{}
);
expect(mockHook.afterEvaluation).toHaveBeenCalled();

// Test track hook
client.track('event-key', { data: true }, 42);
expect(mockHook.afterTrack).toHaveBeenCalledWith({
context: { key: 'user-key', kind: 'user' },
key: 'event-key',
data: { data: true },
metricValue: 42,
});
});
});

it('registers multiple plugins and executes all hooks', async () => {
const mockHook1 = createTestHook('test-hook-1');
const mockHook2 = createTestHook('test-hook-2');
const mockPlugin1 = createTestPlugin('test-plugin-1', [mockHook1]);
const mockPlugin2 = createTestPlugin('test-plugin-2', [mockHook2]);

await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin1, mockPlugin2], async client => {
// Verify plugins were registered
expect(mockPlugin1.register).toHaveBeenCalled();
expect(mockPlugin2.register).toHaveBeenCalled();

// Test that both hooks work
await client.identify({ key: 'user-key', kind: 'user' });
client.variation('flag-key', false);
client.track('event-key', { data: true }, 42);

expect(mockHook1.beforeEvaluation).toHaveBeenCalled();
expect(mockHook1.afterEvaluation).toHaveBeenCalled();
expect(mockHook2.beforeEvaluation).toHaveBeenCalled();
expect(mockHook2.afterEvaluation).toHaveBeenCalled();
expect(mockHook1.afterTrack).toHaveBeenCalled();
expect(mockHook2.afterTrack).toHaveBeenCalled();
});
});

it('passes correct environmentMetadata to plugin getHooks and register functions', async () => {
const mockPlugin = createTestPlugin('test-plugin');
const options = {
wrapperName: 'test-wrapper',
wrapperVersion: '2.0.0',
application: {
name: 'test-app',
version: '3.0.0',
},
};

await withClient(
{ key: 'user-key', kind: 'user' },
{ ...options, plugins: [mockPlugin] },
[mockPlugin],
async (client, logger, testPlatform) => {
expect(testPlatform.userAgent).toBeDefined();
expect(testPlatform.version).toBeDefined();
// Verify getHooks was called with correct environmentMetadata
expect(mockPlugin.getHooks).toHaveBeenCalledWith({
sdk: {
name: testPlatform.userAgent,
version: testPlatform.version,
wrapperName: options.wrapperName,
wrapperVersion: options.wrapperVersion,
},
application: {
id: options.application.id,
version: options.application.version,
},
clientSideId: 'env',
});

// Verify register was called with correct environmentMetadata
expect(mockPlugin.register).toHaveBeenCalledWith(
expect.any(Object), // client
{
sdk: {
name: testPlatform.userAgent,
version: testPlatform.version,
wrapperName: options.wrapperName,
wrapperVersion: options.wrapperVersion,
},
application: {
id: options.application.id,
version: options.application.version,
},
clientSideId: 'env',
}
);
}
);
});

it('passes correct environmentMetadata without optional fields', async () => {
const mockPlugin = createTestPlugin('test-plugin');

await withClient(
{ key: 'user-key', kind: 'user' },
{ plugins: [mockPlugin] },
[mockPlugin],
async (client, logger, testPlatform) => {
expect(testPlatform.userAgent).toBeDefined();
expect(testPlatform.version).toBeDefined();
// Verify getHooks was called with correct environmentMetadata
expect(mockPlugin.getHooks).toHaveBeenCalledWith({
sdk: {
name: testPlatform.userAgent,
version: testPlatform.version,
},
clientSideId: 'env',
});

// Verify register was called with correct environmentMetadata
expect(mockPlugin.register).toHaveBeenCalledWith(
expect.any(Object), // client
{
sdk: {
name: testPlatform.userAgent,
version: testPlatform.version,
},
clientSideId: 'env',
}
);
}
);
});
1 change: 1 addition & 0 deletions src/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const baseOptionDefs = {
application: { validator: applicationConfigValidator },
inspectors: { default: [] },
hooks: { default: [] },
plugins: { default: [] },
};

/**
Expand Down
12 changes: 10 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const { checkContext, getContextKeys } = require('./context');
const { InspectorTypes, InspectorManager } = require('./InspectorManager');
const timedPromise = require('./timedPromise');
const createHookRunner = require('./HookRunner');

const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins');
const changeEvent = 'change';
const internalChangeEvent = 'internal-change';
const highTimeoutThreshold = 5;
Expand All @@ -41,7 +41,13 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
const sendEvents = options.sendEvents;
let environment = env;
let hash = options.hash;
const hookRunner = createHookRunner(logger, options.hooks);
const plugins = [...options.plugins];

const pluginEnvironment = createPluginEnvironment(platform, env, options);

const pluginHooks = getPluginHooks(logger, pluginEnvironment, plugins);

const hookRunner = createHookRunner(logger, [...options.hooks, ...pluginHooks]);

const persistentStorage = PersistentStorage(platform.localStorage, logger);

Expand Down Expand Up @@ -872,6 +878,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
addHook: addHook,
};

registerPlugins(logger, pluginEnvironment, client, plugins);

return {
client: client, // The client object containing all public methods.
options: options, // The validated configuration object, including all defaults.
Expand Down
109 changes: 109 additions & 0 deletions src/plugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
const UNKNOWN_PLUGIN_NAME = 'unknown plugin';

/**
* Safely gets the name of a plugin with error handling
* @param {{ error: (message: string) => void }} logger - The logger instance
* @param {{getMetadata: () => {name: string}}} plugin - Plugin object that may have a name property
* @returns {string} The plugin name or 'unknown' if not available
*/
function getPluginName(logger, plugin) {
try {
return plugin.getMetadata().name || UNKNOWN_PLUGIN_NAME;
} catch (error) {
logger.error(`Exception thrown getting metadata for plugin. Unable to get plugin name.`);
return UNKNOWN_PLUGIN_NAME;
}
}

/**
* Safely retrieves hooks from plugins with error handling
* @param {Object} logger - The logger instance
* @param {Object} environmentMetadata - Metadata about the environment for plugin initialization
* @param {Array<{getHooks: (environmentMetadata: object) => Hook[]}>} plugins - Array of plugin objects that may implement getHooks
* @returns {Array<Hook>} Array of hook objects collected from all plugins
*/
function getPluginHooks(logger, environmentMetadata, plugins) {
const hooks = [];
plugins.forEach(plugin => {
try {
const pluginHooks = plugin.getHooks?.(environmentMetadata);
if (pluginHooks === undefined) {
logger.error(`Plugin ${getPluginName(logger, plugin)} returned undefined from getHooks.`);
} else if (pluginHooks && pluginHooks.length > 0) {
hooks.push(...pluginHooks);
}
} catch (error) {
logger.error(`Exception thrown getting hooks for plugin ${getPluginName(logger, plugin)}. Unable to get hooks.`);
}
});
return hooks;
}

/**
* Registers plugins with the SDK
* @param {{ error: (message: string) => void }} logger - The logger instance
* @param {Object} environmentMetadata - Metadata about the environment for plugin initialization
* @param {Object} client - The SDK client instance
* @param {Array<{register: (client: object, environmentMetadata: object) => void}>} plugins - Array of plugin objects that implement register
*/
function registerPlugins(logger, environmentMetadata, client, plugins) {
plugins.forEach(plugin => {
try {
plugin.register(client, environmentMetadata);
} catch (error) {
logger.error(`Exception thrown registering plugin ${getPluginName(logger, plugin)}.`);
}
});
}

/**
* Creates a plugin environment object
* @param {{userAgent: string, version: string}} platform - The platform object
* @param {string} env - The environment
* @param {{application: {name: string, version: string}, wrapperName: string, wrapperVersion: string}} options - The options
* @returns {{sdk: {name: string, version: string, wrapperName: string, wrapperVersion: string}, application: {name: string, version: string}, clientSideId: string}} The plugin environment
*/
function createPluginEnvironment(platform, env, options) {
const pluginSdkMetadata = {};

if (platform.userAgent) {
pluginSdkMetadata.name = platform.userAgent;
}
if (platform.version) {
pluginSdkMetadata.version = platform.version;
}
if (options.wrapperName) {
pluginSdkMetadata.wrapperName = options.wrapperName;
}
if (options.wrapperVersion) {
pluginSdkMetadata.wrapperVersion = options.wrapperVersion;
}

const pluginApplicationMetadata = {};

if (options.application) {
if (options.application.name) {
pluginApplicationMetadata.name = options.application.name;
}
if (options.application.version) {
pluginApplicationMetadata.version = options.application.version;
}
}

const pluginEnvironment = {
sdk: pluginSdkMetadata,
clientSideId: env,
};

if (Object.keys(pluginApplicationMetadata).length > 0) {
pluginEnvironment.application = pluginApplicationMetadata;
}

return pluginEnvironment;
}

module.exports = {
getPluginHooks,
registerPlugins,
createPluginEnvironment,
};
Loading