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

357 lines
15 KiB
JavaScript

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