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