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']); });