Files
node_server/tests/plugin_manager.test.mjs
T
2026-10-22 17:07:08 +08:00

388 lines
21 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
import {build} from 'esbuild';
import {tsImport} from 'tsx/esm/api';
const url = new URL('../bot/plugins/init.ts', import.meta.url);
const compiled = await build({
stdin: {contents: (await readFile(url, 'utf8')).replace('return import(url.href);', 'return globalThis.load(url);'), loader: 'ts', resolveDir: new URL('../bot/plugins/', import.meta.url).pathname},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
define: {'import.meta.url': JSON.stringify(url.href)},
});
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const i18n = [{lang: 'en', name: 'Test', description: ''}];
const item = (name = 'a', config = {}) => ({path: 'runtime', name, config});
function setup() {
const events = [];
const modules = new Map();
const options = {};
const module = {exports: {}};
const load = async url => {
const name = url.pathname.split('/').at(-1).slice(0, -3);
if (options.load_gate) await options.load_gate;
if (name === 'define' && options.directories) return {define: {path: url.pathname.split('/').at(-2), i18n}};
return modules.get(name) ?? {init: plugin(name)};
};
function plugin(name, fields = []) {
return class {
name = name;
config_template = {name, i18n, version: '1', config: fields};
status = {count: 0};
listeners = new Set();
on_states_update(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
async init(_context, config) {
events.push(`init:${name}`);
this.status.config = config;
if (options.init_gate) await options.init_gate;
if (options.fail === name) throw new Error('install failed');
}
get_status() { return this.status; }
async dispose() {
events.push(`dispose:${name}`);
if (options.dispose_fail === name) throw new Error('cleanup failed');
}
};
}
vm.runInNewContext(compiled.outputFiles[0].text, {
module, exports: module.exports, require: name => name === 'node:fs/promises'
? {readdir: async (url, config) => options.directories
? options.directories[url.pathname.split('/').filter(Boolean).at(-1)] ?? []
: createRequire(import.meta.url)(name).readdir(url, config)}
: createRequire(import.meta.url)(name), load, URL, process, structuredClone,
});
return {...module.exports, events, modules, options, plugin, manager: new module.exports.PluginManager({})};
}
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
test('update config looks up installed path inside queue and replaces with template defaults', async () => {
const {manager, modules, plugin, events, options} = setup();
const fields = ['x', 'y'].map(name => ({name, type: 'number', default: 1, required: true, i18n}));
modules.set('a', {init: plugin('a', fields)});
const gate = deferred(); options.init_gate = gate.promise;
const injection = manager.inject({...item('a', {x: 9, y: 8}), path: 'nested/category'});
const update = manager.update_config('a', {x: 3});
await flush(); assert.deepEqual(events, ['init:a']);
gate.resolve(); await injection; await update;
assert.deepEqual(manager.get_plugin('a').config, {x: 3, y: 1});
assert.equal(manager.get_plugin('a').path, 'nested/category');
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a']);
await assert.rejects(manager.update_config('a', {unknown: 'secret'}), {reason: 'INVALID_CONFIG'});
assert.deepEqual(manager.get_plugin('a').config, {x: 3, y: 1});
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a']);
await manager.uninject('a');
await assert.rejects(manager.update_config('a', {}), {reason: 'NOT_INSTALLED'});
await manager.uninject('a');
await manager.dispose();
await assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'});
});
test('queued update follows reinjection path and cannot resurrect a disposed context', async () => {
const {manager, options, events} = setup();
await manager.inject(item());
const gate = deferred(); options.load_gate = gate.promise;
const replacement = manager.re_inject({...item(), path: 'new/category'});
const update = manager.update_config('a', {});
gate.resolve(); await replacement; await update;
assert.equal(manager.get_plugin('a').path, 'new/category');
const blocked = deferred(); options.load_gate = blocked.promise;
const pending = assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'});
const queued = assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'});
await flush(); const disposal = manager.dispose();
blocked.resolve(); await pending; await queued; await disposal;
assert.equal(manager.get_plugin('a').lifecycle, 'disabled');
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a', 'dispose:a', 'init:a', 'dispose:a']);
});
test('initialization and concurrent mutations share a queue; snapshots are detached', async () => {
const {manager, events, options} = setup();
const gate = deferred(); options.init_gate = gate.promise;
const initial = manager.init([item()]);
const second = manager.inject(item('b'));
await flush(); assert.deepEqual(events, ['init:a']);
gate.resolve(); await initial; await second;
const snapshot = manager.get_plugin('a'); snapshot.states.count = 99; snapshot.config.extra = true;
assert.equal(manager.get_plugin('a').states.count, 0);
assert.equal(manager.get_plugin('a').config.extra, undefined);
await assert.rejects(manager.inject(item()), /Duplicate/);
await manager.dispose();
assert.deepEqual(events, ['init:a', 'init:b', 'dispose:b', 'dispose:a']);
});
test('creation failure cleans partial candidate then rolls installed plugins back in reverse', async () => {
const {create_plugin_manager, options, events} = setup(); options.fail = 'c';
await assert.rejects(create_plugin_manager({}, [item('a'), item('b'), item('c')]), {reason: 'INIT_FAILED'});
assert.deepEqual(events, ['init:a', 'init:b', 'init:c', 'dispose:c', 'dispose:b', 'dispose:a']);
});
test('duplicate initial names fail before installation', async () => {
const {manager, events} = setup();
await assert.rejects(manager.init([item(), {...item(), path: 'other'}]), /Duplicate/);
assert.deepEqual(events, []);
});
test('replacement validates before old disposal; install failure retains error record', async () => {
const {manager, events, options, modules} = setup();
await manager.inject(item());
await assert.rejects(manager.re_inject(item('a', {unknown: true})), {reason: 'INVALID_CONFIG'});
assert.ok(manager.get_plugin('a')); assert.deepEqual(events, ['init:a']);
modules.set('a', {init: 1});
await assert.rejects(manager.re_inject(item()), {reason: 'LOAD_FAILED'});
assert.ok(manager.get_plugin('a'));
modules.clear(); options.fail = 'a';
await assert.rejects(manager.re_inject(item()), {reason: 'INIT_FAILED'});
assert.equal(manager.get_plugin('a').lifecycle, 'error');
assert.equal(manager.get_plugin('a').enabled, false);
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a', 'dispose:a']);
});
test('dispose during module load blocks initial, queued and new work', async () => {
const {manager, options, events} = setup();
const gate = deferred(); options.load_gate = gate.promise;
const first = assert.rejects(manager.inject(item()), /disposed/);
const queued = assert.rejects(manager.inject(item('b')), /disposed/);
await flush(); const disposal = manager.dispose();
assert.equal(manager.dispose(), disposal);
await assert.rejects(manager.inject(item('c')), /disposed/);
gate.resolve(); await first; await queued; await disposal;
assert.deepEqual(events, []);
});
test('dispose waits for in-flight init and cleans candidate; cleanup errors do not skip earlier plugins', async () => {
const {manager, options, events} = setup();
await manager.init([item('a'), item('b')]);
const gate = deferred(); options.init_gate = gate.promise; options.dispose_fail = 'b';
const pending = assert.rejects(manager.inject(item('c')), /disposed/);
await flush(); const disposal = manager.dispose();
const rejected = assert.rejects(disposal, /disposal failed/);
gate.resolve(); await pending; await rejected;
assert.deepEqual(events, ['init:a', 'init:b', 'init:c', 'dispose:c', 'dispose:b', 'dispose:a']);
assert.equal(manager.get_all_plugins().length, 3);
assert.ok(manager.get_all_plugins().every(record => !record.enabled));
});
test('validates all data types, defaults, optional nulls, required values and metadata', async () => {
const {manager, modules, plugin} = setup();
const values = {string: 'x', boolean: false, number: 3, datetime: '2026-10-20T12:00:00Z',
'string-select': 'x', 'number-select': 1, 'string-multi-select': ['x'], 'number-multi-select': [1],
'string-array': ['x'], 'number-array': [1]};
const fields = Object.entries(values).map(([type, value], index) => ({name: `f${index}`, type, default: value, required: true, i18n}));
fields.push({name: 'optional', type: 'string', default: null, required: false, i18n});
modules.set('a', {init: plugin('a', fields)});
await manager.inject(item());
assert.deepEqual(Object.values(manager.get_plugin('a').config), [...Object.values(values), null]);
for (const field of fields.slice(0, -1)) {
for (const bad of [null, {}, undefined]) await assert.rejects(manager.re_inject(item('a', {[field.name]: bad})), /Invalid plugin config/);
}
for (const bad of [NaN, Infinity, '1', [1]]) await assert.rejects(manager.re_inject(item('a', {f2: bad})), /Invalid plugin config/);
for (const bad of [[1], ['x', 1], Array(1)]) await assert.rejects(manager.re_inject(item('a', {f8: bad})), /Invalid plugin config/);
assert.ok(manager.get_plugin('a'));
fields[0].default = 1;
await assert.rejects(manager.re_inject(item()), {reason: 'LOAD_FAILED'});
await manager.dispose();
});
test('standalone scanner preserves wire metadata and auto reconnect defaults', async () => {
const {get_plugin_list, create_plugin_manager} = await tsImport('../bot/plugins/init.ts', import.meta.url);
const list = await get_plugin_list();
const reconnect = list.find(item => item.name === 'auto_reconnect');
assert.deepEqual(Object.keys(reconnect).sort(), ['name', 'path', 'path_config', 'plugin_config_template']);
const listeners = new Map();
const context = {on(event, fn) { listeners.set(event, fn); }, off(event) { listeners.delete(event); }, cancel_reconnect() {}};
const manager = await create_plugin_manager(context, [item('auto_reconnect')]);
assert.deepEqual(manager.get_plugin('auto_reconnect').config, {reconnect_delay: 1000, reconnect_max_attempts: 10});
await manager.dispose(); assert.equal(listeners.size, 0);
});
test('scanner skips missing init and rejects invalid exports, metadata and duplicate names', async () => {
const {get_plugin_list, options, modules, plugin, events} = setup();
const entry = (name, directory = false) => ({name, isDirectory: () => directory, isFile: () => !directory});
options.directories = {plugins: [entry('runtime', true)], runtime: [entry('define.ts'), entry('empty.ts'), entry('a.ts')]};
modules.set('define', {define: {path: 'runtime', i18n}});
modules.set('empty', {});
assert.equal((await get_plugin_list()).length, 1);
assert.deepEqual(events, []);
modules.set('empty', {init: undefined});
await assert.rejects(get_plugin_list(), /init class/);
modules.set('empty', {});
modules.set('a', {init: plugin('wrong')});
await assert.rejects(get_plugin_list(), /metadata/);
modules.delete('a');
options.directories.plugins.push(entry('other', true));
options.directories.other = [entry('define.ts'), entry('a.ts')];
await assert.rejects(get_plugin_list(), /Duplicate/);
});
test('lifecycle updates precede disposal, retain final states and support disabled/error retries', async () => {
const {manager, modules, plugin, options} = setup();
const Base = plugin('a');
const instances = [];
modules.set('a', {init: class extends Base {
constructor() { super(); instances.push(this); }
async dispose() {
assert.equal(manager.get_plugin('a').enabled, false);
assert.equal(manager.get_plugin('a').lifecycle, 'disabling');
this.status.count = 7;
await super.dispose();
}
}});
const updates = [];
manager.on_update(() => { throw new Error('listener failure'); });
manager.on_update(event => { event.states.count = 999; });
const unsubscribe = manager.on_update(event => updates.push(event));
await manager.inject(item());
assert.deepEqual(updates.map(event => [event.lifecycle, event.enabled]), [['initializing', false], ['enabled', true]]);
const count = updates.length;
manager.get_plugin('a'); manager.get_all_plugins();
assert.equal(updates.length, count);
await manager.uninject('a');
assert.deepEqual(updates.slice(-2).map(event => event.lifecycle), ['disabling', 'disabled']);
assert.equal(manager.get_plugin('a').states.count, 7);
assert.equal(instances[0].listeners.size, 0);
await manager.uninject('a');
assert.equal(updates.length, count + 2);
await manager.inject(item());
assert.notEqual(instances[0], instances[1]);
options.fail = 'a';
await assert.rejects(manager.update_config('a', {}), {reason: 'INIT_FAILED'});
assert.equal(manager.get_plugin('a').lifecycle, 'error');
assert.equal(manager.get_plugin('a').enabled, false);
assert.ok(instances.every(instance => instance.listeners.size === 0));
assert.equal(JSON.stringify(manager.get_plugin('a')).includes('install failed'), false);
options.fail = undefined;
unsubscribe(); unsubscribe();
const before = updates.length;
await manager.inject(item());
assert.equal(updates.length, before);
await manager.dispose();
assert.equal(manager.get_plugin('a').lifecycle, 'disabled');
assert.throws(() => manager.on_update(() => {}), /disposed/);
});
test('auto reconnect publishes live internal states, isolates clones and stops after unsubscribe/disposal', async () => {
const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url);
const {EventEmitter} = await import('node:events');
const context = new EventEmitter();
context.cancel_reconnect = () => {};
const manager = new PluginManager(context);
const updates = [];
const unsubscribe = manager.on_update(event => updates.push(event));
await manager.inject(item('auto_reconnect'));
assert.equal(updates.length, 2);
context.emit('status_update', 'online');
assert.equal(updates.length, 2);
context.emit('reconnect_attempt');
assert.equal(updates.at(-1).states.attempts, 1);
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
updates.at(-1).states.attempts = 99;
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
context.emit('connection_requested');
assert.equal(updates.at(-1).states.attempts, 0);
unsubscribe(); unsubscribe();
const before = updates.length;
context.emit('reconnect_attempt');
assert.equal(updates.length, before);
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
await manager.uninject('auto_reconnect');
context.emit('reconnect_attempt');
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
assert.equal(context.listenerCount('reconnect_attempt'), 0);
await manager.inject(item('auto_reconnect'));
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 0);
await manager.dispose();
});
test('load failures leave sanitized retryable records while invalid input leaves records unchanged', async () => {
const {manager, modules, plugin} = setup();
modules.set('a', {init: 1});
await assert.rejects(manager.inject(item()), {reason: 'LOAD_FAILED'});
assert.deepEqual(manager.get_plugin('a'), {
name: 'a', path: 'runtime', config: {}, enabled: false, lifecycle: 'error', states: null,
});
await assert.rejects(manager.inject(item('a', {unknown: () => {}})), {reason: 'INVALID_CONFIG'});
assert.equal(manager.get_plugin('a').lifecycle, 'error');
modules.set('a', {init: plugin('a')});
await manager.inject(item());
assert.equal(manager.get_plugin('a').enabled, true);
await manager.dispose();
});
test('full snapshot reconcile preserves unchanged instances and resolves omitted defaults', async () => {
const {manager, modules, plugin, events} = setup();
modules.set('a', {init: plugin('a', [{name: 'x', type: 'number', default: 1, required: true, i18n}])});
await manager.init([item('a'), item('b')]);
const previous = manager.entries.get('a');
await manager.apply_desired([item('a', {x: 1}), item('c')]);
assert.equal(manager.entries.get('a'), previous);
assert.deepEqual(events, ['init:a', 'init:b', 'dispose:b', 'init:c']);
await manager.apply_desired([item('a', {x: 2}), item('c')]);
assert.deepEqual(events.slice(-2), ['dispose:a', 'init:a']);
assert.equal(manager.get_plugin('a').config.x, 2);
await manager.apply_desired([]);
assert.ok(manager.get_all_plugins().every(entry => entry.lifecycle === 'disabled'));
await manager.dispose();
});
test('reconcile validates every desired name, path, module and config before removals', async () => {
const {manager, modules, events} = setup();
await manager.init([item('a'), item('b')]);
for (const desired of [[item('a'), {...item('a'), path: 'other'}], [{...item('a'), path: 'other'}],
[item('a'), item('c', {unknown: true})], [item('a'), item('bad')]]) {
modules.set('bad', {init: 1});
await assert.rejects(manager.apply_desired(desired));
assert.deepEqual(events, ['init:a', 'init:b']);
assert.equal(manager.get_plugin('a').enabled, true);
assert.equal(manager.get_plugin('b').enabled, true);
}
await manager.dispose();
});
test('failed removal quarantines retained entry and blocks every reinstallation route', async () => {
const {manager, modules, plugin, options, events} = setup();
modules.set('a', {init: plugin('a', [{name: 'x', type: 'number', default: 1, required: true, i18n}])});
await manager.init([item('a'), item('b')]);
options.dispose_fail = 'a';
await assert.rejects(manager.apply_desired([item('a', {x: 2})]), {reason: 'DISPOSE_FAILED'});
assert.equal(manager.entries.get('a').quarantined, true);
assert.equal(manager.get_plugin('a').lifecycle, 'error');
assert.equal(manager.get_plugin('a').error.code, 'DISPOSE_FAILED');
assert.equal(manager.get_plugin('a').enabled, false);
for (const operation of [() => manager.apply_desired([item('a')]), () => manager.apply_desired([]),
() => manager.inject(item('a')), () => manager.re_inject(item('a')), () => manager.uninject('a')]) {
await assert.rejects(operation());
}
assert.deepEqual(events, ['init:a', 'init:b', 'dispose:a']);
const before = events.length;
await assert.rejects(manager.dispose(), {reason: 'DISPOSE_FAILED'});
assert.deepEqual(events.slice(before), ['dispose:b']);
});
test('failed candidate init cleanup quarantines and cannot be retried in same context', async () => {
const {manager, options, events} = setup();
options.fail = 'a'; options.dispose_fail = 'a';
await assert.rejects(manager.apply_desired([item('a')]), {reason: 'DISPOSE_FAILED'});
options.fail = undefined; options.dispose_fail = undefined;
await assert.rejects(manager.apply_desired([item('a')]), {reason: 'DISPOSE_FAILED'});
await assert.rejects(manager.inject(item('a')));
assert.deepEqual(events, ['init:a', 'dispose:a']);
await assert.rejects(manager.dispose(), {reason: 'DISPOSE_FAILED'});
});
test('reconcile serialization observes committed defaults and disposal interrupts preflight', async () => {
const {manager, options, events} = setup();
await manager.inject(item('a'));
const gate = deferred(); options.load_gate = gate.promise;
const first = assert.rejects(manager.apply_desired([item('b')]), {reason: 'MANAGER_DISPOSED'});
const queued = assert.rejects(manager.apply_desired([]), {reason: 'MANAGER_DISPOSED'});
await flush();
const disposing = manager.dispose();
gate.resolve(); await first; await queued; await disposing;
assert.deepEqual(events, ['init:a', 'dispose:a']);
});