chore: initial commit
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user