714 lines
35 KiB
JavaScript
714 lines
35 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {randomUUID} from 'node:crypto';
|
|
import {EventEmitter} from 'node:events';
|
|
import {createRequire} from 'node:module';
|
|
import {fileURLToPath} from 'node:url';
|
|
import vm from 'node:vm';
|
|
import test from 'node:test';
|
|
import {build} from 'esbuild';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
const sources = {
|
|
'./bot_instance_init': 'export const BotContext = globalThis.mocks.BotContext;',
|
|
'../workflow/init': 'export const init_workflow = async () => {};',
|
|
'../../service/server/update/bot_status.ts': 'export const update_one_bot_status = async () => {};',
|
|
};
|
|
const compiled = await build({
|
|
stdin: {
|
|
contents: `export * from './bot/forward/stream.ts'; export * from './bot/instance/index.ts'; export * from './utils/command_completion.ts'; export {init as InventoryInfo} from './bot/info/bot/inventory.ts';`,
|
|
resolveDir: root, loader: 'ts',
|
|
},
|
|
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
|
|
plugins: [{
|
|
name: 'mock-stream-lifecycle',
|
|
setup(build) {
|
|
build.onResolve({filter: /.*/}, args => Object.hasOwn(sources, args.path)
|
|
? {path: args.path, namespace: 'mock'} : undefined);
|
|
build.onLoad({filter: /.*/, namespace: 'mock'}, args => ({contents: sources[args.path], loader: 'js'}));
|
|
},
|
|
}],
|
|
});
|
|
|
|
function setup() {
|
|
const contexts = [];
|
|
const mocks = {
|
|
cleanup_error: false,
|
|
BotContext: class extends EventEmitter {
|
|
constructor(config) {
|
|
super();
|
|
this.bot_config = {...config, secret: 'SECRET'};
|
|
this.bot_status = 'offline';
|
|
this.plugin_manager = {async init() {}, async dispose() {}};
|
|
this.bot_instance = null;
|
|
this.logger = {get_recent_log() { throw new Error('Disk access forbidden'); }};
|
|
contexts.push(this);
|
|
}
|
|
validate_config() { return 0; }
|
|
async connect() {
|
|
this.bot_status = 'online';
|
|
this.sent = [];
|
|
this.bot_instance = {chat: message => this.sent.push(message)};
|
|
this.emit('status_update', 'online');
|
|
return 0;
|
|
}
|
|
async clear_this_context() {
|
|
this.bot_status = 'offline';
|
|
this.bot_instance = null;
|
|
this.emit('status_update', 'offline');
|
|
if (mocks.cleanup_error) throw new Error('SECRET disposal failure');
|
|
this.removeAllListeners();
|
|
}
|
|
},
|
|
};
|
|
const module = {exports: {}};
|
|
vm.runInNewContext(compiled.outputFiles[0].text, {
|
|
module, exports: module.exports, require, mocks, console, Buffer, structuredClone, setTimeout, clearTimeout,
|
|
});
|
|
const api = module.exports;
|
|
return {
|
|
...api, contexts, mocks,
|
|
start: () => api.start_bot_context({}, {
|
|
instance_config: {bot_id: 1, user_id: 1}, plugins: [], workflow: [],
|
|
}, 1),
|
|
};
|
|
}
|
|
|
|
function source() {
|
|
let current;
|
|
const observers = new Set();
|
|
return {
|
|
get: () => current,
|
|
observe(id, callback) {
|
|
observers.add(callback);
|
|
callback(current);
|
|
return () => observers.delete(callback);
|
|
},
|
|
set(context) {
|
|
current = context;
|
|
for (const callback of observers) callback(context);
|
|
},
|
|
get count() { return observers.size; },
|
|
};
|
|
}
|
|
|
|
function context() {
|
|
return Object.assign(new EventEmitter(), {
|
|
bot_status: 'online', bot_instance: {chat() {}}, secret: 'SECRET',
|
|
logger: {get_recent_log() { throw new Error('Disk access forbidden'); }},
|
|
});
|
|
}
|
|
|
|
const segment = message => ({message, font_color: '#abcdef', font_style: ['normal']});
|
|
const plain = value => JSON.parse(JSON.stringify(value));
|
|
const fails = (action, code) => assert.throws(action, error => error.code === code);
|
|
|
|
test('context snapshot accepts the complete inventory translation tree alongside other information', () => {
|
|
const {BotStream, InventoryInfo} = setup();
|
|
const instance = Object.assign(new EventEmitter(), {_client: new EventEmitter(), inventory: null, currentWindow: null});
|
|
const inventory = new InventoryInfo(instance);
|
|
const context_source = source();
|
|
const bot = context();
|
|
const records = [
|
|
{name: 'health', i18n: {lang: 'zh-CN', name: '生命值', description: ''}, value: 20},
|
|
{name: inventory.name, i18n: inventory.i18n, value: inventory.get_info()},
|
|
];
|
|
bot.info_manager = {get_all_info: () => records};
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source, now: () => 100});
|
|
try {
|
|
assert.deepEqual(plain(stream.get_context_info({}, 200)), plain(records));
|
|
} finally { stream.dispose(); inventory.dispose(); }
|
|
});
|
|
|
|
test('context info is online-only, cloned and rechecks state after reading', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const stream = new BotStream(1, {source: context_source, now: () => 100});
|
|
const read = () => stream.get_context_info({}, 200);
|
|
fails(read, 'NO_CONTEXT');
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
for (const status of ['offline', 'ready', 'connecting', 'disconnecting', 'reconnecting']) {
|
|
bot.bot_status = status;
|
|
fails(read, 'BOT_NOT_ONLINE');
|
|
}
|
|
bot.bot_status = 'online';
|
|
fails(read, 'INTERNAL_ERROR');
|
|
const records = [{name: 'health', i18n: {lang: 'zh-CN', name: 'Health', description: ''}, value: {health: 20}}];
|
|
bot.info_manager = {get_all_info: () => records};
|
|
assert.deepEqual(plain(read()), records);
|
|
const result = read(); result[0].value.health = 1;
|
|
assert.equal(records[0].value.health, 20);
|
|
fails(() => stream.get_context_info({extra: true}, 200), 'INVALID_REQUEST');
|
|
fails(() => stream.get_context_info({}, 100), 'TIMEOUT');
|
|
bot.info_manager.get_all_info = () => {bot.bot_instance = null; return records;};
|
|
fails(read, 'CANCELLED');
|
|
fails(read, 'BOT_NOT_ONLINE');
|
|
stream.dispose();
|
|
fails(read, 'CANCELLED');
|
|
});
|
|
|
|
test('context info rejects malformed, cyclic, oversized and non-JSON module results without invoking getters', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source(); const bot = context(); context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const record = {name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20};
|
|
const cyclic = {}; cyclic.self = cyclic;
|
|
let getter_calls = 0;
|
|
const getter = Object.defineProperty({}, 'value', {enumerable: true, get() {getter_calls++; return 1;}});
|
|
const deep = {}; let leaf = deep;
|
|
for (let i = 0; i < 70; i++) leaf = leaf.next = {};
|
|
const sparse = []; sparse.length = 2;
|
|
for (const value of [undefined, NaN, Infinity, 1n, () => {}, cyclic, getter, new Date(), sparse,
|
|
{toJSON() {throw new Error('must not call');}}, 'x'.repeat(4 * 1024 * 1024 + 1), deep]) {
|
|
bot.info_manager = {get_all_info: () => [{...record, value}]};
|
|
fails(() => stream.get_context_info({}, Date.now() + 1000), 'INTERNAL_ERROR');
|
|
}
|
|
for (const records of [[{...record, name: 'bad.name'}], [record, record],
|
|
[{...record, i18n: {...record.i18n, fields: {x: {lang: 'en', name: 'X'}}}}],
|
|
[{...record, i18n: {...record.i18n, values: []}}],
|
|
[{...record, i18n: {...record.i18n, extra: true}}], [{...record, secret: true}],
|
|
[{...record, i18n: {...record.i18n, name: 'x'.repeat(257)}}], Array(257).fill(record)]) {
|
|
bot.info_manager = {get_all_info: () => records};
|
|
fails(() => stream.get_context_info({}, Date.now() + 1000), 'INTERNAL_ERROR');
|
|
}
|
|
assert.equal(getter_calls, 0);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('info updates share log ordering, clone values and detach stale contexts', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source(); const bot = context(); context_source.set(bot);
|
|
bot.info_manager = {get_all_info: () => []};
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const events = []; stream.subscribe(event => events.push(event));
|
|
bot.emit('log', [segment('before')]);
|
|
const input = {name: 'health', value: {health: 19}};
|
|
bot.emit('info_update', input); input.value.health = 1;
|
|
bot.emit('log', [segment('after')]);
|
|
assert.deepEqual(events.map(event => event.name), ['log', 'info_update', 'log']);
|
|
assert.equal(events[1].payload.value.health, 19);
|
|
assert.ok(events[0].seq < events[1].seq && events[1].seq < events[2].seq);
|
|
bot.bot_status = 'offline'; bot.emit('info_update', {name: 'health', value: 0});
|
|
assert.equal(events.length, 3);
|
|
bot.bot_status = 'online';
|
|
const stale = bot.listeners('info_update')[0];
|
|
context_source.set(context());
|
|
const count = events.length;
|
|
stale({name: 'health', value: 1});
|
|
assert.equal(events.length, count);
|
|
assert.equal(bot.listenerCount('info_update'), 0);
|
|
stream.dispose();
|
|
assert.equal(context_source.get().listenerCount('info_update'), 0);
|
|
});
|
|
|
|
test('invalid info pushes are skipped without interrupting later valid updates', t => {
|
|
const diagnostics = [];
|
|
t.mock.method(console, 'error', message => diagnostics.push(message));
|
|
const {BotStream} = setup();
|
|
const context_source = source(); const bot = context(); context_source.set(bot);
|
|
bot.info_manager = {get_all_info: () => []};
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const events = []; stream.subscribe(event => events.push(event));
|
|
let called = false;
|
|
const accessor = Object.defineProperty({name: 'health'}, 'value', {
|
|
enumerable: true, get() {called = true; return 10;},
|
|
});
|
|
for (const update of [accessor, {name: 'health', value: NaN}, {name: 'health', value: 1, extra: true}])
|
|
bot.emit('info_update', update);
|
|
assert.equal(called, false);
|
|
assert.equal(events.length, 0);
|
|
assert.equal(diagnostics.length, 3);
|
|
bot.emit('info_update', {name: 'health', value: 12});
|
|
assert.equal(events.length, 1);
|
|
assert.equal(events[0].payload.value, 12);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('deep and large inventory values work for both queries and pushes with bounded diagnostics', t => {
|
|
const diagnostics = [];
|
|
t.mock.method(console, 'error', (...args) => diagnostics.push(args));
|
|
const {BotStream} = setup();
|
|
const context_source = source(); const bot = context(); context_source.set(bot);
|
|
let deep = {text: 'book page'};
|
|
for (let i = 0; i < 24; i++) deep = {value: deep};
|
|
const value = {window: {slots: Array.from({length: 90}, () => ({
|
|
type: 1, nbt: deep, components: [{type: 'custom_data', data: 'x'.repeat(4096)}],
|
|
}))}};
|
|
const records = [{name: 'inventory', i18n: {lang: 'en', name: 'Inventory', description: ''}, value}];
|
|
bot.info_manager = {get_all_info: () => records};
|
|
const stream = new BotStream(1, {source: context_source});
|
|
try {
|
|
assert.ok(Buffer.byteLength(JSON.stringify(value)) > 196_608);
|
|
assert.deepEqual(plain(stream.get_context_info({}, Date.now() + 1000)), records);
|
|
const events = []; stream.subscribe(event => events.push(event));
|
|
bot.emit('info_update', {name: 'inventory', value});
|
|
assert.equal(events.length, 1);
|
|
assert.deepEqual(plain(events[0].payload.value), value);
|
|
let too_deep = {secret: 'DO_NOT_LOG'};
|
|
for (let i = 0; i < 70; i++) too_deep = {next: too_deep};
|
|
bot.emit('info_update', {name: 'inventory', value: too_deep});
|
|
assert.equal(events.length, 1);
|
|
assert.equal(diagnostics[0][1].module, 'inventory');
|
|
assert.equal(diagnostics[0][1].reason, 'max_depth');
|
|
assert.ok(diagnostics[0][1].path.startsWith('$.value.'));
|
|
assert.equal(JSON.stringify(diagnostics).includes('DO_NOT_LOG'), false);
|
|
bot.emit('info_update', {name: 'inventory', value: {window: null}});
|
|
assert.equal(events.length, 2);
|
|
} finally {stream.dispose();}
|
|
});
|
|
|
|
test('observers follow absent, insertion, replacement and removal; unsubscribe releases observer', async () => {
|
|
const app = setup();
|
|
const seen = [];
|
|
const unsubscribe = app.observe_bot_context(1, value => seen.push(value));
|
|
assert.deepEqual(seen, [undefined]);
|
|
assert.equal(app.get_bot_context(1), undefined);
|
|
await app.start();
|
|
const first = app.contexts[0];
|
|
assert.equal(app.get_bot_context(1), first);
|
|
first.bot_status = 'offline';
|
|
await app.start();
|
|
const second = app.contexts[1];
|
|
assert.deepEqual(seen, [undefined, first, undefined, second]);
|
|
await first.clear_this_context();
|
|
assert.equal(app.get_bot_context(1), second);
|
|
assert.equal(seen.length, 4);
|
|
await app.stop_bot_context(1);
|
|
assert.equal(seen.at(-1), undefined);
|
|
unsubscribe();
|
|
unsubscribe();
|
|
await app.start();
|
|
assert.equal(seen.length, 5);
|
|
});
|
|
|
|
test('cleanup failure still removes context and notifies observers; failed observer cannot interrupt startup', async () => {
|
|
const app = setup();
|
|
const seen = [];
|
|
const unsubscribe = app.observe_bot_context(1, value => seen.push(value));
|
|
const failing = app.observe_bot_context(1, () => { throw new Error('consumer failure'); });
|
|
assert.equal(await app.start(), 464);
|
|
app.mocks.cleanup_error = true;
|
|
assert.equal(await app.stop_bot_context(1), 466);
|
|
assert.equal(app.get_bot_context(1), undefined);
|
|
assert.equal(seen.at(-1), undefined);
|
|
unsubscribe(); failing();
|
|
});
|
|
|
|
test('default source follows start/stop/restart with stable stream ID and UUID generation per context lifetime', async () => {
|
|
const app = setup();
|
|
const stream = new app.BotStream(1);
|
|
const events = [];
|
|
stream.subscribe(event => events.push(event));
|
|
const absent = stream.snapshot({});
|
|
assert.equal(absent.context_exists, false);
|
|
assert.equal(absent.context_generation, null);
|
|
assert.equal(absent.bot_status, 'offline');
|
|
assert.equal(absent.seq, 0);
|
|
await app.start();
|
|
const first = stream.snapshot();
|
|
assert.match(first.context_generation, /^[0-9a-f-]{36}$/);
|
|
app.contexts[0].emit('log', [segment('first')]);
|
|
const twin = new app.BotStream(1);
|
|
assert.equal(twin.snapshot().context_generation, first.context_generation);
|
|
assert.notEqual(twin.stream_id, stream.stream_id);
|
|
twin.dispose();
|
|
await app.stop_bot_context(1);
|
|
const stopped = stream.snapshot();
|
|
assert.equal(stopped.context_exists, false);
|
|
assert.equal(stopped.context_generation, null);
|
|
assert.equal(stopped.bot_status, 'offline');
|
|
assert.deepEqual(plain(events.at(-1).payload), {status: 'offline', context_exists: false});
|
|
await app.start();
|
|
const restarted = stream.snapshot();
|
|
assert.notEqual(restarted.context_generation, first.context_generation);
|
|
assert.equal(restarted.stream_id, absent.stream_id);
|
|
assert.ok(restarted.seq > first.seq);
|
|
assert.equal(stream.logs({limit: 200}).records[0].context_generation, first.context_generation);
|
|
for (let i = 1; i < events.length; i++) assert.ok(events[i].seq > events[i - 1].seq);
|
|
stream.dispose();
|
|
await app.stop_bot_context(1);
|
|
});
|
|
|
|
test('bounded replay has shared sequence watermarks, gap detection, limit paging and stream reset detection', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source, capacity: 3, now: () => 123});
|
|
const start = stream.snapshot();
|
|
const events = [];
|
|
stream.subscribe(event => events.push(event));
|
|
for (let i = 0; i < 5; i++) bot.emit('log', [segment(String(i))]);
|
|
bot.emit('message', 'hello', 'chat');
|
|
const snapshot = stream.snapshot();
|
|
assert.equal(snapshot.seq, events.at(-1).seq);
|
|
const latest = stream.logs({limit: 2});
|
|
assert.equal(latest.seq, snapshot.seq);
|
|
assert.deepEqual(latest.records.map(record => record.segments[0].message), ['3', '4']);
|
|
assert.equal(latest.truncated, true);
|
|
const missing = stream.logs({limit: 2, stream_id: stream.stream_id, after_seq: start.seq});
|
|
assert.deepEqual(missing.records.map(record => record.segments[0].message), ['2', '3']);
|
|
assert.equal(missing.truncated, true);
|
|
const last = stream.logs({limit: 2, stream_id: stream.stream_id, after_seq: missing.records.at(-1).seq});
|
|
assert.deepEqual(last.records.map(record => record.segments[0].message), ['4']);
|
|
assert.equal(last.truncated, false);
|
|
assert.equal(stream.logs({limit: 2, stream_id: randomUUID(), after_seq: 0}).truncated, true);
|
|
fails(() => stream.logs({limit: 1, stream_id: stream.stream_id, after_seq: snapshot.seq + 1}), 'RESYNC_REQUIRED');
|
|
stream.dispose();
|
|
});
|
|
|
|
test('resync emits a fresh status watermark for absent and current contexts without resetting stream', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const events = [];
|
|
stream.subscribe(event => events.push(event));
|
|
stream.resync();
|
|
assert.deepEqual(plain(events[0].payload), {status: 'offline', context_exists: false});
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const current = stream.snapshot();
|
|
stream.resync();
|
|
assert.equal(events.at(-1).stream_id, current.stream_id);
|
|
assert.equal(events.at(-1).context_generation, current.context_generation);
|
|
assert.ok(events.at(-1).seq > current.seq);
|
|
assert.equal(events.at(-1).seq, stream.snapshot().seq);
|
|
stream.dispose();
|
|
fails(() => stream.resync(), 'CANCELLED');
|
|
});
|
|
|
|
test('safe projection never serializes raw objects, message JSON, accessors, error details or extra fields', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const events = [];
|
|
stream.subscribe(event => events.push(event));
|
|
let conversions = 0;
|
|
const secret = {secret: 'SECRET', toString() { conversions++; throw new Error('Do not stringify'); }};
|
|
const accessor = {get message() { conversions++; throw new Error('Do not invoke'); }};
|
|
bot.emit('log', [segment(secret), accessor]);
|
|
assert.equal(events.length, 0);
|
|
bot.emit('log', [{...segment('safe'), secret, font_color: 'url(SECRET)', font_style: ['bold', 'unsafe']}]);
|
|
bot.emit('message', {json: {text: 'hello', extra: [{text: ' world'}], secret}, toString: secret.toString}, 'chat');
|
|
const error = new Error('SECRET stack/password');
|
|
error.code = 'SECRET';
|
|
bot.emit('error', error);
|
|
assert.equal(conversions, 0);
|
|
assert.deepEqual(events.map(event => event.name), ['log', 'message', 'bot_error']);
|
|
assert.deepEqual(plain(events[0].payload.records[0].segments), [{
|
|
message: 'safe', font_color: '#ffffff', font_style: ['bold'],
|
|
}]);
|
|
assert.deepEqual(plain(events[1].payload), {text: 'hello world', position: 'chat'});
|
|
assert.deepEqual(plain(events[2].payload), {message: 'Bot connection error'});
|
|
assert.equal(JSON.stringify({events, snapshot: stream.snapshot(), logs: stream.logs({limit: 200})}).includes('SECRET'), false);
|
|
assert.deepEqual(Object.keys(events[0]).sort(), ['bot_id', 'context_generation', 'emitted_at', 'name', 'payload', 'seq', 'stream_id']);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('projection and buffer sizes stay bounded even for huge Unicode messages and log arrays', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const input = Array.from({length: 500}, () => ({
|
|
...segment('😀'.repeat(10_000)), font_style: ['normal', 'italic', 'bold', 'light', 'underline', 'url'],
|
|
}));
|
|
for (let i = 0; i < 250; i++) bot.emit('log', input);
|
|
const logs = stream.logs({limit: 200});
|
|
assert.ok(logs.records.length > 0 && logs.records.length < 200);
|
|
assert.equal(logs.truncated, true);
|
|
assert.ok(Buffer.byteLength(JSON.stringify(logs)) < 262_144);
|
|
assert.ok(logs.records.every(record => record.segments.every(item => Buffer.byteLength(item.message) <= 16_000)));
|
|
const next = stream.logs({limit: 200, stream_id: stream.snapshot().stream_id, after_seq: 0});
|
|
const page = stream.logs({limit: 200, stream_id: next.stream_id, after_seq: next.records.at(-1).seq});
|
|
assert.ok(page.records[0].seq > next.records.at(-1).seq);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('forwarded logs preserve every colored character and the string prefix', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const input = [{...segment('chat: '), hoverEvent: {action: 'show_text', value: 'tooltip', segments: [{message: 'tooltip', font_color: '#55ffff', font_style: ['bold']}]},
|
|
clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'chat'}, ...Array.from('完整的多色聊天消息 ABC 123 😀', (message, index) => ({
|
|
...segment(message), font_color: index % 2 ? '#ff0000' : '#00ff00',
|
|
}))];
|
|
let live;
|
|
stream.subscribe(event => { if (event.name === 'log') live = event; });
|
|
bot.emit('log', input);
|
|
const expected = input.map(item => item.message).join('');
|
|
const segments = live.payload.records[0].segments;
|
|
assert.equal(segments.length, input.length);
|
|
assert.equal(segments.map(item => item.message).join(''), expected);
|
|
assert.deepEqual(plain(segments[0]), input[0]);
|
|
assert.deepEqual(plain(stream.logs({limit: 1}).records[0].segments), plain(segments));
|
|
stream.dispose();
|
|
});
|
|
|
|
test('disk history works without a context and does not change live stream cursors', async () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const entries = Array.from({length: 25}, (_, index) => ({id: randomUUID(), timestamp: index,
|
|
log: [{...segment(`disk ${index}`), hoverEvent: {action: 'show_text', value: 'tooltip'}}]}));
|
|
let requested;
|
|
const stream = new BotStream(1, {source: context_source, history_reader: async count => {
|
|
requested = count;
|
|
return entries.slice(-count);
|
|
}});
|
|
const before = stream.snapshot();
|
|
const result = await stream.get_log_history();
|
|
assert.equal(requested, 20);
|
|
assert.equal(result.records.length, 20);
|
|
assert.deepEqual(plain(result.records.map(record => record.id)), entries.slice(-20).map(entry => entry.id));
|
|
assert.equal(result.records[0].segments[0].hoverEvent.value, 'tooltip');
|
|
assert.notEqual(result.stream_id, before.stream_id);
|
|
assert.equal(stream.snapshot().seq, before.seq);
|
|
assert.equal(stream.logs({limit: 20}).records.length, 0);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('subscriber mutations and exceptions cannot corrupt other consumers or buffered records', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
stream.subscribe(event => {
|
|
if (event.name === 'log') event.payload.records[0].segments[0].message = 'changed';
|
|
throw new Error('consumer failure');
|
|
});
|
|
let received;
|
|
stream.subscribe(event => { received = event; });
|
|
const input = [segment('original')];
|
|
bot.emit('log', input);
|
|
input[0].message = 'changed';
|
|
assert.equal(received.payload.records[0].segments[0].message, 'original');
|
|
const result = stream.logs({limit: 1});
|
|
result.records[0].segments[0].message = 'changed';
|
|
assert.equal(stream.logs({limit: 1}).records[0].segments[0].message, 'original');
|
|
stream.dispose();
|
|
});
|
|
|
|
test('reentrant subscribers preserve event sequence order for every consumer', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const seen = [];
|
|
stream.subscribe(event => {
|
|
if (event.name === 'log') stream.resync();
|
|
});
|
|
stream.subscribe(event => seen.push(event.seq));
|
|
bot.emit('log', [segment('nested')]);
|
|
assert.equal(seen.length, 2);
|
|
assert.ok(seen[1] > seen[0]);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('replacement detaches listeners, rejects stale callbacks; disposal releases source and context subscriptions', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const old = context();
|
|
context_source.set(old);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const events = [];
|
|
const unsubscribe = stream.subscribe(event => events.push(event));
|
|
const old_log = old.listeners('log')[0];
|
|
const replacement = context();
|
|
context_source.set(replacement);
|
|
assert.equal(old.listenerCount('log'), 0);
|
|
const count = events.length;
|
|
old_log([segment('stale')]);
|
|
assert.equal(events.length, count);
|
|
replacement.emit('log', [segment('current')]);
|
|
assert.equal(events.length, count + 1);
|
|
unsubscribe();
|
|
replacement.emit('log', [segment('unsubscribed')]);
|
|
assert.equal(events.length, count + 1);
|
|
assert.equal(context_source.count, 1);
|
|
stream.dispose(); stream.dispose();
|
|
assert.equal(context_source.count, 0);
|
|
for (const event of ['status_update', 'log', 'message', 'error']) assert.equal(replacement.listenerCount(event), 0);
|
|
fails(() => stream.snapshot(), 'CANCELLED');
|
|
fails(() => stream.subscribe(() => {}), 'CANCELLED');
|
|
});
|
|
|
|
test('tab completion normalizes mixed matches, deduplicates and cancels stale results', async () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const reject = (promise, code) => assert.rejects(promise, error => error.code === code);
|
|
await reject(stream.tab_complete({text: '/he'}, Date.now() + 1000), 'NO_CONTEXT');
|
|
const bot = context(); bot.bot_status = 'online';
|
|
let release;
|
|
bot.bot_instance.tabComplete = () => new Promise(resolve => { release = resolve; });
|
|
context_source.set(bot);
|
|
const pending = stream.tab_complete({text: '/he'}, Date.now() + 1000);
|
|
await reject(stream.tab_complete({text: '/he'}, Date.now() + 1000), 'RATE_LIMITED');
|
|
release(['help', {match: 'help'}, {match: 'hello'}, 'hello', '', {match: 42}]);
|
|
assert.deepEqual(plain(await pending), {matches: ['help', 'hello'], start: 1, length: 2});
|
|
bot.bot_instance.tabComplete = async () => ['Alex', 'Alex'];
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/tell Al'}, Date.now() + 1000)),
|
|
{matches: ['Alex'], start: 6, length: 2});
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/tell '}, Date.now() + 1000)),
|
|
{matches: ['Alex'], start: 6, length: 0});
|
|
bot.bot_instance.tabComplete = async () => ['/help', 'help'];
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/he'}, Date.now() + 1000)),
|
|
{matches: ['help'], start: 1, length: 2});
|
|
bot.bot_instance.tabComplete = () => new Promise(resolve => { release = resolve; });
|
|
for (const payload of [{}, {text: 2}, {text: 'a\n'}, {text: 'x'.repeat(257)}])
|
|
await reject(stream.tab_complete(payload, Date.now() + 1000), 'INVALID_REQUEST');
|
|
const stale = stream.tab_complete({text: ''}, Date.now() + 1000);
|
|
context_source.set(undefined); release(['stale']);
|
|
await reject(stale, 'CANCELLED');
|
|
stream.dispose();
|
|
});
|
|
|
|
test('modern completion matches transaction IDs and cleans listeners on timeout', async () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
const bot = context(); bot.bot_status = 'online';
|
|
const client = new EventEmitter(); client.protocolVersion = 393;
|
|
let sent;
|
|
client.write = (name, packet) => { sent = packet; assert.equal(name, 'tab_complete'); };
|
|
bot.bot_instance._client = client;
|
|
bot.bot_instance.tabComplete = () => { throw new Error('Legacy API must not be used'); };
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
const pending = stream.tab_complete({text: '/he'}, Date.now() + 1000);
|
|
client.emit('tab_complete', {transactionId: sent.transactionId + 1, matches: ['wrong']});
|
|
client.emit('tab_complete', {transactionId: sent.transactionId, start: 1, length: 2, matches: [{match: 'help'}, {match: 'help'}]});
|
|
assert.deepEqual(plain(await pending), {matches: ['help'], start: 1, length: 2});
|
|
const parameter = stream.tab_complete({text: '/tell Alex he'}, Date.now() + 1000);
|
|
client.emit('tab_complete', {transactionId: sent.transactionId, start: 11, length: 2, matches: [{match: 'hello'}]});
|
|
assert.deepEqual(plain(await parameter), {matches: ['hello'], start: 11, length: 2});
|
|
const malformed = stream.tab_complete({text: '/tell '}, Date.now() + 1000);
|
|
client.emit('tab_complete', {transactionId: sent.transactionId, start: 6, length: 10, matches: []});
|
|
await assert.rejects(malformed, error => error.code === 'INTERNAL_ERROR');
|
|
assert.equal(client.listenerCount('tab_complete'), 0);
|
|
await assert.rejects(stream.tab_complete({text: '/he'}, Date.now() + 15), error => error.code === 'TIMEOUT');
|
|
assert.equal(client.listenerCount('tab_complete'), 0);
|
|
assert.equal(client.listenerCount('end'), 0);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('declared commands supply missing literals, subcommands and player arguments', async () => {
|
|
const {BotStream, remember_command_tree} = setup();
|
|
const context_source = source();
|
|
const bot = context(); bot.bot_status = 'online';
|
|
const client = new EventEmitter(); client.protocolVersion = 393;
|
|
client.write = (_name, packet) => queueMicrotask(() => client.emit('tab_complete', {
|
|
transactionId: packet.transactionId, start: 0, length: 0, matches: [],
|
|
}));
|
|
bot.bot_instance._client = client;
|
|
bot.bot_instance.tabComplete = async () => [];
|
|
bot.bot_instance.players = {Alex: {}, Alice: {}, Bob: {}};
|
|
const literal = (name, children = []) => ({flags: {command_node_type: 1}, extraNodeData: {name}, children});
|
|
remember_command_tree(bot.bot_instance, {rootIndex: 0, nodes: [
|
|
{children: [1, 3]}, literal('tell', [2]),
|
|
{flags: {command_node_type: 2}, extraNodeData: {name: 'player', parser: 'minecraft:entity'}, children: []},
|
|
literal('team', [4]), literal('join', [5]), literal('red'),
|
|
]});
|
|
context_source.set(bot);
|
|
const stream = new BotStream(1, {source: context_source});
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/te'}, Date.now() + 1000)),
|
|
{matches: ['tell', 'team'], start: 1, length: 2});
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/tell Al'}, Date.now() + 1000)),
|
|
{matches: ['Alex', 'Alice'], start: 6, length: 2});
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/team join r'}, Date.now() + 1000)),
|
|
{matches: ['red'], start: 11, length: 1});
|
|
client.write = () => {};
|
|
assert.deepEqual(plain(await stream.tab_complete({text: '/tell Al'}, Date.now() + 1000)),
|
|
{matches: ['Alex', 'Alice'], start: 6, length: 2});
|
|
stream.dispose();
|
|
});
|
|
|
|
test('26.3 codec preserves actual command suggestion request and response fields', () => {
|
|
const {createSerializer, createDeserializer} = require('@wp2508/minecraft-protocol/src/transforms/serializer');
|
|
for (const isServer of [false, true]) {
|
|
const packet = {name: 'tab_complete', params: isServer
|
|
? {transactionId: 7, start: 6, length: 2, matches: [{match: 'Alex'}]}
|
|
: {transactionId: 7, text: '/tell Al'}};
|
|
const serializer = createSerializer({state: 'play', isServer, version: '26.3'});
|
|
const decoder = createDeserializer({state: 'play', isServer: !isServer, version: '26.3'});
|
|
assert.deepEqual(JSON.parse(JSON.stringify(decoder.parsePacketBuffer(serializer.createPacketBuffer(packet)).data)), packet);
|
|
}
|
|
});
|
|
|
|
test('function RPC returns booleans and prevents expired queued work', async () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
let now = 100;
|
|
const stream = new BotStream(1, {source: context_source, now: () => now});
|
|
const bot = context();
|
|
context_source.set(bot);
|
|
const payload = {function: 'inventory', name: 'left_click_slot', args: [9]};
|
|
for (const result of [true, false, undefined]) {
|
|
bot.function_manager = {async run(path, name, args, before_run) {
|
|
assert.equal(path, payload.function);
|
|
assert.equal(name, payload.name);
|
|
assert.deepEqual(args, payload.args);
|
|
before_run();
|
|
return result;
|
|
}};
|
|
assert.equal(await stream.execute_function(payload, 200), result === true);
|
|
}
|
|
bot.function_manager = {async run() { throw new Error('SECRET'); }};
|
|
assert.equal(await stream.execute_function(payload, 200), false);
|
|
bot.bot_status = 'offline';
|
|
assert.equal(await stream.execute_function(payload, 200), false);
|
|
bot.bot_status = 'online';
|
|
bot.function_manager = {async run(path, name, args, before_run) { now = 200; before_run(); }};
|
|
await assert.rejects(stream.execute_function(payload, 200), error => error.code === 'TIMEOUT');
|
|
now = 100;
|
|
bot.function_manager = {async run(path, name, args, before_run) {
|
|
context_source.set(context());
|
|
before_run();
|
|
}};
|
|
await assert.rejects(stream.execute_function(payload, 200), error => error.code === 'CANCELLED');
|
|
stream.dispose();
|
|
});
|
|
|
|
test('strict RPC inputs and chat eligibility/deadlines prevent side effects and sanitize failures', () => {
|
|
const {BotStream} = setup();
|
|
const context_source = source();
|
|
let now = 100;
|
|
const stream = new BotStream(1, {source: context_source, now: () => now});
|
|
fails(() => stream.chat({message: 'hello'}, 200), 'NO_CONTEXT');
|
|
const bot = context();
|
|
const sent = [];
|
|
bot.bot_instance.chat = message => sent.push(message);
|
|
context_source.set(bot);
|
|
for (const payload of [null, [], {}, {message: ''}, {message: ' '}, {message: 'x'.repeat(257)},
|
|
{message: 'hello', extra: 'SECRET'}, {get message() { throw new Error('getter'); }}]) {
|
|
fails(() => stream.chat(payload, 200), 'INVALID_REQUEST');
|
|
}
|
|
for (const deadline of [100, 99, NaN, Infinity]) fails(() => stream.chat({message: 'hello'}, deadline), 'TIMEOUT');
|
|
for (const status of ['ready', 'connecting', 'reconnecting', 'disconnecting', 'offline']) {
|
|
bot.bot_status = status;
|
|
fails(() => stream.chat({message: 'hello'}, 200), 'BOT_NOT_ONLINE');
|
|
}
|
|
assert.deepEqual(sent, []);
|
|
bot.bot_status = 'online';
|
|
assert.deepEqual(plain(stream.chat({message: 'hello'}, 200)), {accepted: true});
|
|
assert.deepEqual(sent, ['hello']);
|
|
bot.bot_instance.chat = () => { throw new Error('SECRET chat failed'); };
|
|
assert.throws(() => stream.chat({message: 'hello'}, 200), error => error.code === 'INTERNAL_ERROR' && !error.message.includes('SECRET'));
|
|
for (const payload of [null, [], {extra: true}]) fails(() => stream.snapshot(payload), 'INVALID_REQUEST');
|
|
for (const payload of [null, [], {}, {limit: 0}, {limit: 201}, {limit: 1.5}, {limit: 1, after_seq: 0},
|
|
{limit: 1, stream_id: 'bad'}, {limit: 1, stream_id: stream.stream_id, after_seq: -1}, {limit: 1, extra: true}]) {
|
|
fails(() => stream.logs(payload), 'INVALID_REQUEST');
|
|
}
|
|
now = 300;
|
|
fails(() => stream.chat({message: 'hello'}, 200), 'TIMEOUT');
|
|
stream.dispose();
|
|
});
|