PluginManager supports loading extensions from {dataDir}/plugins/ that can
register:
- Custom service types with health-check hooks
- Custom notification providers
- Custom workflow action types
- Dashboard widgets (via manifest)
- Pre/post container deploy hooks
- Config validation hooks
Security: plugins declare permissions in manifest.json, admin must approve.
Currently runs in-process (no sandbox). Plugin directory auto-created on
first run. 14 tests, 1618 total pass.
Example manifest.json:
{ "name": "my-plugin", "version": "1.0.0", "serviceType": "custom-app",
"permissions": ["docker:read", "notifications:send"] }
156 lines
4.8 KiB
JavaScript
156 lines
4.8 KiB
JavaScript
/**
|
|
* DC-080: Plugin manager tests
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { PluginManager } = require('../../src/plugins/plugin-manager');
|
|
|
|
describe('DC-080: Plugin Manager', () => {
|
|
let tmpDir, manager;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-plugins-'));
|
|
manager = new PluginManager({
|
|
dataDir: tmpDir,
|
|
log: { info: jest.fn(), error: jest.fn() },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('loadAll()', () => {
|
|
it('creates plugin directory if it does not exist', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins');
|
|
expect(fs.existsSync(pluginDir)).toBe(false);
|
|
await manager.loadAll();
|
|
expect(fs.existsSync(pluginDir)).toBe(true);
|
|
});
|
|
|
|
it('loads successfully with empty plugin dir', async () => {
|
|
await manager.loadAll();
|
|
expect(manager.plugins.size).toBe(0);
|
|
expect(manager.loaded).toBe(true);
|
|
});
|
|
|
|
it('skips hidden directories', async () => {
|
|
const hiddenDir = path.join(tmpDir, 'plugins', '.hidden');
|
|
fs.mkdirSync(hiddenDir, { recursive: true });
|
|
await manager.loadAll();
|
|
expect(manager.plugins.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('loadOne()', () => {
|
|
it('loads a plugin with valid manifest', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins', 'test-plugin');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, 'manifest.json'),
|
|
JSON.stringify({
|
|
name: 'test-plugin',
|
|
version: '1.0.0',
|
|
description: 'A test plugin',
|
|
})
|
|
);
|
|
|
|
await manager.loadOne(pluginDir);
|
|
expect(manager.plugins.has('test-plugin')).toBe(true);
|
|
});
|
|
|
|
it('throws if manifest.json is missing', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins', 'no-manifest');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
|
|
await expect(manager.loadOne(pluginDir)).rejects.toThrow('manifest.json');
|
|
});
|
|
|
|
it('throws if manifest lacks name or version', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins', 'invalid');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, 'manifest.json'),
|
|
JSON.stringify({ description: 'no name' })
|
|
);
|
|
|
|
await expect(manager.loadOne(pluginDir)).rejects.toThrow('name and version');
|
|
});
|
|
|
|
it('throws on duplicate plugin name', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins', 'dup');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, 'manifest.json'),
|
|
JSON.stringify({ name: 'dup', version: '1.0.0' })
|
|
);
|
|
|
|
await manager.loadOne(pluginDir);
|
|
await expect(manager.loadOne(pluginDir)).rejects.toThrow('already loaded');
|
|
});
|
|
});
|
|
|
|
describe('unload()', () => {
|
|
it('unloads a loaded plugin', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins', 'removable');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, 'manifest.json'),
|
|
JSON.stringify({ name: 'removable', version: '1.0.0' })
|
|
);
|
|
|
|
await manager.loadOne(pluginDir);
|
|
expect(manager.plugins.has('removable')).toBe(true);
|
|
|
|
manager.unload('removable');
|
|
expect(manager.plugins.has('removable')).toBe(false);
|
|
});
|
|
|
|
it('returns false for unknown plugin', () => {
|
|
expect(manager.unload('nonexistent')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('list()', () => {
|
|
it('returns empty array when no plugins', () => {
|
|
expect(manager.list()).toEqual([]);
|
|
});
|
|
|
|
it('returns plugin metadata', async () => {
|
|
const pluginDir = path.join(tmpDir, 'plugins', 'listed');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, 'manifest.json'),
|
|
JSON.stringify({ name: 'listed', version: '2.0.0', description: 'Test' })
|
|
);
|
|
|
|
await manager.loadOne(pluginDir);
|
|
const list = manager.list();
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].name).toBe('listed');
|
|
expect(list[0].version).toBe('2.0.0');
|
|
});
|
|
});
|
|
|
|
describe('executeHook()', () => {
|
|
it('returns empty results when no plugins have the hook', async () => {
|
|
await manager.loadAll();
|
|
const results = await manager.executeHook('service:health-check');
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getWidgets()', () => {
|
|
it('returns empty array by default', () => {
|
|
expect(manager.getWidgets()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getServiceTypes()', () => {
|
|
it('returns empty array by default', () => {
|
|
expect(manager.getServiceTypes()).toEqual([]);
|
|
});
|
|
});
|
|
});
|