import assert from 'node:assert/strict'; import {EventEmitter} from 'node:events'; import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; import vm from 'node:vm'; import test from 'node:test'; import {build} from 'esbuild'; const require = createRequire(import.meta.url); const root = fileURLToPath(new URL('../', import.meta.url)); const sources = { './bot_instance_init': 'export const BotContext = globalThis.mocks.BotContext;', '../workflow/init': 'export const init_workflow = globalThis.mocks.init_workflow;', '../../service/server/update/bot_status.ts': 'export const update_one_bot_status = globalThis.mocks.report;', }; const compiled = await build({ stdin: { contents: `export * from './bot/instance/index.ts'; export {handler} from './event_handler/bot/start_bot.ts'; export {handler as stop_handler} from './event_handler/bot/stop_bot.ts';`, resolveDir: root, loader: 'ts', }, bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, plugins: [{ name: 'mock-startup-dependencies', setup(build) { build.onResolve({filter: /.*/}, args => Object.hasOwn(sources, args.path) ? {path: args.path, namespace: 'mock'} : undefined); build.onLoad({filter: /.*/, namespace: 'mock'}, args => ({ contents: sources[args.path], loader: 'js', })); }, }], }); function deferred() { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; } function config(bot_id = 1) { return { instance_config: { username: 'TestBot', host: 'localhost', port: 25565, version: '1.20.4', bot_id, user_id: 1, }, plugins: [], workflow: [], }; } function setup() { const contexts = []; const errors = []; const reports = []; const socket = {emitted: [], emit(...args) { this.emitted.push(args); }}; const mocks = { validation: 0, connect_result: 0, connect_error: false, plugin_error: false, plugin_calls: 0, workflow_calls: 0, uninject_calls: 0, report_error: false, report_gate: null, async initialize_plugins(context) { mocks.plugin_calls++; assert.equal(context.listenerCount('status_update'), 1); context.plugin_installed = true; if (mocks.plugin_error) throw new Error('plugin initialization failed'); }, async init_workflow() { mocks.workflow_calls++; }, async report(socket, node_id, bot_id, user_id, status) { reports.push({node_id, bot_id, user_id, status, snapshot: api.get_bot_context_status()}); if (mocks.report_error) throw new Error('status reporting failed'); if (mocks.report_gate) await mocks.report_gate; }, BotContext: class extends EventEmitter { constructor(config) { super(); this.bot_config = config; this.bot_status = 'offline'; this.plugin_manager = {init: () => mocks.initialize_plugins(this), dispose: async () => { if (this.plugin_installed) { mocks.uninject_calls++; this.plugin_installed = false; } }}; this.connect_calls = 0; this.cleanup_calls = 0; this.cleanup_gate = null; this.cleanup_entered = deferred(); contexts.push(this); } validate_config() { return mocks.validation; } async connect() { this.connect_calls++; if (this.bot_status === 'online') return 462; if (this.bot_status === 'connecting') return 463; if (mocks.connect_error) throw new Error('connection failed'); if ([0, 462, 463].includes(mocks.connect_result)) { this.bot_status = mocks.connect_result === 462 ? 'online' : 'connecting'; this.emit('status_update', this.bot_status); } return mocks.connect_result; } clear_this_context() { this.cleanup_calls++; if (this.cleanup_promise) return this.cleanup_promise; this.bot_status = 'disconnecting'; this.cleanup_entered.resolve(); this.cleanup_promise = (async () => { if (this.cleanup_gate) await this.cleanup_gate; await this.plugin_manager.dispose(); this.bot_status = 'offline'; this.emit('status_update', 'offline'); this.removeAllListeners(); })(); return this.cleanup_promise; } }, }; const module = {exports: {}}; vm.runInNewContext(compiled.outputFiles[0].text, { module, exports: module.exports, require, mocks, structuredClone, console: {error: (...args) => errors.push(args)}, }); const api = module.exports; return { ...api, contexts, errors, reports, mocks, socket, start(payload = config(), node_id = 1) { return api.start_bot_context(socket, payload, node_id); }, async ack(...args) { const payload = args.length ? args[0] : {...config(), node_id: 1}; const replies = []; await api.handler(socket, payload, reply => replies.push(reply)); assert.equal(replies.length, 1); return {...replies[0]}; }, }; } test('startup rejects malformed payloads, arrays, and node/user/bot IDs before creating a context', async () => { const app = setup(); const malformed = [ null, undefined, [], 'invalid', {}, {...config(), instance_config: null}, {...config(), instance_config: []}, {...config(), plugins: null}, {...config(), plugins: {}}, {...config(), plugins: [null]}, {...config(), plugins: [[]]}, {...config(), workflow: undefined}, {...config(), workflow: {}}, {...config(), workflow: ['invalid']}, {...config(), workflow: [null]}, ]; for (const payload of malformed) { assert.equal(await app.start_bot_context(app.socket, payload, 1), 484); } for (const id of [undefined, null, '1', 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { assert.equal(await app.start_bot_context(app.socket, config(), id), 484); for (const field of ['bot_id', 'user_id']) { const payload = config(); payload.instance_config[field] = id; assert.equal(await app.start(payload), 484); } } assert.equal(app.contexts.length, 0); assert.equal(app.mocks.plugin_calls, 0); }); test('stop handler validates bot_id and ACKs stopped, busy and failed cleanup', async () => { const app = setup(); const ack = async payload => { const replies = []; await app.stop_handler(app.socket, payload, reply => replies.push({...reply})); assert.equal(replies.length, 1); return replies[0]; }; for (const payload of [null, undefined, [], 1, {}, {bot_id: '1'}, {bot_id: 0}, {bot_id: -1}, {bot_id: 1.5}, {bot_id: Number.MAX_SAFE_INTEGER + 1}]) { assert.deepEqual(await ack(payload), {status: 'error', data: 484}); } assert.deepEqual(await ack({bot_id: 1}), {status: 'success', data: 461}); assert.equal(await app.start(), 464); const context = app.contexts[0]; const gate = deferred(); context.cleanup_gate = gate.promise; const stopping = ack({bot_id: 1}); await context.cleanup_entered.promise; assert.deepEqual(await ack({bot_id: 1}), {status: 'error', data: 465}); gate.resolve(); assert.deepEqual(await stopping, {status: 'success', data: 461}); assert.equal((await app.get_bot_context_status()).length, 0); assert.deepEqual(await ack({bot_id: 1}), {status: 'success', data: 461}); await app.start(); app.contexts.at(-1).clear_this_context = async () => { throw new Error('cleanup failed'); }; assert.deepEqual(await ack({bot_id: 1}), {status: 'error', data: 466}); }); test('stop handler requires ACK callback before attempting cleanup', async () => { const app = setup(); await app.start(); await app.stop_handler(app.socket, {bot_id: 1}, undefined); assert.equal(app.contexts[0].cleanup_calls, 0); assert.equal(app.socket.emitted[0][0], 'error'); assert.equal(app.socket.emitted[0][1].error, 481); }); test('configuration validation runs before plugins and disposes rejected contexts', async () => { const app = setup(); app.mocks.validation = 403; assert.equal(await app.start(), 403); assert.equal(app.mocks.plugin_calls, 0); assert.equal(app.mocks.workflow_calls, 0); assert.equal(app.contexts[0].connect_calls, 0); assert.equal(app.contexts[0].cleanup_calls, 1); assert.equal(app.contexts[0].bot_status, 'offline'); assert.equal((await app.get_bot_context_status()).length, 0); }); test('connect failures propagate their codes, roll back, and release the lock for retry', async () => { for (const code of [403, 430, 465, 466, 467, 468]) { const app = setup(); app.mocks.connect_result = code; assert.equal(await app.start(), code); assert.equal(app.contexts[0].cleanup_calls, 1); assert.equal(app.contexts[0].bot_status, 'offline'); assert.equal(app.mocks.uninject_calls, 1); assert.equal((await app.get_bot_context_status()).length, 0); app.mocks.connect_result = 0; assert.equal(await app.start(), 464); assert.equal(app.contexts.length, 2); } }); test('plugin and connect exceptions clean partially initialized contexts before retry', async () => { for (const failure of ['plugin_error', 'connect_error']) { const app = setup(); app.mocks[failure] = true; assert.equal(await app.start(), 466); assert.equal(app.contexts[0].cleanup_calls, 1); assert.equal(app.contexts[0].bot_status, 'offline'); assert.equal(app.mocks.uninject_calls, 1); assert.equal(app.errors.length, 1); assert.equal((await app.get_bot_context_status()).length, 0); if (failure === 'plugin_error') { assert.equal(app.mocks.workflow_calls, 0); assert.equal(app.contexts[0].connect_calls, 0); } app.mocks[failure] = false; assert.equal(await app.start(), 464); } }); test('replacement awaits disposal, holds the action lock, and reports offline before removal', async () => { const app = setup(); assert.equal(await app.start(), 464); const old = app.contexts[0]; old.bot_status = 'offline'; const gate = deferred(); old.cleanup_gate = gate.promise; const replacing = app.start(); await old.cleanup_entered.promise; assert.equal(app.contexts.length, 1); assert.equal((await app.get_bot_context_status())[0].status, 'disconnecting'); assert.equal(await app.start(), 465); gate.resolve(); assert.equal(await replacing, 464); assert.equal(app.contexts.length, 2); const terminal = app.reports.find(report => report.status === 'offline'); assert.ok(terminal); assert.equal((await terminal.snapshot)[0].status, 'offline'); assert.equal((await app.get_bot_context_status())[0].status, 'connecting'); }); test('cleanup of an old context cannot remove its replacement and current cleanup removes its own entry', async () => { const app = setup(); await app.start(); const old = app.contexts[0]; old.bot_status = 'offline'; await app.start(); const current = app.contexts[1]; await old.clear_this_context(); assert.equal((await app.get_bot_context_status())[0].status, 'connecting'); assert.equal(await app.start(), 463); await current.clear_this_context(); assert.equal((await app.get_bot_context_status()).length, 0); }); test('manual start calls connect on existing active contexts and starts waiting retries immediately', async () => { const app = setup(); await app.start(); for (const [status, code] of [['online', 462], ['connecting', 463], ['ready', 464], ['reconnecting', 464], ['disconnecting', 465]]) { app.contexts[0].bot_status = status; assert.equal(await app.start(), code); } assert.equal(app.contexts.length, 1); assert.equal(app.contexts[0].cleanup_calls, 0); assert.equal(app.contexts[0].connect_calls, 5); }); test('status reporting failures are logged and pending reports do not block startup or disposal', async () => { const app = setup(); app.mocks.report_error = true; assert.equal(await app.start(), 464); assert.equal(app.errors.length, 1); assert.match(app.errors[0][0], /status update failed/); const gate = deferred(); app.mocks.report_error = false; app.mocks.report_gate = gate.promise; await app.contexts[0].clear_this_context(); assert.equal((await app.get_bot_context_status()).length, 0); assert.equal(await app.start(), 464); gate.resolve(); }); test('handler ACKs only connected, connecting, and created results as success', async () => { for (const [result, code] of [[0, 464], [462, 462], [463, 463], [403, 403], [465, 465], [466, 466], [467, 467], [468, 468]]) { const app = setup(); app.mocks.connect_result = result; assert.deepEqual(await app.ack(), { status: [462, 463, 464].includes(code) ? 'success' : 'error', data: code, }); } }); test('handler ACKs malformed requests and unexpected exceptions as errors', async () => { const app = setup(); for (const payload of [null, undefined, [], {}, {node_id: 0}, {node_id: '1'}, {node_id: Infinity}, {...config(0), node_id: 1}, {...config(), node_id: 1, plugins: null}]) { assert.deepEqual(await app.ack(payload), {status: 'error', data: 484}); } const payload = {get node_id() { throw new Error('unexpected payload failure'); }}; assert.deepEqual(await app.ack(payload), {status: 'error', data: 466}); assert.equal(app.errors.length, 1); assert.equal(app.contexts.length, 0); }); test('handler emits method-not-allowed without a callback and does not start a bot', async () => { const app = setup(); for (const callback of [undefined, null, {}, 'invalid']) { await app.handler(app.socket, {...config(), node_id: 1}, callback); } assert.equal(app.socket.emitted.length, 4); for (const [event, payload] of app.socket.emitted) { assert.equal(event, 'error'); assert.equal(payload.error, 481); } assert.equal(app.contexts.length, 0); });