Files
node_server/tests/bot_lifecycle.test.mjs
2508932142 6c69afb38a feat: add world voxel snapshot forwarding
Collect chunk voxel data from the bot world and stream snapshot,
chunk, and unwatch messages through the forward protocol.
2026-10-23 10:44:37 +08:00

704 lines
29 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';
import {readFile} from 'node:fs/promises';
const require = createRequire(import.meta.url);
const root = fileURLToPath(new URL('../', import.meta.url));
const compiled = await build({
stdin: {
contents: `export {BotContext} from './bot/instance/bot_instance_init.ts';
export {init} from './bot/plugins/runtime/auto_reconnect.ts';`,
resolveDir: root,
loader: 'ts',
},
bundle: true,
platform: 'node',
format: 'cjs',
packages: 'external',
write: false,
define: {'import.meta.url': JSON.stringify(new URL('../bot/plugins/init.ts', import.meta.url).href)},
plugins: [{
name: 'mock-bot-and-io',
setup(build) {
build.onLoad({filter: /bot\/plugins\/init\.ts$/}, async args => ({
contents: (await readFile(args.path, 'utf8')).replace('return import(url.href);', 'return globalThis.mocks.load_plugin(url);'), loader: 'ts',
}));
build.onResolve({filter: /@wp2508\/mineflayer$|utils\/(logger|path_utils|validate)\.ts$|(?:info|function)\/init\.ts$/}, args => ({
path: args.path,
namespace: 'mock',
}));
build.onLoad({filter: /.*/, namespace: 'mock'}, args => ({
contents: args.path.endsWith('mineflayer') ? 'export const createBot = globalThis.mocks.createBot;'
: args.path.endsWith('info/init.ts') ? 'export const create_info_manager = (bot, options) => globalThis.mocks.create_info_manager(bot, options);'
: args.path.endsWith('function/init.ts') ? 'export const create_function_manager = context => globalThis.mocks.create_function_manager(context);'
: args.path.endsWith('logger.ts') ? 'export const Logger = globalThis.mocks.Logger;'
: args.path.endsWith('path_utils.ts') ? 'export const get_project_root_path = () => "/tmp";'
: `export const validate_username = () => globalThis.mocks.validation;
export const validate_host = () => 0;
export const validate_port = value => {
globalThis.mocks.validated_ports.push(value);
if (typeof value !== 'number') return 420;
if (Number.isNaN(value)) return 422;
if (!Number.isInteger(value)) return 421;
return value < 0 || value > 65535 ? 423 : 0;
};
export const validate_version = () => 0;
export const validate_proxy = () => 0;`,
loader: 'js',
}));
},
}],
});
const flush = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
function setup() {
const bots = [];
const managers = [];
const functions = [];
const timers = new Map();
let timer_id = 0;
const mocks = {
load_plugin: async () => ({init: module.exports.init}),
validation: 0,
validated_ports: [],
created_options: [],
fail_create: false,
connect_mode: 'pending',
silent_end: false,
info_gate: undefined,
fail_info: false,
fail_info_subscribe: false,
async create_function_manager(context) {
const manager = {disposed: false, async run() { return true; }, dispose() {
this.disposed = true;
this.disposed_before_end = !bots.at(-1).ends;
}};
functions.push(manager);
return manager;
},
async create_info_manager(bot, options) {
if (mocks.info_gate) await mocks.info_gate;
if (mocks.fail_info) throw new Error('info loading failed');
const callbacks = new Set();
const manager = {
options,
disposed: false,
unsubscribe_count: 0,
get_info: name => name === 'health' ? {name, value: 20} : undefined,
get_all_info: () => [{name: 'health', value: 20}],
on_update(callback) {
if (mocks.fail_info_subscribe) throw new Error('info subscription failed');
callbacks.add(callback);
manager.stale_callback = callback;
return () => { callbacks.delete(callback); manager.unsubscribe_count++; };
},
update(value) { for (const callback of callbacks) callback({name: 'health', value}); },
dispose() {
if (manager.disposed) return;
manager.disposed = true;
manager.disposed_before_end = bot.ends === 0;
callbacks.clear();
},
};
managers.push(manager);
return manager;
},
Logger: class {
custom(text) { return text; }
info(text) { return text; }
error(text) { return text; }
warning(text) { return text; }
log() {}
async close() { this.closed = true; }
},
createBot(options) {
mocks.created_options.push(options);
assert.equal(options.autoConnect, false);
if (mocks.fail_create) throw new Error('creation failed');
const bot = new EventEmitter();
bot._client = new EventEmitter();
const any = [];
const emit = bot.emit;
bot.connects = 0;
bot.ends = 0;
bot.onAny = callback => any.push(callback);
bot.emit = function(event, ...args) {
const result = emit.call(this, event, ...args);
for (const callback of any) callback(event, ...args);
return result;
};
bot.connect = () => {
bot.connects++;
if (mocks.connect_mode === 'throw') throw new Error('connect failed');
if (mocks.connect_mode === 'error') bot.emit('error', new Error('connection error'));
if (mocks.connect_mode === 'login') bot.emit('initialLogin');
};
bot.end = () => {
bot.ends++;
if (!mocks.silent_end) bot.emit('end', 'closed');
};
bots.push(bot);
return bot;
},
};
const module = {exports: {}};
vm.runInNewContext(compiled.outputFiles[0].text, {
module, exports: module.exports, require, mocks, process, console, queueMicrotask, URL, structuredClone,
setTimeout: (callback, delay) => {
const id = ++timer_id;
timers.set(id, {callback, delay});
return id;
},
clearTimeout: id => timers.delete(id),
clearInterval: id => timers.delete(id),
});
const context = new module.exports.BotContext({
username: 'TestBot', host: 'localhost', port: 25565, version: '1.20.4', bot_id: 1, user_id: 1,
});
const statuses = [];
context.on('status_update', status => {
assert.equal(context.bot_status, status);
statuses.push(status);
});
return {
context, mocks, bots, managers, functions, timers, statuses,
plugin(config = {}) {
return context.plugin_manager.inject({path: 'runtime', name: 'auto_reconnect', config: {reconnect_delay: 10, reconnect_max_attempts: 2, ...config}});
},
async tick(delay) {
const entry = [...timers.entries()].find(([, timer]) => timer.delay === delay);
assert.ok(entry, `missing timer for ${delay}ms`);
timers.delete(entry[0]);
entry[1].callback();
await flush();
},
};
}
test('function manager follows instance creation, disconnect and replacement', async () => {
const {context, functions} = setup();
assert.equal(context.function_manager, null);
context.on('instance_created', () => assert.equal(context.function_manager, functions.at(-1)));
await context.connect();
const first = context.function_manager;
await context.disconnect();
assert.equal(context.function_manager, null);
assert.equal(first.disposed, true);
assert.equal(first.disposed_before_end, true);
await context.connect();
assert.notEqual(context.function_manager, first);
await context.clear_this_context();
assert.equal(functions.at(-1).disposed, true);
});
test('info manager is available before instance_created and forwards updates across reconnects', async () => {
const {context, bots, managers} = setup();
const updates = [];
let created = 0;
assert.equal(context.info_manager, null);
context.on('instance_created', () => {
created++;
assert.equal(context.info_manager, managers.at(-1));
assert.equal(managers.at(-1).options.bot_id, context.bot_config.bot_id);
assert.equal(context.info_manager.get_info('health').value, 20);
assert.equal(bots.at(-1).connects, 0);
});
context.on('info_update', event => updates.push({...event}));
assert.equal(await context.connect(), 0);
managers[0].update(18);
// Calling connect again changes the operation sequence but must preserve updates.
context.cancel_reconnect();
managers[0].update(17);
assert.deepEqual(updates.map(event => event.value), [18, 17]);
await context.disconnect();
assert.equal(context.info_manager, null);
assert.equal(managers[0].disposed, true);
assert.equal(managers[0].disposed_before_end, true);
assert.equal(managers[0].unsubscribe_count, 1);
await context.connect();
assert.equal(created, 2);
assert.notEqual(managers[0], context.info_manager);
managers[0].stale_callback({name: 'health', value: 1});
managers[1].update(19);
assert.deepEqual(updates.map(event => event.value), [18, 17, 19]);
await context.clear_this_context();
assert.equal(context.info_manager, null);
assert.equal(managers[1].disposed, true);
});
test('info loading blocks connection and initialization failures roll back without success events', async () => {
for (const failure of ['fail_info', 'fail_info_subscribe']) {
const {context, mocks, bots, managers} = setup();
let release;
mocks.info_gate = new Promise(resolve => { release = resolve; });
let created = 0;
const logs = [];
context.on('instance_created', () => created++);
context.on('log', segments => logs.push(...segments));
const pending = context.connect();
await flush();
assert.equal(bots[0].connects, 0);
assert.equal(context.info_manager, null);
assert.equal(created, 0);
mocks[failure] = true;
release();
assert.equal(await pending, 466);
assert.equal(context.bot_instance, null);
assert.equal(context.info_manager, null);
assert.equal(context.bot_status, 'offline');
assert.equal(bots[0].ends, 1);
assert.equal(created, 0);
assert.equal(logs.includes('[NODE] 创建机器人实例成功'), false);
assert.ok(managers.every(manager => manager.disposed));
await context.clear_this_context();
}
});
test('stop and disposal during info loading discard the late manager without connecting', async () => {
for (const stop of ['disconnect', 'clear_this_context']) {
const {context, mocks, bots, managers} = setup();
let release;
mocks.info_gate = new Promise(resolve => { release = resolve; });
const pending = context.connect();
await flush();
const stopped = context[stop]();
release();
assert.equal(await pending, stop === 'disconnect' ? 468 : 467);
await stopped;
assert.equal(bots[0].connects, 0);
assert.equal(bots[0].ends, 1);
assert.equal(managers[0].disposed, true);
assert.equal(context.bot_instance, null);
assert.equal(context.info_manager, null);
assert.equal(context.bot_status, 'offline');
await context.clear_this_context();
}
});
test('terminal events during info loading cannot install a stale manager or duplicate retries', async () => {
const {context, mocks, bots, managers, timers, plugin, tick} = setup();
await plugin();
await context.connect();
bots[0].emit('end');
await flush();
let release;
mocks.info_gate = new Promise(resolve => { release = resolve; });
let lost = 0;
context.on('connection_lost', () => lost++);
await tick(10);
assert.equal(bots.length, 2);
bots[1].emit('error', new Error('failed while loading info'));
await flush();
release();
await flush();
assert.equal(managers[1].disposed, true);
assert.equal(bots[1].connects, 0);
assert.equal(context.info_manager, null);
assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 1);
assert.equal(lost, 1);
assert.equal(timers.size, 1);
mocks.info_gate = undefined;
await tick(10);
assert.equal(context.info_manager, managers[2]);
assert.equal(bots[2].connects, 1);
await context.clear_this_context();
});
test('info manager is disposed before timeout cleanup even when end is never emitted', async () => {
const {context, mocks, managers, tick} = setup();
await context.connect();
mocks.silent_end = true;
const stopped = context.disconnect();
await flush();
assert.equal(context.bot_status, 'disconnecting');
assert.equal(context.info_manager, null);
assert.equal(managers[0].disposed, true);
assert.equal(managers[0].disposed_before_end, true);
await tick(1000);
await stopped;
assert.equal(context.bot_status, 'offline');
await context.clear_this_context();
});
test('instance_created listener can cancel initialization and dispose the installed manager', async () => {
const {context, bots, managers} = setup();
let stopped;
context.on('instance_created', () => { stopped = context.disconnect(); });
assert.equal(await context.connect(), 468);
await stopped;
assert.equal(bots[0].connects, 0);
assert.equal(managers[0].disposed, true);
assert.equal(context.info_manager, null);
await context.clear_this_context();
});
test('creation logs once on direct creation and connect, but never on failure', async () => {
for (const method of ['create_instance', 'connect']) {
const {context} = setup();
const logs = [];
context.on('log', segments => logs.push(...segments));
assert.equal(await context[method](), 0);
assert.deepEqual(logs, ['[NODE] 创建机器人实例成功']);
await context.create_instance();
assert.equal(logs.length, 1);
await context.clear_this_context();
const failed = setup();
const failed_logs = [];
failed.context.on('log', segments => failed_logs.push(...segments));
failed.mocks.fail_create = true;
assert.notEqual(await failed.context[method](), 0);
assert.deepEqual(failed_logs, []);
await failed.context.clear_this_context();
}
});
test('first connect creates once; create leaves ready; concurrent connects never duplicate', async () => {
const {context, bots, statuses} = setup();
assert.equal(await context.create_instance(), 0);
assert.equal(context.bot_status, 'ready');
assert.equal(bots[0].connects, 0);
const results = await Promise.all([context.connect(), context.connect()]);
assert.deepEqual(results, [0, 463]);
assert.equal(bots.length, 1);
assert.equal(bots[0].connects, 1);
bots[0].emit('initialLogin');
assert.equal(context.bot_status, 'online');
assert.equal(await context.connect(), 462);
assert.deepEqual(statuses.slice(0, 3), ['ready', 'connecting', 'online']);
await context.clear_this_context();
assert.equal(context.bot_status, 'offline');
assert.equal(context.logger.closed, true);
});
test('null and omitted ports skip validation and are absent from creation options', async () => {
for (const omitted of [false, true]) {
const {context, mocks} = setup();
if (omitted) delete context.bot_config.port;
else context.bot_config.port = null;
assert.equal(context.validate_config(context.bot_config), 0);
assert.equal(await context.connect(), 0);
assert.equal(context.bot_status, 'connecting');
assert.deepEqual(mocks.validated_ports, []);
assert.equal(Object.hasOwn(mocks.created_options[0], 'port'), false);
await context.clear_this_context();
}
});
test('explicit ports are validated and passed through without substituting defaults', async () => {
for (const port of [0, 25565, 25566]) {
const {context, mocks} = setup();
context.bot_config.port = port;
assert.equal(await context.connect(), 0);
assert.equal(mocks.created_options[0].port, port);
assert.deepEqual(mocks.validated_ports, [port]);
await context.clear_this_context();
}
for (const port of [-1, 65536, '25565', '', 1.5]) {
const {context, mocks} = setup();
context.bot_config.port = port;
assert.notEqual(await context.connect(), 0);
assert.equal(mocks.created_options.length, 0);
await context.clear_this_context();
}
});
test('fresh connect auto-creates; validation and factory failures do not report success', async () => {
const {context, mocks, bots} = setup();
mocks.validation = 404;
assert.equal(await context.connect(), 404);
assert.equal(bots.length, 0);
mocks.validation = 0;
mocks.fail_create = true;
assert.equal(await context.connect(), 466);
assert.equal(context.bot_status, 'offline');
mocks.fail_create = false;
assert.equal(await context.connect(), 0);
assert.equal(bots.length, 1);
await context.clear_this_context();
});
test('connection exceptions and synchronous error events clean up to offline', async () => {
for (const mode of ['throw', 'error']) {
const {context, mocks, bots} = setup();
mocks.connect_mode = mode;
assert.equal(await context.connect(), 466);
assert.equal(context.bot_instance, null);
assert.equal(context.bot_status, 'offline');
assert.equal(bots[0].ends, 1);
await context.clear_this_context();
}
});
test('duplicate terminal events clean once; old callbacks cannot clear or log in new instance', async () => {
const {context, bots} = setup();
await context.connect();
const old = bots[0];
const old_end = old.listeners('end')[0];
const old_login = old.listeners('initialLogin')[0];
let lost = 0;
context.on('connection_lost', () => lost++);
old.emit('error', new Error('failed'));
old.emit('kicked', 'kicked');
old.emit('end', 'closed again');
await flush();
assert.equal(lost, 1);
assert.equal(old.ends, 1);
await context.connect();
const current = context.bot_instance;
old_end(); old_login();
assert.equal(context.bot_instance, current);
assert.equal(context.bot_status, 'connecting');
await context.clear_this_context();
});
test('disconnect cancels pending connect, including connecting instances; no reconnect after manual stop', async () => {
const {context, bots, timers, plugin} = setup();
await plugin();
const pending = context.connect();
const stopped = context.disconnect();
assert.equal(await pending, 468);
assert.equal(await stopped, 0);
assert.equal(bots.length, 0);
await context.connect();
await context.disconnect();
assert.equal(context.bot_status, 'offline');
assert.equal(timers.size, 0);
await context.clear_this_context();
});
test('cleanup preserves end callbacks and waits for timeout fallback before replacement', async () => {
const {context, mocks, bots, tick} = setup();
await context.connect();
let internal_cleanup = 0;
bots[0].on('end', () => internal_cleanup++);
await context.disconnect();
assert.equal(internal_cleanup, 1);
await context.connect();
mocks.silent_end = true;
const stopped = context.disconnect();
await flush();
assert.equal(context.bot_status, 'disconnecting');
const next = context.connect();
await flush();
assert.equal(bots.length, 2);
await tick(1000);
await stopped;
assert.equal(await next, 0);
assert.equal(bots.length, 3);
mocks.silent_end = false;
await context.clear_this_context();
});
test('reconnect uses new instances, caps failures, and resets attempts only after login', async () => {
const {context, mocks, bots, timers, plugin, tick} = setup();
await plugin();
await context.connect();
bots[0].emit('end'); await flush();
assert.equal(context.bot_status, 'reconnecting');
assert.equal(timers.size, 1);
mocks.fail_create = true;
await tick(10);
assert.equal(context.bot_status, 'reconnecting');
await tick(10);
assert.equal(context.bot_status, 'offline');
assert.equal(timers.size, 0);
assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 2);
mocks.fail_create = false;
await context.connect();
bots.at(-1).emit('end'); await flush();
mocks.connect_mode = 'login';
await tick(10);
assert.equal(context.bot_status, 'online');
assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 0);
await context.clear_this_context();
});
test('uninject cancels queued retry; disposal is terminal and idempotent', async () => {
const {context, bots, timers, plugin} = setup();
await plugin();
await context.connect();
bots[0].emit('end'); await flush();
await context.plugin_manager.uninject('auto_reconnect');
assert.equal(context.bot_status, 'offline');
assert.equal(timers.size, 0);
await plugin();
await context.connect();
bots.at(-1).emit('end'); await flush();
await Promise.all([context.clear_this_context(), context.clear_this_context()]);
assert.equal(timers.size, 0);
assert.equal(context.bot_status, 'offline');
assert.equal(await context.connect(), 467);
assert.equal(await context.create_instance(), 467);
assert.equal(await context.disconnect(), 467);
await context.schedule_reconnect(10);
assert.equal(timers.size, 0);
});
test('plugin manager persists across disconnect and reconnect and disposes before context listeners', async () => {
const {context, plugin} = setup();
const manager = context.plugin_manager;
await plugin();
await context.connect();
await context.disconnect();
assert.equal(context.plugin_manager, manager);
assert.ok(manager.get_plugin('auto_reconnect'));
await context.connect();
assert.equal(context.plugin_manager, manager);
assert.ok(manager.get_plugin('auto_reconnect'));
await context.clear_this_context();
assert.equal(manager.get_plugin('auto_reconnect').lifecycle, 'disabled');
await assert.rejects(manager.inject({path: 'runtime', name: 'auto_reconnect', config: {}}), /disposed/);
});
test('context disposal during plugin init does not hold the instance lock needed by the plugin', async () => {
const {context, mocks} = setup();
let resume;
const gate = new Promise(resolve => { resume = resolve; });
let cleaned = false;
mocks.load_plugin = async () => ({init: class {
name = 'test';
config_template = {name: 'test', version: '1', i18n: [{lang: 'en', name: 'Test', description: ''}], config: []};
async init() { await gate; await context.schedule_reconnect(10); }
get_status() { return {}; }
on_states_update() { return () => {}; }
async dispose() { cleaned = true; }
}});
const loading = assert.rejects(context.plugin_manager.inject({name: 'test', path: 'runtime', config: {}}), /disposed/);
await flush();
const disposal = context.clear_this_context();
resume();
await loading; await disposal;
assert.equal(cleaned, true);
});
test('manual connect resets retry attempts, cancels waiting timer, and preserves active transport', async () => {
const {context, mocks, bots, timers, plugin, tick} = setup();
await plugin();
await context.connect();
bots[0].emit('end'); await flush();
mocks.fail_create = true;
await tick(10);
const retry_status = () => context.plugin_manager.get_plugin('auto_reconnect').states;
assert.equal(retry_status().attempts, 1);
assert.equal(context.bot_status, 'reconnecting');
const stale_retry = [...timers.values()][0].callback;
mocks.fail_create = false;
assert.equal(await context.connect(), 0);
assert.equal(retry_status().attempts, 0);
assert.equal(timers.size, 0);
assert.equal(context.bot_status, 'connecting');
const current = bots.at(-1);
stale_retry(); await flush();
assert.equal(bots.length, 2);
assert.equal(current.connects, 1);
context.emit('reconnect_attempt');
assert.equal(await context.connect(), 463);
assert.equal(retry_status().attempts, 0);
assert.equal(current.connects, 1);
current.emit('initialLogin');
context.emit('reconnect_attempt');
assert.equal(await context.connect(), 462);
assert.equal(retry_status().attempts, 0);
assert.equal(current.connects, 1);
await context.clear_this_context();
});
test('status listener can cancel a connection before the transport starts', async () => {
const {context, bots} = setup();
let stopped;
context.on('status_update', status => {
if (status === 'connecting') stopped = context.disconnect();
});
assert.equal(await context.connect(), 468);
await stopped;
assert.equal(bots[0].connects, 0);
assert.equal(context.bot_instance, null);
assert.equal(context.bot_status, 'offline');
await context.clear_this_context();
});
test('synchronous retry failures produce one retry per attempt', async () => {
const {context, mocks, bots, plugin, tick, timers} = setup();
await plugin();
await context.connect();
bots[0].emit('end'); await flush();
mocks.connect_mode = 'error';
await tick(10);
assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 1);
assert.equal(timers.size, 1);
await tick(10);
assert.equal(context.plugin_manager.get_plugin('auto_reconnect').states.attempts, 2);
assert.equal(context.bot_status, 'offline');
assert.equal(timers.size, 0);
await context.clear_this_context();
});
test('disposal invalidates pending retry callbacks and status listener reentry', async () => {
const {context, bots, timers, plugin} = setup();
await plugin();
await context.connect();
bots[0].emit('end'); await flush();
const queued = [...timers.values()][0].callback;
const disposed = context.clear_this_context();
queued();
await disposed;
assert.equal(bots.length, 1);
assert.equal(context.bot_status, 'offline');
assert.equal(timers.size, 0);
const second = setup();
let cleanup;
second.context.on('status_update', status => {
if (status === 'ready') cleanup = second.context.clear_this_context();
});
assert.equal(await second.context.create_instance(), 467);
await cleanup;
assert.equal(second.bots[0].connects, 0);
assert.equal(second.context.bot_status, 'offline');
});
test('context disposal reports offline once even when no instance was created', async () => {
for (const connect of [false, true]) {
const {context, statuses} = setup();
if (connect) await context.connect();
await context.clear_this_context();
await context.clear_this_context();
assert.equal(context.bot_status, 'offline');
assert.equal(statuses.at(-1), 'offline');
assert.equal(statuses.filter(status => status === 'offline').length, 1);
assert.equal(statuses.includes('disposed'), false);
assert.equal(await context.connect(), 467);
}
});
test('throwing log observers cannot interrupt socketClosed cleanup or offline reporting', async () => {
const {context, bots, statuses, managers, functions, timers} = setup();
await context.connect();
bots[0].emit('initialLogin');
const written = [];
context.logger.log = segments => written.push(...segments);
context.on('log', () => { throw new Error('log observer failed'); });
let lost = 0;
context.on('connection_lost', () => lost++);
assert.doesNotThrow(() => bots[0].emit('end', 'socketClosed'));
await flush();
assert.equal(context.bot_instance, null);
assert.equal(context.bot_status, 'offline');
assert.deepEqual(statuses.slice(-2), ['disconnecting', 'offline']);
assert.ok(written.includes('连接已断开: socketClosed'));
assert.ok(written.includes('[NODE] 机器人实例已关闭'));
assert.equal(managers[0].disposed, true);
assert.equal(functions[0].disposed, true);
assert.equal(lost, 1);
assert.equal(timers.size, 0);
await context.clear_this_context();
});