Collect chunk voxel data from the bot world and stream snapshot, chunk, and unwatch messages through the forward protocol.
704 lines
29 KiB
JavaScript
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();
|
|
});
|