681 lines
28 KiB
JavaScript
681 lines
28 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);
|
|
}
|
|
});
|