357 lines
15 KiB
JavaScript
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);
|
|
});
|