* Tests for lib/plugins.js -- createPluginEvents, loadPlugins, and config reading.
*/
import { describe, test, expect, jest } from '@jest/globals';
import { createPluginEvents, loadPlugins } from '../../lib/plugins.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
describe('lib/plugins', () => {
describe('createPluginEvents', () => {
test('returns an EventEmitter with high maxListeners', () => {
const events = createPluginEvents();
expect(events).toBeDefined();
expect(typeof events.on).toBe('function');
expect(typeof events.emit).toBe('function');
expect(events.getMaxListeners()).toBe(50);
});
test('basic emit/on works', () => {
const events = createPluginEvents();
const received = [];
events.on('test:event', (payload) => received.push(payload));
events.emit('test:event', { foo: 'bar' });
expect(received).toEqual([{ foo: 'bar' }]);
});
test('supports multiple listeners', () => {
const events = createPluginEvents();
const results = [];
events.on('multi', () => results.push('a'));
events.on('multi', () => results.push('b'));
events.on('multi', () => results.push('c'));
events.emit('multi');
expect(results).toEqual(['a', 'b', 'c']);
});
test('removeListener works', () => {
const events = createPluginEvents();
const results = [];
const handler = () => results.push('called');
events.on('removal', handler);
events.emit('removal');
expect(results).toEqual(['called']);
events.removeListener('removal', handler);
events.emit('removal');
expect(results).toEqual(['called']);
});
test('emitAsync awaits all listeners including async', async () => {
const events = createPluginEvents();
const results = [];
events.on('async:test', async (payload) => {
await new Promise((r) => setTimeout(r, 10));
results.push('async-' + payload.val);
});
events.on('async:test', (payload) => {
results.push('sync-' + payload.val);
});
await events.emitAsync('async:test', { val: 1 });
expect(results).toContain('async-1');
expect(results).toContain('sync-1');
expect(results.length).toBe(2);
});
test('emitAsync with no listeners resolves immediately', async () => {
const events = createPluginEvents();
await events.emitAsync('nonexistent', {});
});
});
describe('loadPlugins', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camofox-plugin-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMockCtx() {
return {
log: jest.fn(),
events: createPluginEvents(),
sessions: new Map(),
config: {},
};
}
function makePlugin(pluginsDir, name, source, metadata = null) {
const pluginDir = path.join(pluginsDir, name);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'index.js'), source);
if (metadata) {
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify(metadata));
}
}
test('loads config-disabled plugin when its enable env var is set', async () => {
const pluginsDir = path.join(tmpDir, 'plugins');
const configPath = path.join(tmpDir, 'camofox.config.json');
fs.mkdirSync(pluginsDir);
makePlugin(
pluginsDir,
'env-enabled',
'export async function register(app, _ctx, pluginConfig) { app.loaded.push(pluginConfig); }\n',
{ enableEnvVar: 'ENABLE_TEST_PLUGIN' }
);
makePlugin(
pluginsDir,
'disabled',
'export async function register(app) { app.loaded.push("disabled"); }\n'
);
fs.writeFileSync(configPath, JSON.stringify({
plugins: {
'env-enabled': { enabled: false, resolution: '1280x720' },
disabled: { enabled: false },
},
}));
const ctx = makeMockCtx();
const app = { loaded: [] };
await loadPlugins(app, ctx, {
pluginsDir,
configPath,
env: { ENABLE_TEST_PLUGIN: '1' },
});
expect(ctx.log).toHaveBeenCalledWith('info', 'plugin enabled by environment', {
plugin: 'env-enabled',
envVar: 'ENABLE_TEST_PLUGIN',
});
expect(ctx.log).toHaveBeenCalledWith(
'debug',
'plugin "disabled" not in camofox.config.json plugins list, skipping'
);
});
test('skips config-disabled plugin when its enable env var is not set', async () => {
const pluginsDir = path.join(tmpDir, 'plugins');
const configPath = path.join(tmpDir, 'camofox.config.json');
fs.mkdirSync(pluginsDir);
makePlugin(
pluginsDir,
'env-enabled',
'export async function register(app) { app.loaded.push("env-enabled"); }\n',
{ enableEnvVar: 'ENABLE_TEST_PLUGIN' }
);
fs.writeFileSync(configPath, JSON.stringify({
plugins: {
'env-enabled': { enabled: false },
},
}));
const app = { loaded: [] };
const loaded = await loadPlugins(app, makeMockCtx(), {
pluginsDir,
configPath,
env: {},
});
expect(loaded).toEqual([]);
expect(app.loaded).toEqual([]);
});
test('returns empty array when plugins directory does not exist', async () => {
const ctx = makeMockCtx();
const app = {};
const loaded = await loadPlugins(app, ctx);
expect(Array.isArray(loaded)).toBe(true);
for (const name of loaded) {
expect(typeof name).toBe('string');
}
});
test('loadPlugins registers plugins and logs them', async () => {
const ctx = makeMockCtx();
const app = {};
const loaded = await loadPlugins(app, ctx);
if (loaded.length > 0) {
const pluginLoadedCalls = ctx.log.mock.calls.filter(
([level, msg]) => level === 'info' && msg === 'plugin loaded'
);
expect(pluginLoadedCalls.length).toBe(loaded.length);
}
});
});
describe('readPluginConfig (tested indirectly via loadPlugins)', () => {
test('project camofox.config.json exists and is valid', () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.join(__dirname, '../../camofox.config.json');
expect(fs.existsSync(configPath)).toBe(true);
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
expect(config).toHaveProperty('plugins');
const isArray = Array.isArray(config.plugins);
const isObject = typeof config.plugins === 'object' && !isArray;
expect(isArray || isObject).toBe(true);
});
test('array format plugins are string lists', () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.join(__dirname, '../../camofox.config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
if (Array.isArray(config.plugins)) {
for (const name of config.plugins) {
expect(typeof name).toBe('string');
expect(name.length).toBeGreaterThan(0);
}
}
});
test('each configured plugin has an index.js', () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.join(__dirname, '../..');
const configPath = path.join(rootDir, 'camofox.config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const pluginNames = Array.isArray(config.plugins)
? config.plugins
: Object.keys(config.plugins || {});
for (const name of pluginNames) {
const indexPath = path.join(rootDir, 'plugins', name, 'index.js');
expect(fs.existsSync(indexPath)).toBe(true);
}
});
});
});