chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const names = ['destroy', 'place', 'use'].flatMap(action =>
['cursor', 'nearest', 'position', 'position_group'].map(target => `${action}_${target}_block`));
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
const {init} = await tsImport(`../bot/function/block/${name}.ts`, import.meta.url);
return [name, () => new init()];
})));
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const {Vec3} = mf('vec3');
function fixture() {
const bot = new EventEmitter();
bot.entity = {position: new Vec3(0, 0, 0), eyeHeight: 1.62, height: 1.8, isValid: true};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.heldItem = {name: 'stone'};
const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0),
boundingBox: 'block', shapes: [[0, 0, 0, 1, 1, 1]], diggable: true};
const blocks = new Map([[block.position.toString(), block]]);
bot.blockAt = position => blocks.get(position.toString()) ?? null;
bot.findBlocks = options => [...blocks.values()].filter(options.matching).map(block => block.position);
bot.blockAtCursor = reach => {
bot.cursorReach = reach;
return {...block, face: 4, intersect: new Vec3(2, 1.25, 0.5)};
};
bot.calls = [];
bot.looks = [];
bot.lookAt = async point => { bot.looks.push(point); };
bot.canDigBlock = () => true;
bot.dig = async (...args) => { bot.calls.push(['dig', ...args]); };
bot.placeBlock = async (...args) => { bot.calls.push(['place', ...args]); };
bot._placeBlockWithOptions = async (...args) => { bot.calls.push(['place', ...args]); };
bot.activateBlock = async (...args) => { bot.calls.push(['use', ...args]); };
bot.stopDigging = () => { bot.stops = (bot.stops ?? 0) + 1; bot.targetDigBlock = null; };
return {bot, block, blocks};
}
test('position operations pass correct faces and await API completion', async () => {
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]];
for (const action of ['destroy', 'place', 'use']) {
for (let face = 0; face < 6; face++) {
const {bot, block} = fixture();
assert.equal(await operations[`${action}_position_block`]().run(bot, [2, 1, 0, face]), true);
assert.equal(bot.calls[0][1], block);
const vector = bot.calls[0][action === 'destroy' ? 3 : 2];
assert.deepEqual([vector.x, vector.y, vector.z], faces[face]);
}
}
});
test('cursor operations preserve the hit face and surface offset', async () => {
for (const action of ['destroy', 'place', 'use']) {
const {bot} = fixture();
assert.equal(await operations[`${action}_cursor_block`]().run(bot, [4.5]), true);
assert.equal(bot.cursorReach, 4.5);
const call = bot.calls[0];
const vector = call[action === 'destroy' ? 3 : 2];
assert.deepEqual([vector.x, vector.y, vector.z], [-1, 0, 0]);
if (action === 'destroy') assert.equal(call[2], 'ignore');
if (action === 'place') {
assert.equal(call[3].forceLook, 'ignore');
assert.deepEqual([call[3].delta.x, call[3].delta.y, call[3].delta.z], [0, 0.25, 0.5]);
}
if (action === 'use') {
assert.equal(call[4], 'ignore');
assert.deepEqual([call[3].x, call[3].y, call[3].z], [0, 0.25, 0.5]);
}
}
});
test('nearest operations match partial type IDs and choose the closest loaded block', async () => {
for (const action of ['destroy', 'place', 'use']) {
const {bot, block, blocks} = fixture();
blocks.set('3,1,0', {...block, position: new Vec3(3, 1, 0)});
assert.equal(await operations[`${action}_nearest_block`]().run(bot, ['MINECRAFT:STO', 4.5, 1]), true);
assert.equal(bot.calls[0][1], block);
assert.equal(await operations[`${action}_nearest_block`]().run(bot, ['dirt', 4.5, 1]), false);
}
});
test('all block functions default to no aim and disable internal aiming', async () => {
for (const name of names) {
for (const flag of [[], [false], [true]]) {
const {bot} = fixture();
const args = name.includes('group') ? [[2, 1, 0, 4]] : name.includes('position') ? [2, 1, 0, 4]
: name.includes('nearest') ? ['stone', 4.5, 4] : [4.5];
bot.placeBlock = () => { assert.fail('public placement may aim internally'); };
assert.equal(await operations[name]().run(bot, [...args, ...flag]), true, name);
assert.equal(bot.looks.length, flag[0] === true ? 1 : 0, name);
if (flag[0]) assert.deepEqual(bot.looks[0], new Vec3(2, 1.5, 0.5));
const call = bot.calls[0];
if (name.startsWith('destroy')) assert.equal(call[2], 'ignore');
if (name.startsWith('place')) {
assert.equal(call[3].forceLook, 'ignore');
assert.equal(call[3].swingArm, 'right');
if (!name.includes('cursor')) assert.equal(call[3].delta, undefined);
}
if (name.startsWith('use')) assert.equal(call[4], 'ignore');
}
}
});
test('groups await a separate face-center look before every operation on all faces', async () => {
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]];
for (const action of ['destroy', 'place', 'use']) {
const {bot, block} = fixture();
const events = [];
bot.lookAt = async point => {
events.push('look');
await Promise.resolve();
bot.looks.push(point);
events.push('looked');
};
const record = async () => { events.push('operate'); };
bot.dig = bot._placeBlockWithOptions = bot.activateBlock = record;
assert.equal(await operations[`${action}_position_group_block`]().run(bot,
[faces.flatMap((_, face) => [2, 1, 0, face]), true]), true);
assert.deepEqual(events, faces.flatMap(() => ['look', 'looked', 'operate']));
assert.deepEqual(bot.looks, faces.map(face => block.position.offset(0.5, 0.5, 0.5)
.plus(new Vec3(...face).scaled(0.5))));
}
});
test('operations revalidate after aiming and clean up on cancellation or look failure', async () => {
for (const name of names) {
const changes = [
({bot}) => bot.emit('end'),
({bot}) => { bot.entity = {...bot.entity}; },
({bot}) => { bot.entity.position = new Vec3(100, 0, 0); },
({blocks}) => blocks.clear(),
({block, blocks}) => blocks.set(block.position.toString(), {...block, type: 2}),
({block, blocks}) => blocks.set(block.position.toString(), {...block, stateId: 2}),
() => { throw Error('look failed'); },
];
if (name.startsWith('place')) changes.push(({bot}) => { bot.heldItem = null; });
if (name.startsWith('destroy')) changes.push(({bot}) => { bot.canDigBlock = () => false; });
for (const change of changes) {
const context = fixture();
const {bot} = context;
bot.lookAt = async () => { change(context); };
const args = name.includes('group') ? [[2, 1, 0, 1]] : name.includes('position') ? [2, 1, 0, 1]
: name.includes('nearest') ? ['stone', 4.5, 1] : [4.5];
assert.equal(await operations[name]().run(bot, [...args, true]), false, name);
assert.equal(bot.calls.length, 0, name);
assert.equal(bot.listenerCount('end'), 0, name);
}
}
});
test('dig ownership covers pending explicit aim and is released on disconnect', async () => {
const {bot} = fixture();
let finishLook;
bot.lookAt = () => new Promise(resolve => { finishLook = resolve; });
const fn = operations.destroy_position_block();
const first = fn.run(bot, [2, 1, 0, 1, true]);
assert.equal(await fn.run(bot, [2, 1, 0, 1]), false);
bot.emit('end');
assert.equal(await first, false);
finishLook();
await Promise.resolve();
assert.equal(bot.calls.length, 0);
assert.equal(bot.stops, undefined);
assert.equal(await fn.run(bot, [2, 1, 0, 1]), true);
});
test('groups prevalidate every tuple, execute in order and stop on a failure', async () => {
for (const action of ['destroy', 'place', 'use']) {
const {bot, block, blocks} = fixture();
const second = {...block, position: new Vec3(3, 1, 0)};
blocks.set(second.position.toString(), second);
const fn = operations[`${action}_position_group_block`]();
assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 7]]), false);
assert.equal(bot.calls.length, 0);
assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), true);
assert.deepEqual(bot.calls.map(call => call[1].position.x), [2, 3]);
bot.calls.length = 0;
blocks.delete(second.position.toString());
assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), false);
assert.equal(bot.calls.length, 1);
}
});
test('block operations reject invalid parameters, missing targets and API errors', async () => {
for (const name of names) {
const {bot} = fixture();
const args = name.includes('group') ? [[2, 1, 0, 1]] : name.includes('position') ? [2, 1, 0, 1]
: name.includes('nearest') ? ['stone', 4.5, 1] : [4.5];
const fn = operations[name]();
for (const invalid of [[], null, [...args, 1], [...args, undefined], [...args, false, true]])
assert.equal(await fn.run(bot, invalid), false, name);
const fail = async () => { throw Error('failed'); };
bot.dig = bot.placeBlock = bot._placeBlockWithOptions = bot.activateBlock = fail;
assert.equal(await fn.run(bot, args), false, name);
}
const {bot, block} = fixture();
for (const args of [[2.5, 1, 0, 1], [2, 1, 0, 6], [20, 1, 0, 1]])
assert.equal(await operations.use_position_block().run(bot, args), false);
bot.heldItem = null;
assert.equal(await operations.place_position_block().run(bot, [2, 1, 0, 1]), false);
bot.canDigBlock = () => false;
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
block.name = 'air'; block.type = 0;
assert.equal(await operations.use_position_block().run(bot, [2, 1, 0, 1]), false);
});
test('owned digging stops on disconnect and releases operation listeners', async () => {
const {bot} = fixture();
bot.dig = async block => { bot.targetDigBlock = block; bot.emit('end', 'disconnected'); };
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
assert.equal(bot.stops, 1);
assert.equal(bot.listenerCount('end'), 0);
});
test('digging does not overlap and releases ownership after rejection', async () => {
const {bot} = fixture();
let release;
bot.dig = () => new Promise(resolve => { release = resolve; });
const first = operations.destroy_position_block().run(bot, [2, 1, 0, 1]);
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
release();
assert.equal(await first, true);
bot.dig = async () => { throw Error('rejected'); };
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
bot.dig = async () => {};
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), true);
assert.equal(bot.listenerCount('end'), 0);
});
test('groups stop after disconnect or bot instance replacement', async () => {
for (const change of [bot => bot.emit('end'), bot => { bot.entity = {...bot.entity}; }]) {
const {bot, block, blocks} = fixture();
const second = {...block, position: new Vec3(3, 1, 0)};
blocks.set(second.position.toString(), second);
bot.activateBlock = async (...args) => { bot.calls.push(['use', ...args]); change(bot); };
assert.equal(await operations.use_position_group_block().run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), false);
assert.equal(bot.calls.length, 1);
assert.equal(bot.listenerCount('end'), 0);
}
});
test('loader discovers all twelve block functions and valid metadata', async () => {
const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url);
const category = (await get_function_list()).find(item => item.path === 'block');
assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort());
for (const fn of category.function_list) {
const types = fn.name.includes('group') ? ['number-array'] : fn.name.includes('position') ? Array(4).fill('number')
: fn.name.includes('nearest') ? ['string', 'number', 'number'] : ['number'];
assert.deepEqual(fn.args.map(arg => arg.type), [...types, 'boolean']);
assert.equal(fn.args.at(-1).name, 'need_look');
assert.equal(fn.args.at(-1).default, false);
}
});
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {mkdtemp, mkdir, writeFile, appendFile, rename, symlink, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import {tsImport} from 'tsx/esm/api';
const {BotFileService} = await tsImport('../bot/forward/files.ts', import.meta.url);
const deadline = () => Date.now() + 20_000;
const code = expected => error => error.code === expected && !error.message.includes(tmpdir());
async function fixture(t) {
const root = await mkdtemp(path.join(tmpdir(), 'bot-files-'));
t.after(() => rm(root, {recursive: true, force: true}));
const service = new BotFileService(1, {root});
t.after(() => service.dispose());
return {root, service};
}
test('sorted trees preserve empty directories and binary chunks use a fixed listed size', async t => {
const {root, service} = await fixture(t);
await mkdir(path.join(root, 'empty'));
const binary = Buffer.alloc(300_000);
for (let i = 0; i < binary.length; i++) binary[i] = i % 256;
await writeFile(path.join(root, 'z.bin'), binary);
await writeFile(path.join(root, 'a'), 'a');
const tree = await service.list(deadline());
assert.equal(tree.partial, false);
assert.deepEqual(tree.children.map(x => x.name), ['a', 'empty', 'z.bin']);
assert.deepEqual(tree.children[1], {type: 'directory', name: 'empty', children: []});
assert.equal(JSON.stringify(tree).includes(root), false);
const id = tree.children[2].id;
const first = await service.read({id}, deadline());
const second = await service.read({id, offset: first.next_offset}, deadline());
assert.equal(first.next_offset, 262144);
assert.equal(first.eof, false);
assert.equal(second.eof, true);
assert.equal(second.total_bytes, binary.length);
assert.deepEqual(Buffer.concat([Buffer.from(first.data, 'base64'), Buffer.from(second.data, 'base64')]), binary);
const end = await service.read({id, offset: binary.length}, deadline());
assert.equal(end.data, '');
assert.equal(end.eof, true);
await writeFile(path.join(root, '..legal'), 'ok');
const legal = (await service.list(deadline())).children.find(entry => entry.name === '..legal');
assert.equal(Buffer.from((await service.read({id: legal.id}, deadline())).data, 'base64').toString(), 'ok');
});
test('IDs are scoped, expiring, opaque and request ranges are strict', async t => {
const {root} = await fixture(t);
let now = Date.now();
const service = new BotFileService(1, {root, now: () => now});
const other = new BotFileService(1, {root});
t.after(() => { service.dispose(); other.dispose(); });
await writeFile(path.join(root, 'file'), '123');
const tree = await service.list(now + 1000);
const id = tree.children[0].id;
assert.equal(tree.expires_at, now + 300_000);
await assert.rejects(other.read({id}, deadline()), code('INVALID_REQUEST'));
for (const payload of [{id: '../file'}, {id, offset: -1}, {id, offset: 4}, {id, length: 0},
{id, length: 262145}, {id, path: 'file'}, {id, offset: 0.5}]) {
await assert.rejects(service.read(payload, now + 1000), code('INVALID_REQUEST'));
}
now = tree.expires_at;
await assert.rejects(service.read({id}, now + 1000), code('INVALID_REQUEST'));
const limited = await service.list(now + 1000, now + 500);
assert.equal(limited.expires_at, now + 500);
now += 500;
await assert.rejects(service.read({id: limited.children[0].id}, now + 1000), code('INVALID_REQUEST'));
});
test('absent roots and empty files work; symlinks are excluded and unsafe roots rejected', async t => {
const {root, service} = await fixture(t);
const absent = new BotFileService(1, {root: path.join(root, 'absent')});
assert.deepEqual((await absent.list(deadline())).children, []);
await writeFile(path.join(root, 'empty'), '');
await symlink('empty', path.join(root, 'link'));
await symlink(root, path.join(root, 'loop'));
const tree = await service.list(deadline());
assert.deepEqual(tree.children.map(x => x.name), ['empty']);
assert.equal((await service.read({id: tree.children[0].id}, deadline())).data, '');
const unsafe = new BotFileService(1, {root: path.join(root, 'loop')});
await assert.rejects(unsafe.list(deadline()), code('RESYNC_REQUIRED'));
});
test('ordinary mutation, replacement, truncation and ancestor symlinks require resync', async t => {
const {root, service} = await fixture(t);
await mkdir(path.join(root, 'dir'));
const file = path.join(root, 'dir', 'file');
await writeFile(file, 'before');
const listedId = async () => (await service.list(deadline())).children[0].children[0].id;
let id = await listedId();
await writeFile(file, 'after');
await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED'));
id = await listedId();
await rename(file, `${file}.old`);
await writeFile(file, 'after');
await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED'));
id = await listedId();
await rename(path.join(root, 'dir'), path.join(root, 'saved'));
await symlink('saved', path.join(root, 'dir'));
await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED'));
});
test('only direct log/*.log files may append, with fixed EOF; truncation and replacement reject', async t => {
const {root, service} = await fixture(t);
await mkdir(path.join(root, 'log'));
const file = path.join(root, 'log', 'bot.log');
await writeFile(file, 'first');
const id = (await service.list(deadline())).children[0].children[0].id;
await appendFile(file, 'second');
const data = await service.read({id}, deadline());
assert.equal(Buffer.from(data.data, 'base64').toString(), 'first');
assert.equal(data.total_bytes, 5);
assert.equal(data.eof, true);
await writeFile(file, 'x');
await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED'));
await rename(file, `${file}.old`);
await writeFile(file, 'replacement');
await assert.rejects(service.read({id}, deadline()), code('RESYNC_REQUIRED'));
});
test('deadline, disposal and concurrency limits are checked during asynchronous operations', async t => {
const {root, service} = await fixture(t);
await writeFile(path.join(root, 'file'), 'data');
await assert.rejects(service.list(Date.now() - 1), code('TIMEOUT'));
const scan = service.list(deadline());
await assert.rejects(service.list(deadline()), code('RATE_LIMITED'));
const id = (await scan).children[0].id;
const reads = Array.from({length: 4}, () => service.read({id}, deadline()));
await assert.rejects(service.read({id}, deadline()), code('RATE_LIMITED'));
await Promise.all(reads);
const pending = service.read({id}, deadline());
service.dispose();
await assert.rejects(pending, code('CANCELLED'));
await assert.rejects(service.list(deadline()), code('CANCELLED'));
await assert.rejects(service.read({id}, deadline()), code('CANCELLED'));
const cancelled = new BotFileService(1, {root});
const listing = cancelled.list(deadline());
cancelled.dispose();
await assert.rejects(listing, code('CANCELLED'));
});
test('listings enforce node, depth and byte limits', async t => {
const {root, service} = await fixture(t);
await Promise.all(Array.from({length: 2010}, (_, i) => writeFile(path.join(root, `file-${i}`), '')));
const tree = await service.list(deadline());
assert.equal(tree.partial, true);
assert.ok(tree.children.length <= 2000);
assert.ok(Buffer.byteLength(JSON.stringify(tree)) <= 512 * 1024);
const firstId = tree.children[0].id;
for (let i = 0; i < 3; i++) await service.list(deadline());
const exhausted = await service.list(deadline());
assert.equal(exhausted.partial, true);
assert.deepEqual(exhausted.children, []);
assert.equal((await service.read({id: firstId}, deadline())).eof, true);
await rm(root, {recursive: true});
await mkdir(root);
let directory = root;
for (let i = 0; i < 34; i++) { directory = path.join(directory, 'd'); await mkdir(directory); }
const deep = await service.list(deadline());
let nodes = deep.children;
let depth = 0;
while (nodes.length) { depth++; nodes = nodes[0].children; }
assert.equal(depth, 32);
assert.equal(deep.partial, true);
});
test('byte bound truncates listings before the entry bound', async t => {
const {root, service} = await fixture(t);
await Promise.all(Array.from({length: 1800}, (_, i) =>
writeFile(path.join(root, `${String(i).padStart(4, '0')}-${'x'.repeat(240)}`), '')));
const tree = await service.list(deadline());
assert.equal(tree.partial, true);
assert.ok(tree.children.length > 0 && tree.children.length < 1800);
assert.ok(Buffer.byteLength(JSON.stringify(tree)) <= 512 * 1024);
});
test('deadline expiry during asynchronous work rejects and releases the scan slot', async t => {
const {root} = await fixture(t);
let now = Date.now();
const service = new BotFileService(1, {root, now: () => now});
t.after(() => service.dispose());
const end = now + 100;
const scan = service.list(end);
now = end;
await assert.rejects(scan, code('TIMEOUT'));
assert.deepEqual((await service.list(now + 100)).children, []);
await mkdir(path.join(root, 'log'));
await writeFile(path.join(root, 'log', 'ordinary.txt'), 'first');
const id = (await service.list(now + 100)).children[0].children[0].id;
await appendFile(path.join(root, 'log', 'ordinary.txt'), 'more');
await assert.rejects(service.read({id}, now + 100), code('RESYNC_REQUIRED'));
});
+291
View File
@@ -0,0 +1,291 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {register} from 'tsx/esm/api';
const loader = register({namespace: 'bot-functions-test'});
const tsImport = loader.import;
test.after(() => loader.unregister());
const names = ['chat', 'jump', 'squat_once', 'squat_continue_switch', 'use_direction_key', 'look_at', 'stop_current_function'];
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
const {init} = await tsImport(`../bot/function/bot/${name}.ts`, import.meta.url);
return [name, () => new init()];
})));
const require = createRequire(import.meta.url);
const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3');
function fixture() {
const bot = new EventEmitter();
bot.entity = {position: new Vec3(1, 2, 3), eyeHeight: 1.62, onGround: true, isValid: true};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.controlState = {};
bot.calls = [];
bot.getControlState = key => bot.controlState[key] === true;
bot.setControlState = (key, enabled) => {
bot.calls.push([key, enabled]);
bot.controlState[key] = enabled;
};
bot.chat = message => { bot.message = message; };
bot.lookAt = async target => { bot.target = target; };
return bot;
}
function clean(bot, client = bot._client) {
for (const event of ['end', 'error', 'physicsTick']) assert.equal(bot.listenerCount(event), 0, event);
for (const event of ['end', 'error']) assert.equal(client.listenerCount(event), 0, `client ${event}`);
}
// Track pending timers as well as using the test runner's deterministic clock.
function clock(t) {
t.mock.timers.enable({apis: ['setTimeout']});
const schedule = globalThis.setTimeout;
const clear = globalThis.clearTimeout;
const pending = new Set();
t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => {
const timer = schedule(() => { pending.delete(timer); callback(...args); }, delay);
pending.add(timer);
return timer;
});
t.mock.method(globalThis, 'clearTimeout', timer => { pending.delete(timer); clear(timer); });
return {tick: ms => t.mock.timers.tick(ms), clean: () => assert.equal(pending.size, 0, 'pending timers')};
}
test('chat preserves exact content and validates its single argument without server acknowledgement', async () => {
const bot = fixture();
for (const message of [' hello ', '/help', 'x'.repeat(256), '你好']) {
assert.equal(await operations.chat().run(bot, [message]), true);
assert.equal(bot.message, message);
clean(bot);
}
for (const args of [null, [], ['a', 'b'], [1], [''], [' \t '], ['x'.repeat(257)], ['a\rb'], ['a\nb'], ['a\0b']]) {
delete bot.message;
assert.equal(await operations.chat().run(bot, args), false);
assert.equal(bot.message, undefined);
}
bot.chat = () => { throw Error('send failed'); };
assert.equal(await operations.chat().run(bot, ['hello']), false);
clean(bot);
});
test('pulses hold their own key for one physicsTick and listen before enabling', async t => {
const timer = clock(t);
for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) {
const bot = fixture();
bot.controlState.forward = true;
const pending = operations[name]().run(bot, []);
assert.equal(bot.controlState[key], true);
timer.tick(999);
assert.equal(bot.controlState[key], true);
bot.emit('physicsTick');
assert.equal(await pending, true);
assert.deepEqual(bot.calls, [[key, true], [key, false]]);
assert.equal(bot.controlState.forward, true);
clean(bot);
timer.clean();
const synchronous = fixture();
const set = synchronous.setControlState;
synchronous.setControlState = (control, enabled) => {
set(control, enabled);
if (enabled) synchronous.emit('physicsTick');
};
assert.equal(await operations[name]().run(synchronous, []), true);
assert.equal(synchronous.controlState[key], false);
clean(synchronous);
timer.clean();
}
});
test('pulses time out after one second and reject held keys, airborne jump and extra args', async t => {
const timer = clock(t);
for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) {
const bot = fixture();
const pending = operations[name]().run(bot, []);
timer.tick(1000);
assert.equal(await pending, false);
assert.equal(bot.controlState[key], false);
clean(bot);
timer.clean();
bot.controlState[key] = true;
bot.calls = [];
assert.equal(await operations[name]().run(bot, []), false);
for (const args of [null, [false], [1]]) assert.equal(await operations[name]().run(bot, args), false);
assert.deepEqual(bot.calls, []);
clean(bot);
}
const bot = fixture();
bot.entity.onGround = false;
assert.equal(await operations.jump().run(bot, []), false);
assert.deepEqual(bot.calls, []);
timer.clean();
});
test('persistent sneak is explicit and idempotent and survives a concurrent squat pulse', async t => {
const timer = clock(t);
const bot = fixture();
const fn = operations.squat_continue_switch();
for (const enabled of [true, true, false, false]) {
assert.equal(await fn.run(bot, [enabled]), true);
assert.equal(bot.controlState.sneak, enabled);
clean(bot);
}
for (const args of [null, [], [0], ['true'], [undefined], [true, false]])
assert.equal(await fn.run(bot, args), false);
const pending = operations.squat_once().run(bot, []);
assert.equal(await fn.run(bot, [true]), true);
bot.emit('physicsTick');
assert.equal(await pending, true);
assert.equal(bot.controlState.sneak, true);
assert.equal(await operations.squat_once().run(bot, []), false);
assert.equal(bot.controlState.sneak, true);
assert.equal(await fn.run(bot, [false]), true);
clean(bot);
timer.clean();
});
test('directions use exact millisecond durations, reject held keys and leave unrelated keys alone', async t => {
const timer = clock(t);
for (const direction of ['forward', 'back', 'left', 'right']) {
for (const duration of [1, 5000]) {
const bot = fixture();
bot.controlState.sneak = true;
const pending = operations.use_direction_key().run(bot, [direction, duration]);
assert.equal(bot.controlState[direction], true);
bot.emit('physicsTick');
timer.tick(duration - 1);
assert.equal(bot.controlState[direction], true);
assert.equal(await operations.use_direction_key().run(bot, [direction, 1]), false);
timer.tick(1);
assert.equal(await pending, true);
assert.deepEqual(bot.calls, [[direction, true], [direction, false]]);
assert.equal(bot.controlState.sneak, true);
clean(bot);
timer.clean();
}
}
const bot = fixture();
for (const args of [null, [], ['forward'], ['forward', 1, 2], ...['jump', 'sneak', 'Forward', '', 1].map(key => [key, 1]),
...[0, -1, 5001, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '5', null].map(ms => ['forward', ms])])
assert.equal(await operations.use_direction_key().run(bot, args), false);
assert.deepEqual(bot.calls, []);
clean(bot);
timer.clean();
});
test('owned controls release on end, error, identity change, disconnect and synchronous failure', async t => {
const timer = clock(t);
for (const [name, key, args] of [['jump', 'jump', []], ['squat_once', 'sneak', []], ['use_direction_key', 'left', ['left', 20]]]) {
for (const reason of ['end', 'error', 'client-end', 'client-error', 'self', 'client', 'disconnect', 'throw', 'tick-throw']) {
const bot = fixture();
const client = bot._client;
if (reason === 'throw' || reason === 'tick-throw') {
const set = bot.setControlState;
bot.setControlState = (control, enabled) => {
set(control, enabled);
if (enabled) {
if (reason === 'tick-throw') bot.emit('physicsTick');
throw Error('failed');
}
};
}
const pending = operations[name]().run(bot, args);
if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed'));
if (reason.startsWith('client-')) client.emit(reason.slice(7), Error('closed'));
if (reason === 'self') bot.entity = {...bot.entity};
if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'});
if (reason === 'disconnect') client.state = 'disconnected';
if (['self', 'client', 'disconnect'].includes(reason)) {
bot.emit('physicsTick');
timer.tick(20);
}
assert.equal(await pending, false, `${name}: ${reason}`);
assert.equal(bot.controlState[key], false);
clean(bot, client);
timer.clean();
}
}
const bot = fixture();
bot.setControlState = () => { throw Error('API failure'); };
assert.equal(await operations.squat_continue_switch().run(bot, [true]), false);
clean(bot);
timer.clean();
});
test('look_at constructs a vector from position, accepts decimals and rejects invalid or eye-coincident targets', async t => {
const timer = clock(t);
const bot = fixture();
assert.equal(await operations.look_at().run(bot, [-1.5, 4.25, 8.75]), true);
assert.ok(bot.target instanceof Vec3);
assert.deepEqual(bot.target, new Vec3(-1.5, 4.25, 8.75));
assert.deepEqual(bot.entity.position, new Vec3(1, 2, 3));
for (const args of [null, [], [1, 2], [1, 2, 3, 4], ['1', 2, 3], [Infinity, 2, 3], [1, NaN, 3],
[1, 2, undefined], [1, 2 + bot.entity.eyeHeight, 3]]) {
delete bot.target;
assert.equal(await operations.look_at().run(bot, args), false);
assert.equal(bot.target, undefined);
clean(bot);
timer.clean();
}
});
test('look_at bounds waits and contains late rejections, synchronous errors and session changes', async t => {
const timer = clock(t);
for (const reason of ['timeout', 'end', 'client-end', 'error', 'self', 'client', 'disconnect', 'invalid', 'throw', 'reject']) {
const bot = fixture();
const client = bot._client;
let resolveLook, rejectLook;
bot.lookAt = () => new Promise((resolve, reject) => { resolveLook = resolve; rejectLook = reject; });
if (reason === 'throw') bot.lookAt = () => { throw Error('look failed'); };
if (reason === 'reject') bot.lookAt = () => Promise.reject(Error('look failed'));
const pending = operations.look_at().run(bot, [1.5, 2.5, 3.5]);
if (reason === 'timeout') timer.tick(5000);
if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed'));
if (reason === 'client-end') client.emit('end');
if (reason === 'self') bot.entity = {...bot.entity};
if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'});
if (reason === 'disconnect') client.state = 'disconnected';
if (reason === 'invalid') bot.entity.isValid = false;
if (['self', 'client', 'disconnect', 'invalid'].includes(reason)) resolveLook();
assert.equal(await pending, false, reason);
clean(bot, client);
timer.clean();
rejectLook?.(Error('late failure'));
await Promise.resolve();
}
});
test('all operations reject stale sessions before side effects', async () => {
for (const [name, args] of [['chat', ['hello']], ['jump', []], ['squat_once', []],
['squat_continue_switch', [true]], ['use_direction_key', ['forward', 1]], ['look_at', [1, 2, 3]]]) {
for (const change of [bot => { bot.entity = null; }, bot => { bot.entity.isValid = false; },
bot => { bot._client.state = 'disconnected'; }]) {
const bot = fixture();
change(bot);
assert.equal(await operations[name]().run(bot, args), false, name);
assert.deepEqual(bot.calls, []);
assert.equal(bot.message, undefined);
assert.equal(bot.target, undefined);
clean(bot);
}
}
});
test('dynamic discovery publishes all bot operations and their exact positional metadata', async () => {
const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url);
const category = (await get_function_list()).find(item => item.path === 'bot');
assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort());
const expected = {stop_current_function: [], chat: [['message', 'string']], jump: [], squat_once: [], squat_continue_switch: [['enabled', 'boolean']],
use_direction_key: [['direction', 'string'], ['duration_ms', 'number']], look_at: [['x', 'number'], ['y', 'number'], ['z', 'number']]};
for (const fn of category.function_list) {
assert.equal(fn.i18n.lang, 'zh-CN');
assert.ok(fn.i18n.name && fn.i18n.description);
assert.deepEqual(fn.args.map(arg => [arg.name, arg.type]), expected[fn.name]);
for (const arg of fn.args) {
assert.equal(arg.i18n.lang, 'zh-CN');
assert.ok(arg.i18n.name && arg.i18n.description);
assert.equal(Object.hasOwn(arg, 'default'), false);
}
}
});
+680
View File
@@ -0,0 +1,680 @@
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);
}
});
+356
View File
@@ -0,0 +1,356 @@
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';
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 = globalThis.mocks.init_workflow;',
'../../service/server/update/bot_status.ts': 'export const update_one_bot_status = globalThis.mocks.report;',
};
const compiled = await build({
stdin: {
contents: `export * from './bot/instance/index.ts';
export {handler} from './event_handler/bot/start_bot.ts';
export {handler as stop_handler} from './event_handler/bot/stop_bot.ts';`,
resolveDir: root,
loader: 'ts',
},
bundle: true,
platform: 'node',
format: 'cjs',
packages: 'external',
write: false,
plugins: [{
name: 'mock-startup-dependencies',
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 deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return {promise, resolve};
}
function config(bot_id = 1) {
return {
instance_config: {
username: 'TestBot', host: 'localhost', port: 25565, version: '1.20.4', bot_id, user_id: 1,
},
plugins: [],
workflow: [],
};
}
function setup() {
const contexts = [];
const errors = [];
const reports = [];
const socket = {emitted: [], emit(...args) { this.emitted.push(args); }};
const mocks = {
validation: 0,
connect_result: 0,
connect_error: false,
plugin_error: false,
plugin_calls: 0,
workflow_calls: 0,
uninject_calls: 0,
report_error: false,
report_gate: null,
async initialize_plugins(context) {
mocks.plugin_calls++;
assert.equal(context.listenerCount('status_update'), 1);
context.plugin_installed = true;
if (mocks.plugin_error) throw new Error('plugin initialization failed');
},
async init_workflow() { mocks.workflow_calls++; },
async report(socket, node_id, bot_id, user_id, status) {
reports.push({node_id, bot_id, user_id, status, snapshot: api.get_bot_context_status()});
if (mocks.report_error) throw new Error('status reporting failed');
if (mocks.report_gate) await mocks.report_gate;
},
BotContext: class extends EventEmitter {
constructor(config) {
super();
this.bot_config = config;
this.bot_status = 'offline';
this.plugin_manager = {init: () => mocks.initialize_plugins(this), dispose: async () => {
if (this.plugin_installed) { mocks.uninject_calls++; this.plugin_installed = false; }
}};
this.connect_calls = 0;
this.cleanup_calls = 0;
this.cleanup_gate = null;
this.cleanup_entered = deferred();
contexts.push(this);
}
validate_config() { return mocks.validation; }
async connect() {
this.connect_calls++;
if (this.bot_status === 'online') return 462;
if (this.bot_status === 'connecting') return 463;
if (mocks.connect_error) throw new Error('connection failed');
if ([0, 462, 463].includes(mocks.connect_result)) {
this.bot_status = mocks.connect_result === 462 ? 'online' : 'connecting';
this.emit('status_update', this.bot_status);
}
return mocks.connect_result;
}
clear_this_context() {
this.cleanup_calls++;
if (this.cleanup_promise) return this.cleanup_promise;
this.bot_status = 'disconnecting';
this.cleanup_entered.resolve();
this.cleanup_promise = (async () => {
if (this.cleanup_gate) await this.cleanup_gate;
await this.plugin_manager.dispose();
this.bot_status = 'offline';
this.emit('status_update', 'offline');
this.removeAllListeners();
})();
return this.cleanup_promise;
}
},
};
const module = {exports: {}};
vm.runInNewContext(compiled.outputFiles[0].text, {
module, exports: module.exports, require, mocks, structuredClone,
console: {error: (...args) => errors.push(args)},
});
const api = module.exports;
return {
...api, contexts, errors, reports, mocks, socket,
start(payload = config(), node_id = 1) { return api.start_bot_context(socket, payload, node_id); },
async ack(...args) {
const payload = args.length ? args[0] : {...config(), node_id: 1};
const replies = [];
await api.handler(socket, payload, reply => replies.push(reply));
assert.equal(replies.length, 1);
return {...replies[0]};
},
};
}
test('startup rejects malformed payloads, arrays, and node/user/bot IDs before creating a context', async () => {
const app = setup();
const malformed = [
null, undefined, [], 'invalid', {},
{...config(), instance_config: null},
{...config(), instance_config: []},
{...config(), plugins: null},
{...config(), plugins: {}},
{...config(), plugins: [null]},
{...config(), plugins: [[]]},
{...config(), workflow: undefined},
{...config(), workflow: {}},
{...config(), workflow: ['invalid']},
{...config(), workflow: [null]},
];
for (const payload of malformed) {
assert.equal(await app.start_bot_context(app.socket, payload, 1), 484);
}
for (const id of [undefined, null, '1', 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.equal(await app.start_bot_context(app.socket, config(), id), 484);
for (const field of ['bot_id', 'user_id']) {
const payload = config();
payload.instance_config[field] = id;
assert.equal(await app.start(payload), 484);
}
}
assert.equal(app.contexts.length, 0);
assert.equal(app.mocks.plugin_calls, 0);
});
test('stop handler validates bot_id and ACKs stopped, busy and failed cleanup', async () => {
const app = setup();
const ack = async payload => {
const replies = [];
await app.stop_handler(app.socket, payload, reply => replies.push({...reply}));
assert.equal(replies.length, 1);
return replies[0];
};
for (const payload of [null, undefined, [], 1, {}, {bot_id: '1'}, {bot_id: 0},
{bot_id: -1}, {bot_id: 1.5}, {bot_id: Number.MAX_SAFE_INTEGER + 1}]) {
assert.deepEqual(await ack(payload), {status: 'error', data: 484});
}
assert.deepEqual(await ack({bot_id: 1}), {status: 'success', data: 461});
assert.equal(await app.start(), 464);
const context = app.contexts[0];
const gate = deferred();
context.cleanup_gate = gate.promise;
const stopping = ack({bot_id: 1});
await context.cleanup_entered.promise;
assert.deepEqual(await ack({bot_id: 1}), {status: 'error', data: 465});
gate.resolve();
assert.deepEqual(await stopping, {status: 'success', data: 461});
assert.equal((await app.get_bot_context_status()).length, 0);
assert.deepEqual(await ack({bot_id: 1}), {status: 'success', data: 461});
await app.start();
app.contexts.at(-1).clear_this_context = async () => { throw new Error('cleanup failed'); };
assert.deepEqual(await ack({bot_id: 1}), {status: 'error', data: 466});
});
test('stop handler requires ACK callback before attempting cleanup', async () => {
const app = setup();
await app.start();
await app.stop_handler(app.socket, {bot_id: 1}, undefined);
assert.equal(app.contexts[0].cleanup_calls, 0);
assert.equal(app.socket.emitted[0][0], 'error');
assert.equal(app.socket.emitted[0][1].error, 481);
});
test('configuration validation runs before plugins and disposes rejected contexts', async () => {
const app = setup();
app.mocks.validation = 403;
assert.equal(await app.start(), 403);
assert.equal(app.mocks.plugin_calls, 0);
assert.equal(app.mocks.workflow_calls, 0);
assert.equal(app.contexts[0].connect_calls, 0);
assert.equal(app.contexts[0].cleanup_calls, 1);
assert.equal(app.contexts[0].bot_status, 'offline');
assert.equal((await app.get_bot_context_status()).length, 0);
});
test('connect failures propagate their codes, roll back, and release the lock for retry', async () => {
for (const code of [403, 430, 465, 466, 467, 468]) {
const app = setup();
app.mocks.connect_result = code;
assert.equal(await app.start(), code);
assert.equal(app.contexts[0].cleanup_calls, 1);
assert.equal(app.contexts[0].bot_status, 'offline');
assert.equal(app.mocks.uninject_calls, 1);
assert.equal((await app.get_bot_context_status()).length, 0);
app.mocks.connect_result = 0;
assert.equal(await app.start(), 464);
assert.equal(app.contexts.length, 2);
}
});
test('plugin and connect exceptions clean partially initialized contexts before retry', async () => {
for (const failure of ['plugin_error', 'connect_error']) {
const app = setup();
app.mocks[failure] = true;
assert.equal(await app.start(), 466);
assert.equal(app.contexts[0].cleanup_calls, 1);
assert.equal(app.contexts[0].bot_status, 'offline');
assert.equal(app.mocks.uninject_calls, 1);
assert.equal(app.errors.length, 1);
assert.equal((await app.get_bot_context_status()).length, 0);
if (failure === 'plugin_error') {
assert.equal(app.mocks.workflow_calls, 0);
assert.equal(app.contexts[0].connect_calls, 0);
}
app.mocks[failure] = false;
assert.equal(await app.start(), 464);
}
});
test('replacement awaits disposal, holds the action lock, and reports offline before removal', async () => {
const app = setup();
assert.equal(await app.start(), 464);
const old = app.contexts[0];
old.bot_status = 'offline';
const gate = deferred();
old.cleanup_gate = gate.promise;
const replacing = app.start();
await old.cleanup_entered.promise;
assert.equal(app.contexts.length, 1);
assert.equal((await app.get_bot_context_status())[0].status, 'disconnecting');
assert.equal(await app.start(), 465);
gate.resolve();
assert.equal(await replacing, 464);
assert.equal(app.contexts.length, 2);
const terminal = app.reports.find(report => report.status === 'offline');
assert.ok(terminal);
assert.equal((await terminal.snapshot)[0].status, 'offline');
assert.equal((await app.get_bot_context_status())[0].status, 'connecting');
});
test('cleanup of an old context cannot remove its replacement and current cleanup removes its own entry', async () => {
const app = setup();
await app.start();
const old = app.contexts[0];
old.bot_status = 'offline';
await app.start();
const current = app.contexts[1];
await old.clear_this_context();
assert.equal((await app.get_bot_context_status())[0].status, 'connecting');
assert.equal(await app.start(), 463);
await current.clear_this_context();
assert.equal((await app.get_bot_context_status()).length, 0);
});
test('manual start calls connect on existing active contexts and starts waiting retries immediately', async () => {
const app = setup();
await app.start();
for (const [status, code] of [['online', 462], ['connecting', 463], ['ready', 464], ['reconnecting', 464], ['disconnecting', 465]]) {
app.contexts[0].bot_status = status;
assert.equal(await app.start(), code);
}
assert.equal(app.contexts.length, 1);
assert.equal(app.contexts[0].cleanup_calls, 0);
assert.equal(app.contexts[0].connect_calls, 5);
});
test('status reporting failures are logged and pending reports do not block startup or disposal', async () => {
const app = setup();
app.mocks.report_error = true;
assert.equal(await app.start(), 464);
assert.equal(app.errors.length, 1);
assert.match(app.errors[0][0], /status update failed/);
const gate = deferred();
app.mocks.report_error = false;
app.mocks.report_gate = gate.promise;
await app.contexts[0].clear_this_context();
assert.equal((await app.get_bot_context_status()).length, 0);
assert.equal(await app.start(), 464);
gate.resolve();
});
test('handler ACKs only connected, connecting, and created results as success', async () => {
for (const [result, code] of [[0, 464], [462, 462], [463, 463], [403, 403], [465, 465], [466, 466], [467, 467], [468, 468]]) {
const app = setup();
app.mocks.connect_result = result;
assert.deepEqual(await app.ack(), {
status: [462, 463, 464].includes(code) ? 'success' : 'error', data: code,
});
}
});
test('handler ACKs malformed requests and unexpected exceptions as errors', async () => {
const app = setup();
for (const payload of [null, undefined, [], {}, {node_id: 0}, {node_id: '1'}, {node_id: Infinity},
{...config(0), node_id: 1}, {...config(), node_id: 1, plugins: null}]) {
assert.deepEqual(await app.ack(payload), {status: 'error', data: 484});
}
const payload = {get node_id() { throw new Error('unexpected payload failure'); }};
assert.deepEqual(await app.ack(payload), {status: 'error', data: 466});
assert.equal(app.errors.length, 1);
assert.equal(app.contexts.length, 0);
});
test('handler emits method-not-allowed without a callback and does not start a bot', async () => {
const app = setup();
for (const callback of [undefined, null, {}, 'invalid']) {
await app.handler(app.socket, {...config(), node_id: 1}, callback);
}
assert.equal(app.socket.emitted.length, 4);
for (const [event, payload] of app.socket.emitted) {
assert.equal(event, 'error');
assert.equal(payload.error, 481);
}
assert.equal(app.contexts.length, 0);
});
+713
View File
@@ -0,0 +1,713 @@
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();
});
+211
View File
@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import {randomUUID} from 'node:crypto';
import {createRequire} from 'node:module';
import {build} from 'esbuild';
const compiled = await build({
entryPoints: [new URL('../utils/bot_config_apply_handler.ts', import.meta.url).pathname],
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
plugins: [{name: 'isolation', setup(build) {
build.onResolve({filter: /bot\/instance\/index\.ts$/}, () => ({path: 'context', namespace: 'mock'}));
build.onResolve({filter: /console_print\.ts$/}, () => ({path: 'logger', namespace: 'mock'}));
build.onResolve({filter: /runtime\/compile\.ts$/}, () => ({path: 'compile', namespace: 'mock'}));
build.onLoad({filter: /.*/, namespace: 'mock'}, ({path}) => ({contents: path === 'context'
? 'export const get_bot_context = id => globalThis.lookup(id);'
: path === 'compile' ? 'export const compile_workflow = value => globalThis.compile(value);'
: 'export const console_print={log:value=>globalThis.logged(value),success:value=>value,error:value=>value};'}));
}}],
});
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const flush = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
const dto = (revision = 1, extras = {}) => ({command_id: randomUUID(), payload_version: 1, node_session_id: 'session',
bot_id: 1, config_revision: revision, plugins: [], workflows: [], ...extras});
const success = (request, state) => ({status: 'success', data: {command_id: request.command_id, bot_id: request.bot_id,
config_revision: request.config_revision, state}});
function setup() {
const calls = [], logs = [];
const contexts = new Map();
const options = {};
const module = {exports: {}};
vm.runInNewContext(compiled.outputFiles[0].text, {module, exports: module.exports, require: createRequire(import.meta.url),
Buffer, structuredClone, lookup: id => contexts.get(id), logged: value => logs.push(value),
compile: value => {
calls.push(['compile', structuredClone(value)]);
if (options.compile_error || !value?.id) throw new Error('secret workflow code');
return {definition: value};
}});
const context = {bot_config: {config_revision: 0, plugins: [], workflow: []}, bot_status: 'offline', is_disposed: false,
plugin_manager: {async apply_desired(plugins) {
calls.push(['plugins', structuredClone(plugins)]);
if (options.plugin_gate) await options.plugin_gate;
if (options.plugin_error) throw options.plugin_error;
}}, workflow_manager: {async apply(workflows, revision) {
calls.push(['workflows', structuredClone(workflows), revision]);
if (options.workflow_gate) await options.workflow_gate;
if (options.workflow_error) throw options.workflow_error;
}}, logger: {info: value => value, error: value => value}, log: value => logs.push(value)};
contexts.set(1, context);
const handler = module.exports.create_bot_config_apply_handler();
const socket = {connected: true, id: 'session'};
return {calls, logs, contexts, options, context, handler, socket, async run(request, ack) {
const replies = [];
await handler(socket, request, value => { replies.push(structuredClone(value)); ack?.(); });
assert.equal(replies.length, 1);
return replies[0];
}};
}
test('processing ACK is immediate, duplicate jobs install once and completed ACK has exact fields', async () => {
const s = setup(), request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {value: 1}}]});
const gate = deferred(); s.options.plugin_gate = gate.promise;
assert.deepEqual(await s.run(request), success(request, 'processing'));
for (let i = 0; i < 3; i++) assert.deepEqual(await s.run(request), success(request, 'processing'));
assert.deepEqual(s.calls.map(call => call[0]), ['plugins']);
gate.resolve(); await flush();
assert.deepEqual(await s.run(request), success(request, 'completed'));
assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']);
assert.equal(s.context.bot_config.config_revision, 1);
assert.deepEqual(s.context.bot_config.plugins, request.plugins);
assert.equal(s.logs.length, 2);
});
test('strict bounded JSON validation rejects malformed input before lookup or mutation', async () => {
const s = setup(), request = dto();
const unsafe = Object.defineProperty({...request}, 'plugins', {enumerable: true, get() { throw new Error('getter secret'); }});
const cyclic = dto(); cyclic.plugins.push(cyclic);
const bad = [null, [], {}, {...request, extra: true}, {...request, payload_version: 2}, {...request, command_id: 'bad'},
{...request, bot_id: 0}, {...request, config_revision: 0}, {...request, workflows: {}}, unsafe, cyclic,
new Proxy(request, {}), {...request, plugins: [{path: '../evil', name: 'a', config: {}}]},
{...request, plugins: [{path: 'runtime', name: 'a', config: {bad: NaN}}]},
{...request, plugins: [{path: 'runtime', name: 'a', config: {}}, {path: 'other', name: 'a', config: {}}]},
{...request, plugins: [{path: 'runtime', name: 'a', config: {huge: 'x'.repeat(256 * 1024)}}]}];
for (const data of bad) assert.equal((await s.run(data)).data.code, 'INVALID_PAYLOAD');
assert.deepEqual(s.calls, []);
});
test('exact 256KiB serialized limit accepts boundary and rejects next byte', async () => {
const s = setup();
const request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {text: ''}}]});
request.plugins[0].config.text = 'x'.repeat(256 * 1024 - Buffer.byteLength(JSON.stringify(request)));
assert.equal(Buffer.byteLength(JSON.stringify(request)), 256 * 1024);
assert.equal((await s.run(request)).status, 'success');
const excess = structuredClone(request); excess.command_id = randomUUID(); excess.plugins[0].config.text += 'x';
assert.equal((await s.run(excess)).data.code, 'INVALID_PAYLOAD');
await flush();
});
test('all workflows compile before plugins and duplicate graph IDs reject without mutation', async () => {
const s = setup();
for (const workflows of [[{id: 'valid'}, {}], [{id: 'same'}, {id: 'same'}]]) {
assert.equal((await s.run(dto(1, {workflows}))).data.code, 'INVALID_PAYLOAD');
assert.ok(s.calls.every(call => call[0] === 'compile'));
}
s.calls.length = 0;
const request = dto(1, {workflows: [{id: 'a'}, {id: 'b'}]});
await s.run(request); await flush();
assert.deepEqual(s.calls.map(call => call[0]), ['compile', 'compile', 'plugins', 'workflows']);
});
test('missing context is explicit and never creates managers, ACK throws do not repeat work', async () => {
const s = setup(), request = dto();
s.contexts.clear();
assert.deepEqual(await s.run(request), success(request, 'no_context'));
s.contexts.set(1, s.context);
assert.deepEqual(await s.run(request), success(request, 'no_context'));
await s.run(dto(), () => { throw new Error('ACK failed'); }); await flush();
assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']);
await s.handler(s.socket, new Proxy({}, {get() { throw new Error('unread'); }}));
});
test('socket session and context disposal checked before and after each await', async () => {
const s = setup();
assert.equal((await s.run(dto(1, {node_session_id: 'old'}))).data.code, 'SESSION_MISMATCH');
s.socket.connected = false;
assert.equal((await s.run(dto())).data.code, 'SESSION_MISMATCH');
s.socket.connected = true;
const request = dto(), gate = deferred(); s.options.plugin_gate = gate.promise;
await s.run(request);
s.context.is_disposed = true; gate.resolve(); await flush();
assert.equal((await s.run(request)).data.code, 'CONTEXT_CHANGED');
assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 0);
assert.equal(s.context.bot_config.config_revision, 0);
});
test('replacement context and reconnect never receive stale async completion', async () => {
for (const change of ['replace', 'session', 'disconnect']) {
const s = setup(), request = dto(), gate = deferred(); s.options.workflow_gate = gate.promise;
await s.run(request); await flush();
if (change === 'replace') s.contexts.set(1, {...s.context});
if (change === 'session') s.socket.id = 'new';
if (change === 'disconnect') s.socket.connected = false;
gate.resolve(); await flush();
if (change !== 'replace') { s.socket.id = 'session'; s.socket.connected = true; }
assert.equal((await s.run(request)).data.code, change === 'replace' ? 'CONTEXT_CHANGED' : 'SESSION_MISMATCH');
assert.equal(s.context.bot_config.config_revision, 0);
}
});
test('per-context queue serializes revisions and rejects stale, conflicting revision and command payloads', async () => {
const s = setup(), first = dto(2), gate = deferred(); s.options.plugin_gate = gate.promise;
await s.run(first);
const second = dto(3, {plugins: [{path: 'runtime', name: 'a', config: {}}]});
await s.run(second);
assert.equal((await s.run(dto(1))).data.code, 'STALE_REVISION');
assert.equal((await s.run({...second, command_id: randomUUID(), plugins: []})).data.code, 'REVISION_CONFLICT');
assert.equal((await s.run({...first, config_revision: 4})).data.code, 'COMMAND_CONFLICT');
assert.deepEqual(s.calls.map(call => call[0]), ['plugins']);
gate.resolve(); await flush();
assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows', 'plugins', 'workflows']);
assert.equal(s.context.bot_config.config_revision, 3);
assert.deepEqual(await s.run(second), success(second, 'completed'));
});
test('initial context revision fences old commands and identical applied configs preserve managers', async () => {
const s = setup();
s.context.bot_config.config_revision = 9;
assert.equal((await s.run(dto(8))).data.code, 'STALE_REVISION');
const request = dto(9);
await s.run(request); await flush();
assert.deepEqual(await s.run(request), success(request, 'completed'));
assert.deepEqual(s.calls, []);
assert.equal((await s.run(dto(9, {plugins: [{path: 'runtime', name: 'a', config: {}}]}))).data.code, 'REVISION_CONFLICT');
});
test('workflow partial failure is reported safely and same command recovers on next attempt', async () => {
const s = setup(), request = dto();
s.options.workflow_error = {status: 'error', data: {code: 'LEAK', message: 'password secret'}};
await s.run(request); await flush();
const reply = await s.run(request);
assert.equal(reply.data.code, 'PARTIAL_APPLY_FAILED');
assert.equal(JSON.stringify([reply, s.logs]).includes('secret'), false);
assert.equal(s.context.bot_config.config_revision, 0);
s.options.workflow_error = undefined;
assert.deepEqual(await s.run(request), success(request, 'processing'));
await flush();
assert.deepEqual(await s.run(request), success(request, 'completed'));
assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 2);
});
test('pending jobs capped at 32 without evicting inflight work', async () => {
const s = setup(), gate = deferred(); s.options.plugin_gate = gate.promise;
const requests = Array.from({length: 32}, (_, i) => dto(i + 1));
for (const request of requests) assert.equal((await s.run(request)).data.state, 'processing');
assert.equal((await s.run(dto(33))).data.code, 'APPLY_BUSY');
assert.deepEqual(await s.run(requests[0]), success(requests[0], 'processing'));
gate.resolve(); await new Promise(setImmediate);
assert.deepEqual(await s.run(requests.at(-1)), success(requests.at(-1), 'completed'));
assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 2);
assert.equal((await s.run(requests[1])).data.code, 'STALE_REVISION');
});
test('completed job cache retains 100 entries and evicted commands cannot roll revisions back', async () => {
const s = setup(), first = dto(1);
await s.run(first); await flush();
for (let revision = 2; revision <= 101; revision++) { await s.run(dto(revision)); await flush(); }
assert.equal((await s.run(first)).data.code, 'STALE_REVISION');
assert.equal(s.context.bot_config.config_revision, 101);
assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 101);
});
+411
View File
@@ -0,0 +1,411 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const names = ['attack_cursor_entity', 'attack_nearest_entity', 'use_item_on_cursor_entity',
'use_item_on_nearest_entity', 'feed_cursor_entity', 'feed_nearest_entity',
'open_cursor_entity_container', 'open_nearest_entity_container',
'null_main_hand_right_cursor_entity', 'null_main_hand_right_nearest_entity'];
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
const {init} = await tsImport(`../bot/function/entity/${name}.ts`, import.meta.url);
return [name, () => new init()];
})));
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const {Vec3} = mf('vec3');
function fixture() {
const bot = new EventEmitter();
bot.entity = {id: 1, position: new Vec3(0, 0, 0), height: 1.8, eyeHeight: 1.62, isValid: true};
bot.entities = {1: bot.entity};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.currentWindow = null;
bot.inventory = {slots: []};
bot.heldItem = {name: 'wheat'};
bot.supportFeature = () => false;
bot.looks = [];
bot.attacks = [];
bot.interactions = [];
bot.lookAt = async point => { bot.looks.push(point); };
bot.attack = target => { bot.attacks.push(target); };
bot.interactEntity = (target, options) => { bot.interactions.push({target, options}); };
const target = {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 0.9, height: 1.4, isValid: true};
bot.entities[2] = target;
bot.entityAtCursor = (...args) => { bot.cursorArgs = args; return target; };
return {bot, target};
}
test('nearest attack matches names and selects by eye-to-bounding-box distance', async () => {
const {bot, target} = fixture();
const other = {id: 3, name: 'player', username: 'COW_keeper', isValid: true,
position: new Vec3(2.2, 0, 0), width: 3, height: 2};
bot.entities[3] = other;
assert.equal(await operations.attack_nearest_entity().run(bot, ['CoW', 1, false]), true);
assert.deepEqual(bot.attacks, [other]);
assert.equal(bot.looks.length, 0);
other.isValid = false;
assert.equal(await operations.attack_nearest_entity().run(bot, ['cow', 1, false]), false);
assert.equal(await operations.attack_nearest_entity().run(bot, ['cow', 3, false]), true);
assert.equal(bot.attacks.at(-1), target);
for (const args of [['', 3, false], ['cow', 0, false], ['cow', Infinity, false], ['cow'], ['cow', 3]])
assert.equal(await operations.attack_nearest_entity().run(bot, args), false);
});
test('cursor attack forwards reach and block options and rejects stale or distant targets', async () => {
const {bot, target} = fixture();
assert.equal(await operations.attack_cursor_entity().run(bot, [3, false]), true);
assert.deepEqual(bot.cursorArgs, [3, true, false]);
target.position.x = 20;
assert.equal(await operations.attack_cursor_entity().run(bot, [3, true]), false);
target.position.x = 2;
target.isValid = false;
assert.equal(await operations.attack_cursor_entity().run(bot, [3, false]), false);
assert.equal(bot.attacks.length, 1);
});
test('all entity functions aim at the center only when need_look is true', async () => {
for (const name of names) {
for (const needLook of [undefined, false, true]) {
const {bot, target} = fixture();
if (name.startsWith('null_main_hand')) bot.heldItem = null;
const args = name.includes('cursor') ? [3, false] : ['cow', 3];
if (name.startsWith('feed') || name.startsWith('use_item')) args.push(false);
if (name.includes('nearest')) args.push(false);
if (needLook !== undefined) args.push(needLook);
bot.activateEntity = () => { throw Error('automatic aim must not be used'); };
if (name.startsWith('open')) bot.interactEntity = (entity, options) => {
assert.equal(entity, target);
assert.deepEqual(options, {hand: 0});
bot.currentWindow = {id: 7};
bot.emit('windowOpen', bot.currentWindow);
};
const label = `${name}: ${needLook}`;
assert.equal(await operations[name]().run(bot, args), true, label);
assert.deepEqual(bot.looks, needLook ? [target.position.offset(0, target.height / 2, 0)] : [], label);
}
}
});
test('all entity functions revalidate after optional aiming before interaction', async () => {
for (const name of names) {
const {bot, target} = fixture();
if (name.startsWith('null_main_hand')) bot.heldItem = null;
const args = name.includes('cursor') ? [3, false] : ['cow', 3];
if (name.startsWith('feed') || name.startsWith('use_item')) args.push(false);
if (name.includes('nearest')) args.push(false);
args.push(true);
bot.lookAt = async () => { target.isValid = false; };
assert.equal(await operations[name]().run(bot, args), false, name);
assert.deepEqual(bot.attacks, [], name);
assert.deepEqual(bot.interactions, [], name);
assert.equal(bot.listenerCount('windowOpen'), 0, name);
}
});
test('item interaction and feeding select hands and aim before interaction', async () => {
for (const name of ['use_item_on_cursor_entity', 'feed_cursor_entity', 'use_item_on_nearest_entity', 'feed_nearest_entity']) {
const {bot, target} = fixture();
const args = name.includes('cursor') ? [3, false, false, true] : ['cow', 4.5, false, false, true];
assert.equal(await operations[name]().run(bot, args), true);
assert.equal(bot.interactions[0].target, target);
assert.equal(bot.interactions[0].options.hand, 0);
assert.ok(bot.interactions[0].options.position instanceof Vec3);
assert.equal(bot.looks.length, 1);
args[2] = true;
assert.equal(await operations[name]().run(bot, args), false);
bot.inventory.slots[45] = {name: 'wheat'};
assert.equal(await operations[name]().run(bot, args), true);
assert.equal(bot.interactions.at(-1).options.hand, 1);
bot.supportFeature = () => true;
assert.equal(await operations[name]().run(bot, args), false);
args[2] = false;
bot.heldItem = null;
assert.equal(await operations[name]().run(bot, args), false);
}
});
test('look completion rechecks entity validity, range and held items before side effects', async () => {
for (const change of [
({target}) => { target.isValid = false; },
({target}) => { target.position.x = 30; },
({bot}) => { delete bot.entities[2]; },
({bot}) => { bot.entity = {...bot.entity}; },
]) {
const f = fixture();
f.bot.lookAt = async () => change(f);
assert.equal(await operations.attack_nearest_entity().run(f.bot, ['cow', 3, false, true]), false);
assert.equal(f.bot.attacks.length, 0);
}
const {bot} = fixture();
bot.lookAt = async () => { bot.heldItem = null; };
assert.equal(await operations.use_item_on_nearest_entity().run(bot, ['cow', 4.5, false, false, true]), false);
assert.equal(bot.interactions.length, 0);
});
test('entity container opening waits for windowOpen and cleans listeners', async () => {
for (const name of ['open_cursor_entity_container', 'open_nearest_entity_container']) {
const {bot} = fixture();
const args = name.includes('cursor') ? [3, false] : ['cow', 4.5, false];
bot.interactEntity = () => {
bot.currentWindow = {id: 7};
bot.emit('windowOpen', bot.currentWindow);
};
assert.equal(await operations[name]().run(bot, args), true);
assert.equal(bot.listenerCount('windowOpen'), 0);
assert.equal(bot.listenerCount('end'), 0);
assert.equal(await operations[name]().run(bot, args), false);
bot.currentWindow = null;
bot.interactEntity = () => { throw Error('rejected'); };
assert.equal(await operations[name]().run(bot, args), false);
assert.equal(bot.listenerCount('windowOpen'), 0);
bot.interactEntity = () => { bot.emit('end', 'closed'); };
assert.equal(await operations[name]().run(bot, args), false);
assert.equal(bot.listenerCount('windowOpen'), 0);
}
});
test('entity container opening times out and removes its listeners', async t => {
t.mock.timers.enable({apis: ['setTimeout']});
const {bot} = fixture();
bot.interactEntity = () => {};
const pending = operations.open_cursor_entity_container().run(bot, [3, false]);
for (let i = 0; i < 20; i++) await Promise.resolve();
t.mock.timers.tick(5000);
assert.equal(await pending, false);
assert.equal(bot.listenerCount('windowOpen'), 0);
assert.equal(bot.listenerCount('end'), 0);
});
test('entity functions reject malformed arguments and contain API failures', async () => {
for (const name of names) {
const {bot} = fixture();
const cursor = name.includes('cursor');
const item = name.startsWith('use_item') || name.startsWith('feed');
const args = cursor ? [3, false] : ['cow', 3];
if (item) args.push(false);
if (!cursor) args.push(false);
const fn = operations[name]();
for (const invalid of [null, [], ...[undefined, null, 1, 'true', {}].map(value => [...args, value]),
[...args, false, false], cursor ? [-1, false] : ['', 3]])
assert.equal(await fn.run(bot, invalid), false, name);
if (!cursor) {
for (const invalid of [args.slice(0, -1), ...[undefined, null, 0, 1, 'true'].map(lock => [...args.slice(0, -1), lock])])
assert.equal(await fn.run(bot, invalid), false, `${name}: mandatory boolean lock_target`);
}
bot.attack = () => { throw Error('failed'); };
bot.interactEntity = () => { throw Error('failed'); };
assert.equal(await fn.run(bot, args), false, name);
}
});
test('empty-main-hand interaction requires an empty hand before and after aiming', async () => {
for (const name of names.filter(name => name.startsWith('null_main_hand'))) {
const {bot, target} = fixture();
const args = name.includes('cursor') ? [3, false, true] : ['cow', 3, false, true];
const fn = operations[name]();
assert.equal(await fn.run(bot, args), false);
assert.equal(bot.looks.length, 0);
assert.equal(bot.interactions.length, 0);
bot.heldItem = null;
bot.inventory.slots[45] = {name: 'shield'};
assert.equal(await fn.run(bot, args), true);
assert.deepEqual(bot.interactions, [{target, options: {hand: 0}}]);
assert.equal(bot.looks.length, 1);
bot.lookAt = async () => { bot.heldItem = {name: 'wheat'}; };
assert.equal(await fn.run(bot, args), false);
assert.equal(bot.interactions.length, 1);
bot.heldItem = null;
bot.lookAt = async () => { target.isValid = false; };
assert.equal(await fn.run(bot, args), false);
assert.equal(bot.interactions.length, 1);
}
});
test('all nearest functions match partial type IDs with an optional Minecraft namespace', async () => {
for (const name of names.filter(name => name.includes('nearest'))) {
for (const query of ['co', 'MINECRAFT:CO', 'minecraft:cow']) {
const {bot, target} = fixture();
if (name.startsWith('null_main_hand')) bot.heldItem = null;
if (name.startsWith('open')) bot.interactEntity = entity => {
assert.equal(entity, target);
bot.currentWindow = {id: 7};
bot.emit('windowOpen', bot.currentWindow);
};
const args = [query, 3];
if (name.startsWith('feed') || name.startsWith('use_item')) args.push(false);
args.push(false);
assert.equal(await operations[name]().run(bot, args), true, `${name}: ${query}`);
}
}
const {bot, target} = fixture();
target.name = 'minecraft:zombie';
assert.equal(await operations.attack_nearest_entity().run(bot, ['zom', 3, false]), true);
assert.equal(await operations.attack_nearest_entity().run(bot, ['minecraft:', 3, false]), false);
});
const nearestNames = names.filter(name => name.includes('nearest'));
function nearestArgs(name, {query = 'cow', reach = 5, offHand = false, lock = true, needLook} = {}) {
const args = name.startsWith('feed') || name.startsWith('use_item')
? [query, reach, offHand, lock] : [query, reach, lock];
if (needLook !== undefined) args.push(needLook);
return args;
}
function addCloser(bot) {
const closer = {id: 3, name: 'cow', position: new Vec3(1, 0, 0), width: 0.9, height: 1.4, isValid: true};
bot.entities[closer.id] = closer;
return closer;
}
async function runNearest(name, bot, options) {
bot.heldItem = name.startsWith('null_main_hand') ? null : {name: 'wheat'};
bot.inventory.slots[45] = {name: 'wheat'};
bot.currentWindow = null;
const acted = [];
bot.attack = target => { acted.push(target); };
bot.interactEntity = target => {
acted.push(target);
if (name.startsWith('open')) {
bot.currentWindow = {id: 7};
bot.emit('windowOpen', bot.currentWindow);
}
};
// Recreating init on every call verifies that lock state belongs to the bot and invocation key.
const result = await operations[name]().run(bot, nearestArgs(name, options));
return {result, acted};
}
test('all nearest locks survive init recreation and ignore a newly closer entity', async () => {
for (const name of nearestNames) {
const {bot, target} = fixture();
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name);
addCloser(bot);
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name);
}
});
test('all nearest locks share omitted and explicit false need_look keys', async () => {
for (const name of nearestNames) {
for (const firstLook of [undefined, false]) {
const {bot, target} = fixture();
assert.deepEqual(await runNearest(name, bot, {needLook: firstLook}), {result: true, acted: [target]}, name);
addCloser(bot);
assert.deepEqual(await runNearest(name, bot, {needLook: firstLook === false ? undefined : false}),
{result: true, acted: [target]}, name);
assert.equal(bot.looks.length, 0, name);
}
}
});
test('all nearest locks reacquire after target loss, invalidation or same-id object replacement', async () => {
for (const name of nearestNames) {
for (const reason of ['lost', 'invalid', 'replaced']) {
const {bot, target} = fixture();
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, `${name}: ${reason}`);
const closer = addCloser(bot);
if (reason === 'lost') delete bot.entities[target.id];
if (reason === 'invalid') target.isValid = false;
if (reason === 'replaced') bot.entities[target.id] = {...target};
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [closer]}, `${name}: ${reason}`);
target.position.x = 0.5;
if (reason === 'lost') bot.entities[target.id] = target;
if (reason === 'invalid') target.isValid = true;
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [closer]}, `${name}: reacquired lock persists`);
}
}
});
test('all nearest locks return false out of range and retain the original target', async () => {
for (const name of nearestNames) {
const {bot, target} = fixture();
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name);
addCloser(bot);
target.position.x = 20;
const lookCount = bot.looks.length;
assert.deepEqual(await runNearest(name, bot), {result: false, acted: []}, name);
assert.equal(bot.looks.length, lookCount, `${name}: no aiming outside reach`);
target.position.x = 2;
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name);
}
});
test('all nearest functions with lock_target false select afresh without replacing a true lock', async () => {
for (const name of nearestNames) {
const {bot, target} = fixture();
assert.deepEqual(await runNearest(name, bot, {lock: false}), {result: true, acted: [target]}, name);
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name);
const closer = addCloser(bot);
assert.deepEqual(await runNearest(name, bot, {lock: false}), {result: true, acted: [closer]}, name);
closer.position.x = 4;
assert.deepEqual(await runNearest(name, bot, {lock: false}), {result: true, acted: [target]}, name);
closer.position.x = 1;
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, name);
}
});
test('all nearest locks isolate exact query, reach, off_hand and need_look argument values', async () => {
for (const name of nearestNames) {
const variations = [{query: 'COW'}, {query: ' cow '}, {query: 'minecraft:cow'}, {query: 'co'}, {reach: 6}, {needLook: true}];
if (name.startsWith('feed') || name.startsWith('use_item')) variations.push({offHand: true});
for (const variation of variations) {
const {bot, target} = fixture();
const label = `${name}: ${JSON.stringify(variation)}`;
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, label);
const closer = addCloser(bot);
assert.deepEqual(await runNearest(name, bot, variation), {result: true, acted: [closer]}, label);
assert.deepEqual(await runNearest(name, bot), {result: true, acted: [target]}, label);
closer.position.x = 4;
assert.deepEqual(await runNearest(name, bot, variation), {result: true, acted: [closer]}, label);
}
}
});
test('nearest locks isolate function names, including feed and use_item sharing an operation', async () => {
for (const first of nearestNames) {
for (const second of nearestNames.filter(name => name !== first)) {
const {bot, target} = fixture();
const label = `${first} / ${second}`;
assert.deepEqual(await runNearest(first, bot), {result: true, acted: [target]}, label);
const closer = addCloser(bot);
assert.deepEqual(await runNearest(second, bot), {result: true, acted: [closer]}, label);
assert.deepEqual(await runNearest(first, bot), {result: true, acted: [target]}, label);
closer.position.x = 4;
assert.deepEqual(await runNearest(second, bot), {result: true, acted: [closer]}, label);
}
}
});
test('all nearest locks isolate bots with the same entity IDs and exact arguments', async () => {
for (const name of nearestNames) {
const first = fixture();
const second = fixture();
assert.deepEqual(await runNearest(name, first.bot), {result: true, acted: [first.target]}, name);
addCloser(first.bot);
assert.deepEqual(await runNearest(name, second.bot), {result: true, acted: [second.target]}, name);
addCloser(second.bot);
assert.deepEqual(await runNearest(name, first.bot), {result: true, acted: [first.target]}, name);
assert.deepEqual(await runNearest(name, second.bot), {result: true, acted: [second.target]}, name);
}
});
test('dynamic loader publishes all entity functions with positional metadata', async () => {
const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url);
const category = (await get_function_list()).find(item => item.path === 'entity');
assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort());
for (const template of category.function_list) {
const cursor = template.name.includes('cursor');
const item = template.name.startsWith('feed') || template.name.startsWith('use_item');
assert.deepEqual(template.args.map(arg => arg.type), [...(cursor ? ['number', 'boolean'] : ['string', 'number']),
...(item ? ['boolean'] : []), ...(cursor ? [] : ['boolean']), 'boolean']);
if (!cursor) {
assert.equal(template.args.at(-2).name, 'lock_target');
assert.equal(Object.hasOwn(template.args.at(-2), 'default'), false);
}
assert.equal(template.args.at(-1).name, 'need_look');
assert.equal(template.args.at(-1).default, false);
assert.equal(template.args.at(-1).i18n.lang, 'zh-CN');
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"source": "forward_server/tests/token.test.ts:57 AES-256-GCM fixed wire vector",
"token": "AAECAwQFBgcICQoLKReCzFKlEY6g42nAQLV9_x0ByGStIbnU7jVnJBA372eKB2zodLxLE9mBUS1agWpMmTc7swZYqNfSWy53I7FUGk6-ik0it6dgU_Sr5B4xex5F89Dl89k2hg2wkenWZpeIksBqjx2kPX2vj1o05iSueFxOXLopUmDFt7SWUFLzsQUdE5Q1nyTH7cWi71k5S5Fmc-Fo9qetCUp3QDyMPaD5Bn4sTrmUoSztp1AHREowEAMFMNAhjQ5Y5-aVDg08biMmw9X2KbBMukqomk5t-cHk0-l6r2XudPdPxH6B4YFhfE8iqIPnekUXz-GyCwuOODvdPajUKQs48s13OCVyhW5WzvxsoimN1KrM0ac0S1ybB9aRvkXSbkDHuEfy9Q-NCcddJacIKjIpFqeIrRV0EJAdCaMirNPIer_5F4YvmJB9H5UKNGSg4mZ-DYkvmXsvYAleGpSJzIpwjWG_pXb8dbjltryrQQ5HbXSIvs_8GBVGZdROTmwXCOrI_ZS8azkFA29zrHRBHiz-H3Rs0bv0GUgfZPoz_eJWxyA",
"scope": {
"forward_id": 0,
"forward_session_id": "a89acd9a-0ab3-44ac-812e-e221a5b915e0",
"node_session_id": "mZAb09_-SocketIOmainID",
"node_id": 1,
"bot_id": 2
},
"request_id": "45264c09-7529-478f-b3ef-6a84bec8c1a7"
}
+720
View File
@@ -0,0 +1,720 @@
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {EventEmitter} from 'node:events';
import {readFile, mkdtemp, mkdir, writeFile, rm, unlink} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import {createRequire} from 'node:module';
import {setTimeout as delay} from 'node:timers/promises';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
import {io} from 'socket.io-client';
import {tsImport} from 'tsx/esm/api';
const root = fileURLToPath(new URL('../', import.meta.url));
const vectors = JSON.parse(await readFile(new URL('./fixtures/forward_vectors.json', import.meta.url), 'utf8'));
const compiled = await build({
stdin: {contents: `export * from './bot/forward/manager.ts';
export * from './bot/forward/stream.ts'; export * from './bot/forward/files.ts';
export * from './utils/forward_protocol.ts';`,
resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, metafile: true,
plugins: [{name: 'isolated-default-bot-registry', setup(build) {
build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'test'}));
build.onLoad({filter: /.*/, namespace: 'test'}, () => ({loader: 'js', contents:
'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'}));
}}],
});
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(createRequire(import.meta.url), module, module.exports);
const {NodeForwardManager, BotStream, BotFileService, validate_command, validate_join, validate_rpc, success_data, error_ack} = module.exports;
const success = data => ({status: 'success', data});
const scope = () => ({forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'main-socket-session', node_id: 2, bot_id: 3});
const command = (s = vectors.scope, overrides = {}) => ({...s, url: 'http://127.0.0.1:1', path: '/relay/socket.io',
namespace: '/forward', token: vectors.token, expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000, ...overrides});
const request = (method = 'bot.snapshot', payload = {}, request_id = randomUUID()) => ({request_id, method, payload});
const node_request = (s, overrides = {}) => ({...request(), correlation_id: randomUUID(),
forward_session_id: s.forward_session_id, deadline_at: Date.now() + 1000, ...overrides});
function code(ack, expected) {
assert.equal(ack.status, 'error');
assert.deepEqual(Object.keys(ack.data).sort(), ['code', 'message', 'retryable']);
assert.equal(ack.data.code, expected);
assert.equal(typeof ack.data.message, 'string');
assert.equal(typeof ack.data.retryable, 'boolean');
}
const rejects = (action, expected = 'INVALID_REQUEST') => assert.throws(action, error => error.code === expected);
function received(socket, event, predicate = () => true) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { socket.off(event, listener); reject(new Error(`Missing ${event}`)); }, 2500);
const listener = value => {
if (!predicate(value)) return;
clearTimeout(timer); socket.off(event, listener); resolve(value);
};
socket.on(event, listener);
});
}
const ack = (socket, name, payload) => socket.timeout(2500).emitWithAck(name, payload);
class FakeSocket extends EventEmitter {
connected = false;
packets = [];
io = {engine: {writeBuffer: []}};
volatile = {emit: (event, value) => this.packets.push([event, value])};
constructor(cmd, responder) { super(); this.cmd = cmd; this.responder = responder; }
connect() { queueMicrotask(() => {this.connected = true; super.emit('connect');}); }
disconnect() { if (this.connected) {this.connected = false; super.emit('disconnect', 'test');} }
timeout() { return {emit: (event, payload, callback) => {
this.packets.push([event, payload]);
const data = event === 'forward_node_ready' ? {node_ready: true} : {
...Object.fromEntries(Object.keys(vectors.scope).map(key => [key, this.cmd[key]])),
role: 'node', node_ready: event === 'reauth', expires_at: this.cmd.expires_at,
};
if (this.responder) this.responder(event, success(data), callback);
else queueMicrotask(() => callback(null, success(data)));
}}; }
rpc(raw) { return new Promise(resolve => super.emit('relay_request', raw, resolve)); }
}
function fake_manager(t, cmd, responder, stream_factory) {
const sockets = [];
const manager = new NodeForwardManager(cmd.node_id, cmd.node_session_id, {
socket_factory: (_url, options) => {
const socket = new FakeSocket(cmd, responder); socket.options = options; sockets.push(socket); return socket;
}, stream_factory,
});
t.after(() => manager.stop());
return {manager, sockets};
}
test('independent Node bundle has no relay or token issuer imports; fixed token stays opaque and byte-identical', async t => {
assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server')));
assert.doesNotMatch(compiled.outputFiles[0].text, /createCipheriv|createDecipheriv|encode_token|decode_token/);
const cmd = command();
const f = fake_manager(t, cmd);
assert.equal(validate_command(cmd).token, vectors.token);
const ready = await f.manager.ensure(cmd);
assert.deepEqual(ready, success({...vectors.scope, node_ready: true, lease_expires_at: cmd.expires_at}));
assert.equal(f.sockets[0].options.auth.token, vectors.token);
assert.equal(f.sockets[0].options.reconnection, false);
assert.deepEqual(f.sockets[0].packets, [
['join_room', {token: vectors.token}], ['forward_node_ready', {forward_session_id: cmd.forward_session_id}],
]);
// The copied token has Web claims and fixed historical times. Node never interprets them.
const opaque = command({...vectors.scope, forward_session_id: randomUUID()}, {token: 'opaque-not-a-token'});
const second = fake_manager(t, opaque);
assert.equal((await second.manager.ensure(opaque)).status, 'success');
assert.equal(second.sockets[0].packets[0][1].token, opaque.token);
});
test('local command validator enforces exact wire DTO, scope IDs, URL and numeric bounds', () => {
const valid = command();
for (const key of Object.keys(valid)) {
const missing = {...valid}; delete missing[key]; rejects(() => validate_command(missing));
}
for (const patch of [{extra: 'SECRET'}, {node_id: 0}, {bot_id: '2'}, {forward_id: -1},
{forward_session_id: 'session'}, {node_session_id: ' '}, {node_session_id: '\u754c'.repeat(86)},
{token: ''}, {token: 'x'.repeat(8193)}, {deadline_at: Infinity}, {expires_at: 1.5},
{url: 'file:///tmp/relay'}, {url: 'http://user:password@localhost'}, {url: 'http://localhost/path'},
{url: 'http://localhost?token=SECRET'}, {url: 'http://localhost#SECRET'}, {path: 'socket.io'}, {namespace: '/bad?x'}]) {
rejects(() => validate_command({...valid, ...patch}));
}
assert.equal(validate_command({...valid, forward_id: 0, node_session_id: 'x'.repeat(256)}).forward_id, 0);
assert.equal(validate_command({...valid, forward_session_id: '11111111-1111-1111-1111-111111111111'}).forward_session_id,
'11111111-1111-1111-1111-111111111111');
});
test('local Node RPC validator allows only exact public method payloads and relay correlation metadata', () => {
const valid = node_request(vectors.scope, {request_id: vectors.request_id});
for (const [method, payload] of [['bot.snapshot', {}], ['bot.logs', {limit: 200, stream_id: randomUUID(), after_seq: 0}],
['get_log_history', {}], ['get_context_info', {}], ['get_data_file_list', {}],
['get_file_data', {id: randomUUID()}], ['get_file_data', {id: randomUUID(), offset: 0, length: 262144}],
['tab_complete', {text: '/he'}], ['tab_complete', {text: ''}],
['bot.chat', {message: 'hello'}], ['execute_function', {function: 'inventory', name: 'left_click_slot', args: [9]}]]) assert.equal(validate_rpc({...valid, method, payload}).method, method);
for (const payload of [{}, {function: '../inventory', name: 'left_click_slot', args: []},
{function: 'inventory', name: '', args: []}, {function: 'inventory', name: 'x', args: {}},
{function: 'inventory', name: 'x', args: [], extra: true}])
rejects(() => validate_rpc({...valid, method: 'execute_function', payload}));
for (const payload of [{}, {text: 1}, {text: 'x'.repeat(257)}, {text: '/he\n'}, {text: '/he', extra: true}])
rejects(() => validate_rpc({...valid, method: 'tab_complete', payload}));
rejects(() => validate_rpc({...valid, method: 'get_log_history', payload: {limit: 21}}));
rejects(() => validate_rpc({...valid, method: 'get_context_info', payload: {name: 'health'}}));
rejects(() => validate_rpc({...valid, method: 'get_data_file_list', payload: {path: '../'}}));
for (const payload of [{id: '../secret'}, {id: randomUUID(), path: '/tmp'},
{id: randomUUID(), offset: -1}, {id: randomUUID(), offset: 0.5},
{id: randomUUID(), length: 0}, {id: randomUUID(), length: 262145}])
rejects(() => validate_rpc({...valid, method: 'get_file_data', payload}));
for (const key of Object.keys(valid)) {
const missing = {...valid}; delete missing[key]; rejects(() => validate_rpc(missing));
}
for (const patch of [{token: 'SECRET'}, {request_id: 'bad'}, {correlation_id: 'bad'}, {deadline_at: NaN},
{method: 'bot.delete'}, {method: 'bot.subscribe', payload: {events: ['log']}}, {payload: {config: 'SECRET'}},
{method: 'bot.logs', payload: {limit: 0}}, {method: 'bot.logs', payload: {limit: 201}},
{method: 'bot.logs', payload: {limit: 1, after_seq: 0}},
{method: 'bot.chat', payload: {message: 'hello', password: 'SECRET'}},
...['', ' ', 'x'.repeat(257), 'hello\nworld', 'hello\rworld', 'hello\0world'].map(message => ({method: 'bot.chat', payload: {message}}))]) {
rejects(() => validate_rpc({...valid, ...patch}));
}
});
test('local join/ACK validation binds every scope field, lease, role and readiness without exposing errors', () => {
const cmd = command();
const data = {...vectors.scope, role: 'node', node_ready: false, expires_at: cmd.expires_at};
assert.deepEqual(validate_join(success(data), cmd, true), data);
for (const patch of [{forward_id: 9}, {forward_session_id: randomUUID()}, {node_session_id: 'old'}, {node_id: 9},
{bot_id: 9}, {role: 'web'}, {node_ready: true}, {expires_at: cmd.expires_at + 1}, {secret: 'SECRET'}]) {
rejects(() => validate_join(success({...data, ...patch}), cmd, true), 'SESSION_STALE');
}
for (const value of [null, {}, {status: 'success'}, {status: 'success', data, extra: true},
{status: 'error', data: {code: 'SECRET', message: 'SECRET', retryable: false}}]) {
rejects(() => success_data(value), 'RELAY_UNAVAILABLE');
}
rejects(() => success_data(error_ack('TIMEOUT')), 'TIMEOUT');
});
test('manager rejects stale scopes and expired commands before creating a transport', async t => {
const cmd = command(); const f = fake_manager(t, cmd);
for (const [patch, expected] of [[{node_session_id: 'old'}, 'SESSION_STALE'], [{node_id: 99}, 'FORBIDDEN'],
[{deadline_at: Date.now() - 1}, 'TIMEOUT'], [{expires_at: Date.now() - 1}, 'TOKEN_EXPIRED']]) {
code(await f.manager.ensure({...cmd, ...patch}), expected);
}
assert.equal(f.sockets.length, 0);
f.manager.stop(); code(await f.manager.ensure(cmd), 'SESSION_STALE');
});
test('manager deadline rejects late join ACK and prevents readiness or later RPC side effects', async t => {
const cmd = command(vectors.scope, {deadline_at: Date.now() + 40});
let late;
const f = fake_manager(t, cmd, (_event, response, callback) => {late = () => callback(null, response);});
code(await f.manager.ensure(cmd), 'TIMEOUT');
late(); await delay(10);
assert.equal(f.sockets[0].connected, false);
assert.equal(f.sockets[0].packets.some(([name]) => name === 'forward_node_ready'), false);
assert.equal(f.sockets[0].listenerCount('relay_request'), 0);
});
test('manager independently enforces RPC deadlines, session binding, correlation replay and safe error ACKs', async t => {
const cmd = command(); let calls = 0;
const stream = {subscribe: () => () => {}, resync() {}, dispose() {}, snapshot: () => ({seq: ++calls}),
chat() {calls++; throw new Error('SECRET password/stack');}};
const f = fake_manager(t, cmd, undefined, () => stream);
assert.equal((await f.manager.ensure(cmd)).status, 'success');
const socket = f.sockets[0]; const rpc = node_request(cmd);
code(await socket.rpc({...rpc, deadline_at: Date.now() - 1}), 'TIMEOUT');
code(await socket.rpc({...rpc, forward_session_id: randomUUID()}), 'SESSION_STALE');
code(await socket.rpc({...rpc, method: 'bot.chat', payload: {message: 'hello', token: 'SECRET'}}), 'INVALID_REQUEST');
assert.equal(calls, 0);
assert.deepEqual(await socket.rpc(rpc), success({seq: 1}));
code(await socket.rpc(rpc), 'CANCELLED');
const failed = await socket.rpc(node_request(cmd, {method: 'bot.chat', payload: {message: 'hello'}}));
code(failed, 'INTERNAL_ERROR'); assert.equal(JSON.stringify(failed).includes('SECRET'), false);
assert.equal(calls, 2);
});
// The integration layer alone loads relay code. Issuing credentials is test-only.
test('disk history RPC discards responses after deadline or transport replacement', async t => {
const cmd = command();
let release;
const stream = {subscribe: () => () => {}, resync() {}, dispose() {},
get_log_history: () => new Promise(resolve => {release = resolve;})};
const f = fake_manager(t, cmd, undefined, () => stream);
assert.equal((await f.manager.ensure(cmd)).status, 'success');
const socket = f.sockets[0];
const pending = socket.rpc(node_request(cmd, {method: 'get_log_history', payload: {}, deadline_at: Date.now() + 15}));
await delay(25);
release({records: []});
code(await pending, 'TIMEOUT');
const stale = socket.rpc(node_request(cmd, {method: 'get_log_history', payload: {}}));
socket.disconnect();
release({records: []});
code(await stale, 'SESSION_STALE');
});
async function real_fixture(t, limits = {}) {
const [{start_forward_server}, {encode_token}] = await Promise.all([
tsImport('../forward_server/index.ts', import.meta.url),
tsImport('../forward_server/utils/token_utils.ts', import.meta.url),
]);
const key = 'node-forward-integration-test-secret-only';
const control = new EventEmitter(); const statuses = [];
control.on('forward_session_status', value => statuses.push(value));
const handle = await start_forward_server({forward_id: 1, key, host: '127.0.0.1', port: 0,
path: '/node-test/socket_io', namespace: '/node-test', allowed_origins: [], allow_no_origin: true, control,
limits: {rpc_timeout_ms: 250, unused_timeout_ms: 10_000, idle_timeout_ms: 10_000, clock_skew_ms: 0, ...limits}});
const url = `http://127.0.0.1:${handle.http_server.address().port}`;
const clients = []; const node_sockets = []; const managers = []; const sources = new Map(); const streams = new Map();
const packets = [];
handle.io.of(handle.namespace).on('connection', socket => socket.onAny((event, value) => packets.push({id: socket.id, event, value})));
t.after(async () => {for (const manager of managers) manager.stop(); for (const socket of clients) socket.disconnect(); await handle.stop();});
const control_ack = (event, value) => new Promise(resolve => control.emit(event, value, resolve));
const permissions = ['bot.snapshot', 'bot.logs', 'bot.subscribe', 'bot.unsubscribe', 'bot.chat', 'execute_function', 'bot.files'];
function token(s, role, expires_at, grant_id, granted = permissions) {
const now = Date.now();
return encode_token(key, {...s, v: 1, role, permissions: granted, issued_at: now,
join_before: Math.min(now + 1000, expires_at), expires_at, jti: randomUUID(),
...(role === 'web' ? {grant_id, user_id: 4} : {})});
}
function source(bot_id) {
if (!sources.has(bot_id)) {
let current; const listeners = new Set();
sources.set(bot_id, {get: () => current, observe(_id, listener) {listeners.add(listener); listener(current); return () => listeners.delete(listener);},
set(value) {current = value; for (const listener of listeners) listener(value);}, get count() {return listeners.size;}});
}
return sources.get(bot_id);
}
function manager(s, file_factory) {
const result = new NodeForwardManager(s.node_id, s.node_session_id, {
socket_factory: (...args) => {const socket = io(...args); node_sockets.push(socket); return socket;},
stream_factory: bot_id => {const stream = new BotStream(bot_id, {source: source(bot_id), capacity: 30}); streams.set(bot_id, stream); return stream;},
file_factory,
}); managers.push(result); return result;
}
async function open(s, expires_at = Date.now() + 20_000) {
assert.equal((await control_ack('forward_open_session', {...s, lease_expires_at: expires_at})).status, 'success');
return expires_at;
}
const cmd = (s, expires_at = Date.now() + 10_000) => command(s, {url, path: handle.path, namespace: handle.namespace,
expires_at, token: token(s, 'node', expires_at), deadline_at: Date.now() + 2000});
async function web(s, granted = permissions, grant_id = randomUUID(), expires_at = Date.now() + 10_000) {
assert.equal((await control_ack('forward_authorize_grant', {forward_session_id: s.forward_session_id,
grant_id, user_id: 4, permissions: granted, expires_at})).status, 'success');
const socket = io(`${url}${handle.namespace}`, {path: handle.path, autoConnect: false, reconnection: false, forceNew: true, transports: ['websocket']});
clients.push(socket); const connected = received(socket, 'connect'); socket.connect(); await connected;
const joined = await ack(socket, 'join_room', {token: token(s, 'web', expires_at, grant_id, granted)});
assert.equal(joined.status, 'success');
return {socket, grant_id, joined};
}
return {handle, control_ack, statuses, permissions, token, source, streams, manager, open, cmd, web, packets, node_sockets};
}
function online_context() {
const sent = [];
return Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat: message => sent.push(message)},
sent, config: {password: 'SECRET'}, logger: {get_recent_log() {throw new Error('Disk access forbidden');}}});
}
test('file tree and binary downloads use scoped IDs without requiring an online Bot', async t => {
const directory = await mkdtemp(path.join(tmpdir(), 'forward-files-'));
t.after(() => rm(directory, {recursive: true, force: true}));
await mkdir(path.join(directory, 'log'));
await mkdir(path.join(directory, 'empty'));
const bytes = Buffer.alloc(600_123);
for (let index = 0; index < bytes.length; index++) bytes[index] = index % 256;
await writeFile(path.join(directory, 'binary.dat'), bytes);
await writeFile(path.join(directory, 'log', 'sample.log'), 'a log\n');
const f = await real_fixture(t, {rpc_timeout_ms: 1500});
const s = scope(); await f.open(s);
const manager = f.manager(s, bot_id => new BotFileService(bot_id, {root: directory}));
assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s, ['bot.files']);
const denied = await f.web(s, ['bot.snapshot']);
const listing = success_data(await ack(web.socket, 'relay_request', request('get_data_file_list')));
assert.equal(listing.partial, false);
assert.deepEqual(listing.children.map(entry => entry.name), ['binary.dat', 'empty', 'log']);
assert.equal(listing.children[1].type, 'directory');
assert.deepEqual(listing.children[1].children, []);
assert.equal(JSON.stringify(listing).includes(directory), false);
const file = listing.children[0];
assert.equal(file.size, bytes.length);
assert.match(file.id, /^[0-9a-f-]{36}$/);
const chunks = [];
let offset = 0;
while (true) {
const part = success_data(await ack(web.socket, 'relay_request', request('get_file_data', {id: file.id, offset})));
assert.equal(part.offset, offset);
assert.equal(part.total_bytes, bytes.length);
assert.equal(part.encoding, 'base64');
const chunk = Buffer.from(part.data, 'base64');
assert.equal(chunk.length, part.next_offset - offset);
assert.ok(chunk.length <= 262144);
chunks.push(chunk); offset = part.next_offset;
if (part.eof) break;
}
assert.deepEqual(Buffer.concat(chunks), bytes);
code(await ack(denied.socket, 'relay_request', request('get_data_file_list')), 'FORBIDDEN');
code(await ack(denied.socket, 'relay_request', request('get_file_data', {id: file.id})), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_file_data', {id: file.id, length: 262145})), 'INVALID_REQUEST');
code(await ack(web.socket, 'relay_request', request('get_file_data', {id: '../binary.dat'})), 'INVALID_REQUEST');
const another_scope = {...s, bot_id: s.bot_id + 1, forward_session_id: randomUUID()};
await f.open(another_scope);
assert.equal((await manager.ensure(f.cmd(another_scope))).status, 'success');
const another = await f.web(another_scope, ['bot.files']);
code(await ack(another.socket, 'relay_request', request('get_file_data', {id: file.id})), 'INVALID_REQUEST');
// Reusing a request ID must re-read disk instead of replaying a cached file body.
const repeated = request('get_file_data', {id: file.id, length: 10});
assert.equal(success_data(await ack(web.socket, 'relay_request', repeated)).data, bytes.subarray(0, 10).toString('base64'));
await unlink(path.join(directory, 'binary.dat'));
const removed = await ack(web.socket, 'relay_request', repeated);
assert.equal(removed.status, 'error');
assert.ok(['RESYNC_REQUIRED', 'INVALID_REQUEST'].includes(removed.data.code));
const expired = received(web.socket, 'relay_status', value => !value.node_ready);
manager.stop(); await expired;
code(await ack(web.socket, 'relay_request', request('get_data_file_list')), 'NODE_OFFLINE');
});
test('context info RPC and updates travel through actual relay with online and permission checks', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const bot = online_context();
let records = [{name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20}];
bot.info_manager = {get_all_info: () => records};
f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s, ['bot.snapshot', 'bot.logs']);
const denied = await f.web(s, ['bot.logs']);
const snapshot_only = await f.web(s, ['bot.snapshot']);
const no_info = []; denied.socket.on('info_update', value => no_info.push(value));
const rpc = request('get_context_info');
assert.deepEqual(success_data(await ack(web.socket, 'relay_request', rpc)), records);
assert.deepEqual(success_data(await ack(snapshot_only.socket, 'relay_request', request('get_context_info'))), records);
code(await ack(denied.socket, 'relay_request', request('get_context_info')), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_context_info', {name: 'health'})), 'INVALID_REQUEST');
const before = received(web.socket, 'bot_log');
bot.emit('log', [{message: 'before info', font_color: '#ffffff', font_style: ['normal']}]);
const first_log = await before;
const pushed = received(web.socket, 'info_update');
const snapshot_pushed = received(snapshot_only.socket, 'info_update');
records[0].value = 18;
bot.emit('info_update', {name: 'health', value: 18});
const update = await pushed;
assert.deepEqual((await snapshot_pushed).data, {name: 'health', value: 18});
assert.deepEqual(Object.keys(update).sort(), ['bot_id', 'context_generation', 'data', 'emitted_at', 'forward_session_id', 'seq', 'stream_id']);
assert.deepEqual(update.data, {name: 'health', value: 18});
assert.equal(update.bot_id, s.bot_id);
assert.equal(update.forward_session_id, s.forward_session_id);
assert.equal(update.stream_id, first_log.stream_id);
assert.ok(update.seq > first_log.seq);
const after = received(web.socket, 'bot_log');
bot.emit('log', [{message: 'after info', font_color: '#ffffff', font_style: ['normal']}]);
assert.ok((await after).seq > update.seq);
// A completed request ID must re-read the Node rather than replay a stale success.
bot.bot_status = 'offline';
code(await ack(web.socket, 'relay_request', rpc), 'BOT_NOT_ONLINE');
const updates_before = f.packets.filter(packet => packet.event === 'info_update').length;
bot.emit('info_update', {name: 'health', value: 0});
await ack(web.socket, 'relay_request', request());
assert.equal(f.packets.filter(packet => packet.event === 'info_update').length, updates_before);
assert.equal(no_info.length, 0);
const stale = bot.listeners('info_update')[0];
f.source(s.bot_id).set(undefined);
code(await ack(web.socket, 'relay_request', rpc), 'NO_CONTEXT');
stale({name: 'health', value: 1});
const replacement = online_context();
replacement.info_manager = {get_all_info: () => [{...records[0], value: 15}]};
f.source(s.bot_id).set(replacement);
assert.equal(success_data(await ack(web.socket, 'relay_request', rpc))[0].value, 15);
assert.equal(bot.listenerCount('info_update'), 0);
records = [{...records[0], value: NaN}];
replacement.info_manager = {get_all_info: () => records};
code(await ack(web.socket, 'relay_request', request('get_context_info')), 'INTERNAL_ERROR');
manager.stop();
assert.equal(replacement.listenerCount('info_update'), 0);
});
test('complex inventory from the real module survives Node query and push through the relay', async t => {
const {init: InventoryInfo} = await tsImport('../bot/info/bot/inventory.ts', import.meta.url);
const mf = createRequire(createRequire(import.meta.url).resolve('@wp2508/mineflayer'));
const registry = mf('prismarine-registry')('1.21.4');
const windows = mf('prismarine-windows')(registry);
const instance = new EventEmitter();
instance._client = new EventEmitter();
instance.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory');
instance.currentWindow = windows.createWindow(1, 'minecraft:generic_9x6', 'Chest');
instance.quickBarSlot = 0;
let nbt = {type: 'string', value: 'deep content'};
for (let i = 0; i < 15; i++) nbt = {type: 'compound', value: {nested: nbt}};
instance.currentWindow.slots = Array.from({length: 90}, () => ({
type: 1, name: 'stone', displayName: 'Stone', count: 64, metadata: 0, nbt,
components: [
{type: 'custom_data', data: {description: 'x'.repeat(4096)}},
{type: 'custom_name', data: {type: 'compound', value: {text: {type: 'string', value: '菜单'}, color: {type: 'string', value: 'gold'}}}},
{type: 'lore', data: [{type: 'string', value: '说明'}]},
], enchants: [],
}));
const inventory = new InventoryInfo(instance);
t.after(() => inventory.dispose());
const f = await real_fixture(t); const s = scope(); await f.open(s);
const bot = online_context();
bot.info_manager = {get_all_info: () => [{name: inventory.name, i18n: inventory.i18n, value: inventory.get_info()}]};
const unsubscribe = inventory.on_update(value => bot.emit('info_update', {name: 'inventory', value}));
t.after(unsubscribe);
f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s, ['bot.snapshot']);
const records = success_data(await ack(web.socket, 'relay_request', request('get_context_info')));
assert.ok(Buffer.byteLength(JSON.stringify(records)) > 196_608);
assert.deepEqual(records[0], bot.info_manager.get_all_info()[0]);
const item = records[0].value.window.slots[0];
assert.equal(item.displayName[0].message, '菜单');
assert.equal(item.displayName[0].font_color, '#FFAA00');
assert.equal(item.lore[0][0].message, '说明');
assert.equal('components' in item, false);
assert.equal('nbt' in item, false);
const pushed = received(web.socket, 'info_update');
instance.currentWindow.slots[0].count = 32;
instance.currentWindow.emit('updateSlot');
const update = await pushed;
assert.equal(update.data.name, 'inventory');
assert.equal(update.data.value.window.slots[0].count, 32);
assert.equal(update.data.value.window.slots.length, 90);
assert.deepEqual(update.data.value, inventory.get_info());
});
test('execute_function traverses the relay with boolean results, permission checks and no replay', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s);
await manager.ensure(f.cmd(s));
const web = await f.web(s);
const reader = await f.web(s, ['bot.snapshot']);
const bot = online_context();
const calls = [];
bot.function_manager = {async run(path, name, args, before_run) {
before_run();
calls.push({path, name, args});
if (name === 'missing') throw new Error('Unknown function');
return name === 'left_click_slot';
}};
f.source(s.bot_id).set(bot);
const payload = {function: 'inventory', name: 'left_click_slot', args: [9]};
const rpc = request('execute_function', payload);
assert.equal(success_data(await ack(web.socket, 'relay_request', rpc)), true);
code(await ack(web.socket, 'relay_request', rpc), 'CANCELLED');
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], {path: 'inventory', name: 'left_click_slot', args: [9]});
code(await ack(reader.socket, 'relay_request', request('execute_function', payload)), 'FORBIDDEN');
assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', {...payload, name: 'missing'}))), false);
assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', {...payload, name: 'right_click_slot'}))), false);
bot.bot_status = 'offline';
assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', payload))), false);
});
test('actual manager serves snapshots, logs and chat without forwarding bot events', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); const cmd = f.cmd(s);
assert.deepEqual(await manager.ensure(cmd), success({...s, node_ready: true, lease_expires_at: cmd.expires_at}));
const node_id = f.node_sockets[0].id;
assert.deepEqual(f.packets.filter(p => p.id === node_id && ['join_room', 'forward_node_ready'].includes(p.event)).map(p => p.event),
['join_room', 'forward_node_ready']);
assert.ok(f.statuses.some(value => value.state === 'ready' && value.forward_session_id === s.forward_session_id));
const web = await f.web(s); const other = await f.web(s, ['bot.snapshot']);
const absent = success_data(await ack(web.socket, 'relay_request', request()));
assert.deepEqual(Object.keys(absent).sort(), ['bot_id', 'bot_status', 'captured_at', 'context_exists', 'context_generation', 'seq', 'stream_id']);
assert.equal(absent.context_exists, false); assert.equal(absent.context_generation, null); assert.equal(absent.bot_status, 'offline');
code(await ack(web.socket, 'relay_request', request('bot.chat', {message: 'hello'})), 'NO_CONTEXT');
let leaks = 0; other.socket.on('relay_request', () => leaks++); other.socket.on('bot_event', () => leaks++);
assert.equal((await ack(web.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log', 'message', 'bot_error']}))).status, 'success');
const events = []; web.socket.on('bot_event', value => events.push(value));
const bot = online_context();
f.source(s.bot_id).set(bot);
const snapshot = success_data(await ack(web.socket, 'relay_request', request()));
assert.equal(snapshot.context_exists, true); assert.equal(snapshot.bot_status, 'online');
assert.equal(snapshot.stream_id, absent.stream_id); assert.ok(snapshot.context_generation);
for (const state of ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']) {
bot.bot_status = state; bot.emit('status_update', state);
const current = success_data(await ack(web.socket, 'relay_request', request()));
assert.equal(current.context_exists, true);
assert.equal(current.bot_status, state); assert.equal(current.context_generation, snapshot.context_generation);
}
bot.bot_status = 'online'; bot.emit('status_update', 'online');
const log_packet = received(web.socket, 'bot_log');
const metadata = {hoverEvent: {action: 'show_text', value: '悬停说明'},
clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'Alex'};
bot.emit('log', [{message: 'safe log', font_color: '#abcdef', font_style: ['normal'], ...metadata, password: 'SECRET'}]);
const live_log = await log_packet;
assert.equal(live_log.forward_session_id, s.forward_session_id);
assert.equal(live_log.payload.records[0].segments[0].message, 'safe log');
for (const [key, value] of Object.entries(metadata)) assert.deepEqual(live_log.payload.records[0].segments[0][key], value);
assert.equal(JSON.stringify(live_log).includes('SECRET'), false);
const logs = success_data(await ack(web.socket, 'relay_request', request('bot.logs', {limit: 3})));
assert.equal(logs.records[0].segments[0].message, 'safe log');
assert.deepEqual(logs.records[0].segments[0], live_log.payload.records[0].segments[0]);
for (let i = 0; i < 25; i++) bot.emit('log', [{message: `history ${i}`, font_color: '#ffffff', font_style: ['normal']}]);
bot.logger.read_recent_log = async count => Array.from({length: 25}, (_, i) => ({
id: randomUUID(), timestamp: Date.now(), log: [{message: `history ${i}`, font_color: '#ffffff', font_style: ['normal']}],
})).slice(-count);
const history = success_data(await ack(web.socket, 'relay_request', request('get_log_history', {})));
assert.equal(history.records.length, 20);
assert.deepEqual(history.records.map(record => record.segments[0].message),
Array.from({length: 20}, (_, i) => `history ${i + 5}`));
code(await ack(other.socket, 'relay_request', request('get_log_history', {})), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_log_history', {limit: 21})), 'INVALID_REQUEST');
assert.equal((await ack(web.socket, 'relay_request', request('bot.chat', {message: 'hello'}))).data.accepted, true);
assert.deepEqual(bot.sent, ['hello']);
bot.bot_instance.tabComplete = async text => {
assert.equal(text, '/he');
return ['help', {match: 'help'}, {match: 'hello'}];
};
assert.deepEqual(success_data(await ack(web.socket, 'relay_request', request('tab_complete', {text: '/he'}))),
{matches: ['help', 'hello'], start: 1, length: 2});
bot.bot_instance.tabComplete = async () => ['Alex', 'Alice', 'Alex'];
const input = '/tell Al';
const completion = success_data(await ack(web.socket, 'relay_request', request('tab_complete', {text: input})));
assert.deepEqual(completion, {matches: ['Alex', 'Alice'], start: 6, length: 2});
assert.equal(input.slice(0, completion.start) + completion.matches[0]
+ input.slice(completion.start + completion.length), '/tell Alex');
code(await ack(other.socket, 'relay_request', request('tab_complete', {text: '/he'})), 'FORBIDDEN');
bot.bot_status = 'offline'; code(await ack(web.socket, 'relay_request', request('bot.chat', {message: 'blocked'})), 'BOT_NOT_ONLINE');
assert.deepEqual(bot.sent, ['hello']);
bot.emit('message', {json: {text: 'public text', password: 'SECRET'}}, 'chat');
bot.emit('error', new Error('SECRET stack'));
assert.equal(JSON.stringify({snapshot, logs}).includes('SECRET'), false);
await ack(other.socket, 'relay_request', request()); assert.equal(leaks, 0);
assert.deepEqual(events, []);
assert.equal(f.packets.some(packet => packet.event === 'bot_event'), false);
manager.stop(); assert.equal(f.source(s.bot_id).count, 0); assert.equal(bot.listenerCount('log'), 0);
});
test('actual manager reauth renews same transport; disconnect requires fresh token and preserves stream identity', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); const first = f.cmd(s, Date.now() + 3000);
assert.equal((await manager.ensure(first)).status, 'success');
const web = await f.web(s); const before = success_data(await ack(web.socket, 'relay_request', request()));
const renewed = f.cmd(s, Date.now() + 8000);
assert.equal((await manager.ensure(renewed)).data.lease_expires_at, renewed.expires_at);
assert.equal(f.node_sockets.length, 1); assert.ok(f.packets.some(p => p.event === 'reauth' && p.value.token === renewed.token));
const offline = received(web.socket, 'relay_status', value => !value.node_ready);
f.node_sockets[0].disconnect(); await offline;
code(await ack(web.socket, 'relay_request', request()), 'NODE_OFFLINE');
code(await manager.ensure({...renewed, deadline_at: Date.now() + 2000}), 'TOKEN_EXPIRED');
assert.equal(f.node_sockets.length, 1);
const fresh = f.cmd(s, Date.now() + 9000);
assert.equal((await manager.ensure(fresh)).status, 'success'); assert.equal(f.node_sockets.length, 2);
const after = success_data(await ack(web.socket, 'relay_request', request()));
assert.equal(after.stream_id, before.stream_id); assert.ok(after.seq > before.seq);
});
test('actual multi-grant request_id reuse is private, read-deduped and chat executes once per grant', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const bot = online_context(); f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const a = await f.web(s); const b = await f.web(s);
const id = randomUUID(); const reads = await Promise.all([a, b].map(web => ack(web.socket, 'relay_request', request('bot.snapshot', {}, id))));
assert.ok(reads.every(value => value.status === 'success'));
assert.deepEqual(await ack(a.socket, 'relay_request', request('bot.snapshot', {}, id)), reads[0]);
code(await ack(a.socket, 'relay_request', request('bot.logs', {limit: 1}, id)), 'INVALID_REQUEST');
const chat_id = randomUUID();
const chats = await Promise.all([a, b].map(web => ack(web.socket, 'relay_request', request('bot.chat', {message: web.grant_id}, chat_id))));
assert.ok(chats.every(value => value.status === 'success')); assert.deepEqual(new Set(bot.sent), new Set([a.grant_id, b.grant_id]));
code(await ack(a.socket, 'relay_request', request('bot.chat', {message: a.grant_id}, chat_id)), 'CANCELLED');
assert.equal(bot.sent.length, 2);
const closed = received(a.socket, 'forward_session_closed');
assert.equal((await f.control_ack('forward_revoke_grant', {forward_session_id: s.forward_session_id, grant_id: a.grant_id, reason: 'permission_removed'})).status, 'success');
await closed; assert.equal((await ack(b.socket, 'relay_request', request())).status, 'success');
code(await f.control_ack('forward_authorize_grant', {forward_session_id: s.forward_session_id, grant_id: a.grant_id,
user_id: 4, permissions: f.permissions, expires_at: Date.now() + 5000}), 'FORBIDDEN');
});
test('one Node manager isolates multiple Bot sessions and observes stop/start without closing Web transport', async t => {
const f = await real_fixture(t);
const a = scope();
const b = {...a, bot_id: a.bot_id + 1, forward_session_id: randomUUID()};
await f.open(a); await f.open(b);
const manager = f.manager(a);
const bot_a = online_context(); const bot_b = online_context();
f.source(a.bot_id).set(bot_a); f.source(b.bot_id).set(bot_b);
assert.equal((await manager.ensure(f.cmd(a))).status, 'success');
assert.equal((await manager.ensure(f.cmd(b))).status, 'success');
const web_a = await f.web(a); const web_b = await f.web(b);
const before = success_data(await ack(web_a.socket, 'relay_request', request()));
const other = success_data(await ack(web_b.socket, 'relay_request', request()));
assert.notEqual(before.stream_id, other.stream_id);
assert.equal(before.bot_id, a.bot_id); assert.equal(other.bot_id, b.bot_id);
await ack(web_a.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']}));
await ack(web_b.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']}));
const events_b = []; web_b.socket.on('bot_event', event => events_b.push(event));
const events_a = []; web_a.socket.on('bot_event', event => events_a.push(event));
bot_a.emit('log', [{message: 'Bot A only', font_color: '#ffffff', font_style: ['normal']}]);
assert.equal(success_data(await ack(web_a.socket, 'relay_request', request('bot.logs', {limit: 10}))).records[0].segments[0].message, 'Bot A only');
assert.equal(success_data(await ack(web_b.socket, 'relay_request', request('bot.logs', {limit: 10}))).records.length, 0);
assert.equal(events_b.length, 0);
await ack(web_a.socket, 'relay_request', request('bot.chat', {message: 'a'}));
await ack(web_b.socket, 'relay_request', request('bot.chat', {message: 'b'}));
assert.deepEqual(bot_a.sent, ['a']); assert.deepEqual(bot_b.sent, ['b']);
f.source(a.bot_id).set(undefined);
const absent = success_data(await ack(web_a.socket, 'relay_request', request()));
assert.equal(absent.bot_status, 'offline'); assert.equal(absent.context_generation, null);
const replacement = online_context();
f.source(a.bot_id).set(replacement);
const after = success_data(await ack(web_a.socket, 'relay_request', request()));
assert.equal(after.stream_id, before.stream_id); assert.notEqual(after.context_generation, before.context_generation);
assert.equal(web_a.socket.connected, true); assert.equal(f.node_sockets.length, 2);
assert.equal(events_b.length, 0);
assert.equal(events_a.length, 0);
assert.equal(bot_a.listenerCount('log'), 0);
});
test('actual session revocation retires manager, closes Web and cannot revive stale scope after a new session opens', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s); const closed = received(web.socket, 'forward_session_closed');
const node_closed = received(f.node_sockets[0], 'forward_session_closed');
assert.equal((await f.control_ack('forward_revoke_session', {forward_session_id: s.forward_session_id, reason: 'ownership_changed'})).status, 'success');
assert.equal((await closed).code, 'SESSION_STALE'); await node_closed;
code(await manager.ensure(f.cmd(s)), 'SESSION_STALE');
code(await f.control_ack('forward_open_session', {...s, lease_expires_at: Date.now() + 5000}), 'SESSION_STALE');
const next = {...s, forward_session_id: randomUUID(), node_session_id: 'new-main-socket-session'}; await f.open(next);
code(await manager.ensure(f.cmd(next)), 'SESSION_STALE');
const replacement = f.manager(next); assert.equal((await replacement.ensure(f.cmd(next))).status, 'success');
const new_web = await f.web(next); assert.equal((await ack(new_web.socket, 'relay_request', request())).status, 'success');
code(await manager.ensure(f.cmd(s)), 'SESSION_STALE');
});
test('actual relay rejects wrong-scope token and manager lease expiry prevents session revival', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); const wrong = f.cmd(s);
wrong.token = f.token({...s, bot_id: s.bot_id + 1}, 'node', wrong.expires_at);
code(await manager.ensure(wrong), 'SESSION_STALE');
const short = f.cmd(s, Date.now() + 200);
assert.equal((await manager.ensure(short)).status, 'success');
const web = await f.web(s);
await delay(Math.max(0, short.expires_at - Date.now()) + 25);
code(await manager.ensure(f.cmd(s)), 'SESSION_STALE');
code(await ack(web.socket, 'relay_request', request()), 'NODE_OFFLINE');
assert.equal(f.source(s.bot_id).count, 0);
});
test('actual relay deadline times out held chat; late manager delivery cannot send chat or replace the ACK', async t => {
const f = await real_fixture(t, {rpc_timeout_ms: 60}); const s = scope(); await f.open(s);
const bot = online_context(); f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s); const node = f.node_sockets[0];
const [handler] = node.listeners('relay_request');
let late; let arrived;
const held = new Promise(resolve => {arrived = resolve;});
node.off('relay_request', handler);
node.on('relay_request', (raw, callback) => {
late = () => new Promise(resolve => handler(raw, response => {callback(response); resolve(response);}));
arrived(raw);
});
const chat = request('bot.chat', {message: 'must never send'});
const response = ack(web.socket, 'relay_request', chat);
const delivered = await held;
assert.equal(delivered.forward_session_id, s.forward_session_id);
assert.notEqual(delivered.correlation_id, chat.request_id);
code(await response, 'TIMEOUT');
code(await late(), 'TIMEOUT'); assert.deepEqual(bot.sent, []);
code(await ack(web.socket, 'relay_request', chat), 'CANCELLED');
assert.deepEqual(bot.sent, []);
node.removeAllListeners('relay_request'); node.on('relay_request', handler);
assert.equal((await ack(web.socket, 'relay_request', request())).status, 'success');
});
test('public relay restart closes old scope; a fresh session and replacement manager restore Web RPC', async t => {
const first = await real_fixture(t); const s = scope(); await first.open(s);
const manager = first.manager(s); assert.equal((await manager.ensure(first.cmd(s))).status, 'success');
const web = await first.web(s);
const node_closed = received(first.node_sockets[0], 'forward_session_closed');
const web_closed = received(web.socket, 'forward_session_closed');
await first.handle.stop();
assert.equal((await node_closed).reason, 'relay_closed');
assert.equal((await web_closed).code, 'RELAY_UNAVAILABLE');
const restarted = await real_fixture(t);
code(await manager.ensure(restarted.cmd(s)), 'SESSION_STALE');
const next = {...s, forward_session_id: randomUUID()}; await restarted.open(next);
assert.equal((await manager.ensure(restarted.cmd(next))).status, 'success');
const current = await restarted.web(next);
const before = success_data(await ack(current.socket, 'relay_request', request()));
const offline = received(current.socket, 'relay_status', value => !value.node_ready);
manager.stop(); await offline;
const replacement = restarted.manager(next);
assert.equal((await replacement.ensure(restarted.cmd(next))).status, 'success');
const after = success_data(await ack(current.socket, 'relay_request', request()));
assert.notEqual(after.stream_id, before.stream_id);
assert.equal(after.context_exists, false);
});
+412
View File
@@ -0,0 +1,412 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import {randomUUID} from 'node:crypto';
import {fileURLToPath} from 'node:url';
import {readFile} from 'node:fs/promises';
import vm from 'node:vm';
import test from 'node:test';
import {build, transformSync} from 'esbuild';
const require = createRequire(import.meta.url);
const root = fileURLToPath(new URL('../', import.meta.url));
const built = await build({
stdin: {contents: `export * from './bot/forward/manager.ts';
export {handler} from './event_handler/bot/forward_bot.ts';`, resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs',
packages: 'external', write: false,
plugins: [{name: 'stream-stub', setup(build) {
build.onResolve({filter: /^\.\/stream\.ts$/}, () => ({path: 'stream', namespace: 'mock'}));
build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({contents: 'export class BotStream {}', loader: 'js'}));
}}],
});
const module = {exports: {}};
vm.runInNewContext(built.outputFiles[0].text, {module, exports: module.exports, require,
Buffer, URL, setTimeout, clearTimeout, console});
const {NodeForwardManager} = module.exports;
function command(overrides = {}) {
return {forward_id: 0, forward_session_id: randomUUID(), node_session_id: 'node-session', node_id: 1, bot_id: 2,
url: 'http://127.0.0.1:1234', path: '/api/forward_server', namespace: '/', token: randomUUID(),
expires_at: Date.now() + 30_000, deadline_at: Date.now() + 500, ...overrides};
}
function setup() {
const sockets = [];
const streams = [];
const files = [];
const hooks = new Map();
const initial = command();
let active = initial;
const manager = new NodeForwardManager(1, 'node-session', {
socket_factory(url, options) {
const socket = new EventEmitter();
socket.sent = [];
socket.url = url;
socket.options = options;
socket.auth = options.auth;
socket.connected = false;
socket.volatile = {emit: (...args) => socket.sent.push(args)};
socket.io = {engine: {writeBuffer: []}};
socket.connect = () => {
socket.connected = true;
queueMicrotask(() => socket.emit('connect'));
};
socket.disconnect = () => {
socket.connected = false;
socket.emit('disconnect');
};
socket.timeout = () => ({emit(event, payload, ack) {
socket.sent.push([event, payload]);
const respond = data => ack(null, {status: 'success', data});
if (hooks.has(event)) return hooks.get(event)({socket, payload, respond, ack});
if (event === 'forward_node_ready') return queueMicrotask(() => respond({node_ready: true}));
const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = active;
queueMicrotask(() => respond({forward_id, forward_session_id, node_session_id, node_id, bot_id,
role: 'node', node_ready: event === 'reauth', expires_at}));
}});
sockets.push(socket);
return socket;
},
stream_factory(bot_id) {
const listeners = new Set();
const stream = {bot_id, disposed: false, chats: 0,
snapshot: () => ({bot_id}), logs: () => ({records: []}),
get_context_info: () => [{name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20}],
chat: () => { stream.chats++; return {accepted: true}; },
subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); },
resync() {}, dispose() { stream.disposed = true; },
event: event => { for (const listener of listeners) listener(event); },
get listeners() { return listeners.size; },
};
streams.push(stream);
return stream;
},
file_factory(bot_id) {
const service = {bot_id, disposed: false,
list: async () => ({listing_id: randomUUID(), expires_at: Date.now() + 300_000, partial: false, children: []}),
read: async ({id, offset = 0}) => ({id, name: 'empty', encoding: 'base64', offset,
next_offset: offset, total_bytes: offset, eof: true, data: ''}),
dispose() { service.disposed = true; },
};
files.push(service);
return service;
},
});
return {manager, sockets, streams, files, hooks, command: active,
ensure(value = initial) { active = value; return manager.ensure(value); }};
}
const tick = () => new Promise(resolve => setImmediate(resolve));
const code = response => response.data.code;
test('file RPCs work without a bot, clamp ID expiry and invalidate services on transport replacement', async t => {
const app = setup(); t.after(() => app.manager.stop());
await app.ensure();
const rpc = (socket, method, payload = {}, deadline_at = Date.now() + 1000) => new Promise(resolve =>
socket.emit('relay_request', {request_id: randomUUID(), correlation_id: randomUUID(), method, payload,
forward_session_id: app.command.forward_session_id, deadline_at}, resolve));
const first = app.sockets[0];
const listed = await rpc(first, 'get_data_file_list');
assert.equal(listed.status, 'success');
assert.equal(listed.data.expires_at, app.command.expires_at);
assert.equal(app.files.length, 1);
assert.equal(app.files[0].bot_id, app.command.bot_id);
const id = randomUUID();
assert.equal((await rpc(first, 'get_file_data', {id})).data.id, id);
assert.equal(code(await rpc(first, 'get_file_data', {id, length: 262145})), 'INVALID_REQUEST');
assert.equal(code(await rpc(first, 'get_file_data', {id, offset: -1})), 'INVALID_REQUEST');
let release;
app.files[0].read = () => new Promise(resolve => {release = resolve;});
const pending = rpc(first, 'get_file_data', {id});
first.disconnect();
assert.equal(app.files[0].disposed, true);
release({id});
assert.equal(code(await pending), 'SESSION_STALE');
assert.equal((await app.ensure({...app.command, token: randomUUID()})).status, 'success');
assert.equal((await rpc(app.sockets[1], 'get_data_file_list')).status, 'success');
assert.equal(app.files.length, 2);
let late;
app.files[1].list = () => new Promise(resolve => {late = resolve;});
const timed = rpc(app.sockets[1], 'get_data_file_list', {}, Date.now() + 10);
await new Promise(resolve => setTimeout(resolve, 15));
late({listing_id: randomUUID(), expires_at: Date.now() + 1000, partial: false, children: []});
assert.equal(code(await timed), 'TIMEOUT');
app.manager.stop();
assert.equal(app.files[1].disposed, true);
});
test('ready ACK waits for ready confirmation, with one RPC handler and log observer across renewals', async t => {
const app = setup();
t.after(() => app.manager.stop());
let release;
app.hooks.set('forward_node_ready', ({respond}) => { release = respond; });
let completed = false;
const result = app.ensure().then(value => { completed = true; return value; });
await tick();
assert.equal(completed, false);
assert.equal(app.sockets[0].listenerCount('relay_request'), 1);
assert.equal(app.streams[0].listeners, 1);
release({node_ready: true});
assert.equal((await result).status, 'success');
app.hooks.delete('forward_node_ready');
const next = {...app.command, token: randomUUID()};
assert.equal((await app.ensure(next)).status, 'success');
assert.equal(app.sockets.length, 1);
assert.equal(app.streams[0].listeners, 1);
assert.equal(app.sockets[0].sent.filter(([event]) => event === 'reauth').length, 1);
});
test('absolute deadline cancels stalled readiness, closes channel and ignores late ready ACK', async t => {
const app = setup();
t.after(() => app.manager.stop());
let release;
app.hooks.set('forward_node_ready', ({respond}) => { release = respond; });
const request = {...app.command, deadline_at: Date.now() + 35};
const response = await app.ensure(request);
assert.equal(code(response), 'TIMEOUT');
assert.equal(app.sockets[0].connected, false);
assert.equal(app.sockets[0].listenerCount('relay_request'), 0);
assert.equal(app.streams[0].disposed, true);
release({node_ready: true});
await tick();
assert.equal(app.sockets[0].connected, false);
});
test('session scope and whitelist validation reject stale node, wrong bot scope, and extra fields', async t => {
const app = setup();
t.after(() => app.manager.stop());
assert.equal(code(await app.ensure({...app.command, node_session_id: 'old'})), 'SESSION_STALE');
assert.equal(code(await app.ensure({...app.command, node_id: 3})), 'FORBIDDEN');
assert.equal(code(await app.ensure({...app.command, extra: true})), 'INVALID_REQUEST');
assert.equal((await app.ensure()).status, 'success');
assert.equal(code(await app.ensure({...app.command, bot_id: 3})), 'SESSION_STALE');
assert.equal(app.sockets.length, 1);
});
test('serial ensure ignores expired queued work without closing a healthy current operation', async t => {
const app = setup();
t.after(() => app.manager.stop());
let release;
app.hooks.set('forward_node_ready', ({respond}) => { release = respond; });
const first = app.ensure();
await tick();
const second = app.ensure({...app.command, token: randomUUID(), deadline_at: Date.now() + 20});
assert.equal(code(await second), 'TIMEOUT');
assert.equal(app.sockets[0].connected, true);
release({node_ready: true});
assert.equal((await first).status, 'success');
await tick();
assert.equal(app.sockets.length, 1);
assert.equal(app.sockets[0].connected, true);
});
test('closed session cannot revive, old callbacks cannot execute chat, and manager stop removes observers', async () => {
const app = setup();
assert.equal((await app.ensure()).status, 'success');
const socket = app.sockets[0];
const stale_rpc = socket.listeners('relay_request')[0];
socket.emit('forward_session_closed', {forward_session_id: app.command.forward_session_id,
reason: 'revoked', code: 'CANCELLED'});
assert.equal(app.streams[0].disposed, true);
assert.equal(socket.connected, false);
assert.equal(code(await app.ensure({...app.command, token: randomUUID()})), 'SESSION_STALE');
let response;
stale_rpc({request_id: randomUUID(), correlation_id: randomUUID(), method: 'bot.chat', payload: {message: 'hello'},
forward_session_id: app.command.forward_session_id, deadline_at: Date.now() + 1000}, value => { response = value; });
assert.equal(code(response), 'SESSION_STALE');
assert.equal(app.streams[0].chats, 0);
app.manager.stop();
assert.equal(code(await app.ensure(command())), 'SESSION_STALE');
});
test('RPC checks deadlines and deduplicates correlation IDs, while allowing grant request ID reuse', async t => {
const app = setup();
t.after(() => app.manager.stop());
await app.ensure();
const base = {request_id: randomUUID(), correlation_id: randomUUID(), method: 'bot.chat', payload: {message: 'hello'},
forward_session_id: app.command.forward_session_id, deadline_at: Date.now() + 1000};
const rpc = request => new Promise(resolve => app.sockets[0].emit('relay_request', request, resolve));
assert.equal((await rpc(base)).status, 'success');
assert.equal(code(await rpc(base)), 'CANCELLED');
assert.equal((await rpc({...base, correlation_id: randomUUID()})).status, 'success');
assert.equal(app.streams[0].chats, 2);
assert.equal(code(await rpc({...base, correlation_id: randomUUID(), deadline_at: Date.now() - 1})), 'TIMEOUT');
assert.equal(app.streams[0].chats, 2);
assert.equal(code(await rpc({...base, method: 'bot.subscribe', payload: {events: []}})), 'INVALID_REQUEST');
const info = await rpc({...base, correlation_id: randomUUID(), method: 'get_context_info', payload: {}});
assert.equal(info.status, 'success');
assert.equal(info.data[0].value, 20);
});
test('transport recovery requires a fresh token; ordinary disconnect never reconnects automatically', async t => {
const app = setup();
t.after(() => app.manager.stop());
await app.ensure();
app.sockets[0].disconnect();
assert.equal(app.sockets[0].options.reconnection, false);
assert.equal(code(await app.ensure(app.command)), 'TOKEN_EXPIRED');
assert.equal(app.sockets.length, 1);
assert.equal((await app.ensure({...app.command, token: randomUUID()})).status, 'success');
assert.equal(app.sockets.length, 2);
assert.equal(app.streams.length, 1);
assert.equal(app.streams[0].listeners, 1);
});
test('wrong-scope join response closes the channel and sanitized ACK never exposes raw relay details', async t => {
const app = setup();
t.after(() => app.manager.stop());
app.hooks.set('join_room', ({respond}) => respond({node_id: 999, token: 'secret', stack: 'private'}));
const response = await app.ensure();
assert.equal(code(response), 'SESSION_STALE');
assert.equal(app.sockets[0].connected, false);
assert.doesNotMatch(JSON.stringify(response), /secret|private|stack/);
});
test('many historical closed sessions do not consume the active-session budget', async t => {
const app = setup();
t.after(() => app.manager.stop());
for (let index = 0; index < 505; index++) {
const request = command({deadline_at: Date.now() + 2000});
assert.equal((await app.ensure(request)).status, 'success');
app.sockets.at(-1).emit('forward_session_closed', {forward_session_id: request.forward_session_id,
reason: 'revoked', code: 'CANCELLED'});
}
assert.equal(app.streams.filter(stream => !stream.disposed).length, 0);
assert.equal((await app.ensure(command())).status, 'success');
});
test('lease expiry retires data channel and queue capacity stays bounded under stalled work', async t => {
const app = setup();
t.after(() => app.manager.stop());
const request = {...app.command, expires_at: Date.now() + 40};
assert.equal((await app.ensure(request)).status, 'success');
await new Promise(resolve => setTimeout(resolve, 50));
assert.equal(app.sockets[0].connected, false);
assert.equal(app.streams[0].disposed, true);
assert.equal(code(await app.ensure({...request, expires_at: Date.now() + 1000, token: randomUUID()})), 'SESSION_STALE');
const stalled = setup();
t.after(() => stalled.manager.stop());
stalled.hooks.set('forward_node_ready', () => {});
const first = stalled.ensure();
await tick();
const queued = Array.from({length: 15}, () => stalled.ensure({...stalled.command, token: randomUUID()}));
assert.equal(code(await stalled.ensure({...stalled.command, token: randomUUID()})), 'RATE_LIMITED');
stalled.manager.stop();
const replies = await Promise.all([first, ...queued]);
assert.ok(replies.every(reply => reply.status === 'error'));
assert.equal(stalled.sockets.length, 1);
});
test('bot events are never forwarded while connected, congested or disconnected', async t => {
const app = setup();
t.after(() => app.manager.stop());
await app.ensure();
const socket = app.sockets[0];
const stream = app.streams[0];
let resyncs = 0;
stream.resync = () => { resyncs++; };
const event = {name: 'status_update', seq: 1, payload: {status: 'offline', context_exists: false}};
stream.event(event);
assert.equal(socket.sent.filter(([name]) => name === 'bot_event').length, 0);
assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 0);
stream.event({name: 'log', seq: 2, payload: {records: []}});
assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 1);
socket.io.engine.writeBuffer.length = 64;
stream.event({...event, seq: 2});
stream.event({name: 'log', seq: 3, payload: {records: []}});
assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 1);
assert.equal(socket.sent.filter(([name]) => name === 'bot_event').length, 0);
socket.emit('relay_status', {forward_session_id: app.command.forward_session_id, node_ready: true, reason: 'resync_required'});
assert.equal(resyncs, 1);
socket.disconnect();
stream.event({...event, seq: 3});
stream.event({name: 'log', seq: 4, payload: {records: []}});
assert.equal(socket.sent.filter(([name]) => name === 'bot_log').length, 1);
assert.equal(socket.sent.filter(([name]) => name === 'bot_event').length, 0);
assert.equal(stream.listeners, 0);
});
test('info updates use the data envelope and share push congestion and rate controls with logs', async t => {
const app = setup(); t.after(() => app.manager.stop());
let ready;
app.hooks.set('forward_node_ready', ({respond}) => {ready = respond;});
const pending = app.ensure(); await tick();
const stream = app.streams[0]; const socket = app.sockets[0];
const event = {bot_id: 2, context_generation: randomUUID(), stream_id: randomUUID(), emitted_at: Date.now(),
seq: 1, name: 'info_update', payload: {name: 'health', value: 18}};
stream.event(event);
assert.equal(socket.sent.some(([name]) => name === 'info_update'), false);
ready({node_ready: true}); await pending;
stream.event(event);
const packet = socket.sent.find(([name]) => name === 'info_update')[1];
assert.deepEqual(Object.keys(packet).sort(), ['bot_id', 'context_generation', 'data', 'emitted_at', 'forward_session_id', 'seq', 'stream_id']);
assert.equal(packet.data.value, 18);
assert.equal(packet.forward_session_id, app.command.forward_session_id);
socket.io.engine.writeBuffer.length = 64; stream.event({...event, seq: 2});
assert.equal(socket.sent.filter(([name]) => name === 'info_update').length, 1);
socket.io.engine.writeBuffer.length = 0;
for (let seq = 3; seq < 203; seq++) stream.event({...event, seq, name: seq % 2 ? 'log' : 'info_update'});
assert.equal(socket.sent.filter(([name]) => ['info_update', 'bot_log'].includes(name)).length, 149);
socket.disconnect(); stream.event({...event, seq: 204});
assert.equal(stream.listeners, 0);
assert.equal(socket.sent.filter(([name]) => ['info_update', 'bot_log'].includes(name)).length, 149);
});
test('plugin lifecycle updates survive a temporarily unwritable transport and retain congestion bounds', async t => {
const app = setup(); t.after(() => app.manager.stop());
await app.ensure();
const socket = app.sockets[0], stream = app.streams[0];
const received = [];
socket.on('info_update', update => received.push(update));
// Match Socket.IO volatile behavior during an outstanding WebSocket write.
socket.volatile = {emit() {}};
const event = {bot_id: 2, context_generation: randomUUID(), stream_id: randomUUID(), emitted_at: Date.now(),
name: 'info_update', payload: {name: 'plugin_states', value: {available: true, plugins: []}}};
for (const [index, lifecycle] of ['initializing', 'enabled'].entries()) {
stream.event({...event, seq: index + 1, payload: {...event.payload, value: {available: true,
plugins: [{name: 'auto_reconnect', path: 'runtime', lifecycle, enabled: lifecycle === 'enabled', states: {attempts: 0}}]}}});
}
assert.deepEqual(received.map(update => update.data.value.plugins[0].lifecycle), ['initializing', 'enabled']);
assert.deepEqual(received.map(update => update.seq), [1, 2]);
assert.ok(received.every(update => update.forward_session_id === app.command.forward_session_id));
assert.ok(received.every(update => !('name' in update) && !('payload' in update)));
stream.event({...event, seq: 3, payload: {name: 'health', value: 20}});
assert.equal(received.length, 2);
socket.io.engine.writeBuffer.length = 64;
stream.event({...event, seq: 4});
assert.equal(received.length, 2);
socket.io.engine.writeBuffer.length = 0;
for (let seq = 5; seq <= 155; seq++) stream.event({...event, seq});
assert.equal(received.length, 148);
socket.disconnect(); stream.event({...event, seq: 156});
assert.equal(received.length, 148);
assert.equal(stream.listeners, 0);
});
test('forward_bot handler requires an ACK and returns structured authorization errors', async () => {
const {handler} = module.exports;
const socket = {connected: true, emit() { assert.fail('Unexpected non-ACK response'); }};
await handler(socket, command(), undefined);
const replies = [];
await handler(socket, command(), reply => replies.push(reply));
assert.equal(replies.length, 1);
assert.equal(code(replies[0]), 'UNAUTHORIZED');
socket.connected = false;
await handler(socket, command(), reply => replies.push(reply));
assert.equal(code(replies[1]), 'NODE_OFFLINE');
});
test('dispatcher delivers forward_bot while hosted relay control events remain on their own handlers', async () => {
const source = await readFile(new URL('../event_handler/init.ts', import.meta.url), 'utf8');
const dispatch = transformSync(source.slice(source.indexOf('export async function event_handler'))
.replace('export async function', 'async function'), {loader: 'ts'}).code;
const calls = [];
const map = new Map([['forward_bot', (...args) => calls.push(args)]]);
const context = {handler_map: map, console_print: {log() {}, warning() {}, error() {}}, socket: {}, payload: command()};
await vm.runInNewContext(`${dispatch}\nevent_handler(socket, 'forward_bot', payload, () => {});`, context);
assert.equal(calls.length, 1);
await vm.runInNewContext("event_handler(socket, 'forward_open_session', payload, () => {});", context);
assert.equal(calls.length, 1);
});
+305
View File
@@ -0,0 +1,305 @@
import assert from 'node:assert/strict';
import {EventEmitter, getEventListeners} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {register} from 'tsx/esm/api';
const loader = register({namespace: 'function-cancellation-test'});
const tsImport = loader.import;
test.after(() => loader.unregister());
const {create_function_manager} = await tsImport('../bot/function/init.ts', import.meta.url);
const {cancel_functions, cancellation_generation, create_function_cancellation, on_function_cancel} =
await tsImport('../bot/function/cancellation.ts', import.meta.url);
const require = createRequire(import.meta.url);
const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3');
const turn = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return {promise, resolve, reject};
};
const listeners = signal => getEventListeners(signal, 'abort').length;
async function fixture(t) {
const bot = new EventEmitter();
bot.entity = {id: 1, position: new Vec3(0, 0, 0), height: 1.8, eyeHeight: 1.62, onGround: true};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.controlState = {};
bot.getControlState = key => bot.controlState[key] === true;
bot.setControlState = (key, enabled) => { bot.controlState[key] = enabled; };
bot.stopDigging = () => {};
bot.deactivateItem = () => {};
bot.clearControlStates = () => { bot.controlState = {}; };
const messages = [];
bot.chat = message => messages.push(message);
bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null};
const manager = await create_function_manager({bot_instance: bot, bot_status: 'online'});
t.after(() => manager.dispose());
return {bot, manager, messages};
}
test('pre-aborted calls preserve their reason and never execute callbacks or the stop command', async t => {
const {bot, manager} = await fixture(t);
const click = deferred();
bot.clickWindow = () => click.promise;
const running = manager.run('inventory', 'slot_to_slot', [0, 1]);
await turn();
const queued = manager.run('bot', 'chat', ['queued']);
let before = 0;
for (const reason of [undefined, Error('cancelled'), 'custom reason', null]) {
const controller = new AbortController();
controller.abort(reason);
for (const [name, args] of [['chat', ['ignored']], ['stop_current_function', []]]) {
await assert.rejects(manager.run('bot', name, args, () => before++, {signal: controller.signal}),
error => error === controller.signal.reason);
}
assert.equal(listeners(controller.signal), 0);
}
assert.equal(before, 0);
click.resolve();
assert.equal(await running, true);
assert.equal(await queued, true);
});
test('aborting queued calls frees capacity without cancelling the current call or other queued calls', async t => {
const {bot, manager, messages} = await fixture(t);
const click = deferred();
bot.clickWindow = () => click.promise;
const running = manager.run('inventory', 'slot_to_slot', [0, 1]);
await turn();
const controller = new AbortController();
let before = 0;
const cancelled = manager.run('bot', 'chat', ['cancelled'], () => before++, {signal: controller.signal});
const rejected = assert.rejects(cancelled, {name: 'AbortError'});
const queued = Array.from({length: 63}, (_, index) => manager.run('bot', 'chat', [`queued ${index}`]));
await assert.rejects(manager.run('bot', 'chat', ['full']), /队列已满/);
controller.abort();
await rejected;
assert.equal(listeners(controller.signal), 0);
const replacement = manager.run('bot', 'chat', ['replacement']);
assert.deepEqual(messages, []);
click.resolve();
assert.equal(await running, true);
assert.deepEqual(await Promise.all(queued), Array(63).fill(true));
assert.equal(await replacement, true);
assert.equal(before, 0);
assert.equal(messages.length, 64);
assert.equal(messages.at(-1), 'replacement');
});
test('executing abort notifies existing control helpers and preserves unrelated queued calls and listeners', async t => {
const {bot, manager, messages} = await fixture(t);
let globalCancels = 0;
t.after(on_function_cancel(bot, () => globalCancels++));
const controller = new AbortController();
let before = 0;
const running = manager.run('bot', 'use_direction_key', ['forward', 5000], () => before++, {signal: controller.signal});
const rejected = assert.rejects(running, {name: 'AbortError'});
await turn();
assert.equal(bot.controlState.forward, true);
const queued = manager.run('bot', 'chat', ['survived']);
controller.abort();
await rejected;
assert.equal(await queued, true);
assert.equal(bot.controlState.forward, false);
assert.equal(before, 1);
assert.equal(globalCancels, 0);
assert.deepEqual(messages, ['survived']);
assert.deepEqual(bot.eventNames(), []);
assert.deepEqual(bot._client.eventNames(), []);
assert.equal(listeners(controller.signal), 0);
});
test('executing abort drains inventory execution before rejecting and starting the next call', async t => {
for (const fails of [false, true]) {
await t.test(fails ? 'execution rejects' : 'execution resolves', async t => {
const {bot, manager, messages} = await fixture(t);
const click = deferred();
const clicks = [];
let cancelled = 0;
bot.clickWindow = slot => {
clicks.push(slot);
const unwatch = on_function_cancel(bot, () => cancelled++);
return click.promise.finally(unwatch);
};
const controller = new AbortController();
const reason = Error('workflow aborted');
let settled = false;
const running = manager.run('inventory', 'slot_to_slot', [0, 1], undefined, {signal: controller.signal});
const rejected = assert.rejects(running, error => { settled = true; return error === reason; });
await turn();
const queued = manager.run('bot', 'chat', ['after drain']);
controller.abort(reason);
await turn();
assert.equal(cancelled, 1);
assert.equal(settled, false);
assert.deepEqual(messages, []);
if (fails) click.reject(Error('late failure'));
else click.resolve();
await rejected;
assert.equal(await queued, true);
assert.deepEqual(clicks, [0]);
assert.deepEqual(messages, ['after drain']);
assert.equal(listeners(controller.signal), 0);
});
}
});
test('global stop keeps its early false result while the queue waits for actual execution settlement', async t => {
const {bot, manager, messages} = await fixture(t);
const click = deferred();
const clicks = [];
bot.clickWindow = slot => { clicks.push(slot); return click.promise; };
const running = manager.run('inventory', 'slot_to_slot', [0, 1]);
await turn();
const controller = new AbortController();
const discarded = manager.run('bot', 'chat', ['discarded'], undefined, {signal: controller.signal});
let before = 0;
assert.equal(await manager.run('bot', 'stop_current_function', [], () => before++), true);
assert.equal(await running, false);
assert.equal(await discarded, false);
assert.equal(before, 1);
assert.equal(listeners(controller.signal), 0);
const fresh = manager.run('bot', 'chat', ['fresh']);
await turn();
assert.deepEqual(messages, []);
click.resolve();
assert.equal(await fresh, true);
assert.deepEqual(clicks, [0]);
assert.deepEqual(messages, ['fresh']);
});
test('dispose rejects queued calls immediately and notifies and drains the current owned call', async t => {
const {bot, manager, messages} = await fixture(t);
const click = deferred();
const clicks = [];
let cancelled = 0;
bot.clickWindow = slot => {
clicks.push(slot);
const unwatch = on_function_cancel(bot, () => cancelled++);
return click.promise.finally(unwatch);
};
const controller = new AbortController();
let settled = false;
const running = manager.run('inventory', 'slot_to_slot', [0, 1], undefined, {signal: controller.signal});
const rejected = assert.rejects(running, error => { settled = true; return /已销毁/.test(error.message); });
await turn();
const queuedController = new AbortController();
const queued = manager.run('bot', 'chat', ['discarded'], undefined, {signal: queuedController.signal});
const queuedRejected = assert.rejects(queued, /已销毁/);
manager.dispose();
manager.dispose();
await queuedRejected;
assert.equal(listeners(queuedController.signal), 0);
await turn();
assert.equal(cancelled, 1);
assert.equal(settled, false);
click.resolve();
await rejected;
assert.deepEqual(clicks, [0]);
assert.deepEqual(messages, []);
assert.equal(listeners(controller.signal), 0);
await assert.rejects(manager.run('bot', 'chat', ['after dispose']), /已销毁/);
});
test('abort or disposal inside before_run prevents execution and preserves callback failures', async t => {
const {manager, messages} = await fixture(t);
const controller = new AbortController();
await assert.rejects(manager.run('bot', 'chat', ['aborted'], () => controller.abort(), {signal: controller.signal}),
{name: 'AbortError'});
assert.equal(listeners(controller.signal), 0);
const failure = Error('callback failed');
await assert.rejects(manager.run('bot', 'chat', ['failed'], () => { throw failure; }), error => error === failure);
assert.equal(await manager.run('bot', 'chat', ['next']), true);
await assert.rejects(manager.run('bot', 'chat', ['disposed'], () => manager.dispose()), /已销毁/);
assert.deepEqual(messages, ['next']);
});
test('successful and failed calls retain booleans and remove signal listeners before later aborts', async t => {
const {bot, manager, messages} = await fixture(t);
const controller = new AbortController();
let before = 0;
assert.equal(await manager.run('bot', 'chat', ['success'], () => before++, {signal: controller.signal}), true);
assert.equal(before, 1);
assert.equal(listeners(controller.signal), 0);
bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null};
bot.clickWindow = () => Promise.reject(Error('click failed'));
assert.equal(await manager.run('inventory', 'slot_to_slot', [0, 1], undefined, {signal: controller.signal}), false);
assert.equal(listeners(controller.signal), 0);
const next = manager.run('bot', 'jump', []);
await turn();
controller.abort();
assert.equal(bot.controlState.jump, true);
bot.emit('physicsTick');
assert.equal(await next, true);
assert.deepEqual(messages, ['success']);
});
test('per-call abort and disposal cannot cancel another manager running on the same bot', async t => {
const {bot, manager} = await fixture(t);
const other = await create_function_manager({bot_instance: bot, bot_status: 'online'});
t.after(() => other.dispose());
const controller = new AbortController();
const first = manager.run('bot', 'use_direction_key', ['forward', 5000], undefined, {signal: controller.signal});
const rejected = assert.rejects(first, {name: 'AbortError'});
const second = other.run('bot', 'jump', []);
await turn();
controller.abort();
await rejected;
assert.equal(bot.controlState.forward, false);
assert.equal(bot.controlState.jump, true);
const third = manager.run('bot', 'use_direction_key', ['right', 5000]);
const disposed = assert.rejects(third, /已销毁/);
await turn();
manager.dispose();
await disposed;
assert.equal(bot.controlState.right, false);
assert.equal(bot.controlState.jump, true);
bot.emit('physicsTick');
assert.equal(await second, true);
assert.deepEqual(bot.eventNames(), []);
assert.deepEqual(bot._client.eventNames(), []);
});
test('scoped cancellation isolates listeners and generation checks across awaits while global cancel reaches all', async () => {
const bot = new EventEmitter();
const first = create_function_cancellation(bot);
const second = create_function_cancellation(bot);
const otherBot = new EventEmitter();
const counts = {global: 0, first: 0, second: 0, other: 0};
const unwatch = on_function_cancel(bot, () => counts.global++);
const unwatchOther = on_function_cancel(otherBot, () => counts.other++);
const globalGeneration = cancellation_generation(bot);
const gate = deferred();
const watch = (scope, name) => scope.run(async () => {
const generation = cancellation_generation(bot);
await turn();
const off = on_function_cancel(bot, () => counts[name]++);
try {
await gate.promise;
return cancellation_generation(bot) - generation;
} finally { off(); }
});
try {
const firstWatch = watch(first, 'first');
const secondWatch = watch(second, 'second');
await turn();
first.cancel();
assert.deepEqual(counts, {global: 0, first: 1, second: 0, other: 0});
assert.equal(cancellation_generation(bot), globalGeneration);
first.run(() => cancel_functions(bot));
assert.deepEqual(counts, {global: 1, first: 2, second: 1, other: 0});
gate.resolve();
assert.deepEqual(await Promise.all([firstWatch, secondWatch]), [2, 1]);
first.dispose();
second.dispose();
cancel_functions(bot);
assert.deepEqual(counts, {global: 2, first: 2, second: 1, other: 0});
} finally {
gate.resolve();
first.dispose();
second.dispose();
unwatch();
unwatchOther();
}
});
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {create_function_manager} = await tsImport('../bot/function/init.ts', import.meta.url);
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const {Vec3} = mf('vec3');
test('manager fills omitted need_look defaults and preserves explicit values', async () => {
const bot = new EventEmitter();
bot.entity = {id: 1, position: new Vec3(0, 0, 0), eyeHeight: 1.62, height: 1.8};
bot._client = {};
bot.entities = {2: {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 1, height: 2}};
let looks = 0;
let attacks = 0;
bot.lookAt = async () => { looks++; };
bot.attack = () => { attacks++; };
const block = {name: 'stone', type: 1, position: new Vec3(2, 1, 0)};
bot.blockAt = () => block;
bot.activateBlock = async (_block, _face, _delta, forceLook) => assert.equal(forceLook, 'ignore');
const manager = await create_function_manager({bot_instance: bot, bot_status: 'online'});
try {
for (const category of manager.get_function_list().filter(category => ['entity', 'block'].includes(category.path))) {
for (const fn of category.function_list) {
assert.equal(fn.args.at(-1).name, 'need_look');
assert.equal(fn.args.at(-1).default, false);
}
}
assert.equal(await manager.run('entity', 'attack_nearest_entity', ['cow', 3, true]), true);
assert.equal(looks, 0);
assert.equal(await manager.run('entity', 'attack_nearest_entity', ['cow', 3, true, false]), true);
assert.equal(looks, 0);
assert.equal(await manager.run('entity', 'attack_nearest_entity', ['cow', 3, true, true]), true);
assert.equal(looks, 1);
assert.equal(attacks, 3);
assert.equal(await manager.run('block', 'use_position_block', [2, 1, 0, 1]), true);
assert.equal(looks, 1);
assert.equal(await manager.run('block', 'use_position_block', [2, 1, 0, 1, true]), true);
assert.equal(looks, 2);
for (const args of [['cow', 3], ['cow', 3, true, null], ['cow', 3, true, 'true']])
await assert.rejects(manager.run('entity', 'attack_nearest_entity', args));
} finally { manager.dispose(); }
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import vm from 'node:vm';
import test from 'node:test';
import {transformSync} from 'esbuild';
const source = await readFile(new URL('../service/server/update/function_list.ts', import.meta.url), 'utf8');
const code = transformSync(source.replace(/^import .*;\n/gm, '')
.replace('export async function', 'async function'), {loader: 'ts'}).code;
function fixture(response = {status: 'success', data: 0}) {
const reports = []; const logs = [];
const list = [{path: 'inventory', name: 'inventory', i18n: [], function_list: []}];
const context = {
get_function_list: async () => list,
console_print: {log: value => logs.push(value), success: value => value},
socket: {connected: true, timeout(ms) {assert.equal(ms, 5000); return this;},
async emitWithAck(...args) {reports.push(args); return response;}},
};
return {context, reports, logs, list, run: () => vm.runInNewContext(`${code}\nupdate_function_list(socket, 1, is_current);`,
{...context, is_current: () => context.socket.connected && context.current !== false})};
}
test('function list reports grouped metadata and validates success, error and malformed ACKs', async () => {
const f = fixture(); await f.run();
assert.equal(f.reports[0][0], 'update_function_list');
assert.equal(f.reports[0][1].id, 1);
assert.equal(f.reports[0][1].function_list, f.list);
assert.equal(f.logs.length, 1);
for (const response of [undefined, null, {}, {status: 'unknown'}, {status: 'error', data: 403}]) {
const failed = fixture(response);
if (response === undefined) failed.context.socket.emitWithAck = async () => undefined;
await assert.rejects(failed.run(), /函数列表上报失败/);
assert.equal(failed.logs.length, 0);
}
});
test('function list never reports a late scan or ACK through a replaced session', async () => {
const f = fixture(); let finish;
f.context.get_function_list = () => new Promise(resolve => {finish = resolve;});
const task = f.run(); f.context.current = false; finish(f.list); await task;
assert.equal(f.reports.length, 0);
const late = fixture(); let ack; let started;
const emitted = new Promise(resolve => {started = resolve;});
late.context.socket.emitWithAck = () => new Promise(resolve => {ack = resolve; started();});
const reporting = late.run(); await emitted; late.context.current = false;
ack({status: 'success'}); await reporting;
assert.equal(late.logs.length, 0);
});
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import {test} from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {init: InventoryInfo} = await tsImport('../bot/info/bot/inventory.ts', import.meta.url);
const {create_info_manager} = await tsImport('../bot/info/init.ts', import.meta.url);
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const registry = mf('prismarine-registry')('1.21.4');
const windows = mf('prismarine-windows')(registry);
const Item = mf('prismarine-item')(registry);
function createBot() {
const bot = new EventEmitter();
bot._client = new EventEmitter();
bot.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory');
bot.currentWindow = null;
bot.quickBarSlot = 2;
bot.registry = registry;
return bot;
}
test('inventory snapshots cover all nine window kinds and special properties without operations', t => {
t.mock.timers.enable({apis: ['setTimeout']});
const bot = createBot();
const info = new InventoryInfo(bot);
t.after(() => info.dispose());
const tick = () => t.mock.timers.tick(50);
const open = (type, id = 1) => {
const window = windows.createWindow(id, `minecraft:${type}`, type);
bot.currentWindow = window;
bot.emit('windowOpen', window);
tick();
return window;
};
const property = (id, value) => bot._client.emit('craft_progress_bar', {
windowId: bot.currentWindow.id, property: id, value,
});
assert.equal(info.get_info().window.kind, 'inventory');
assert.equal(info.get_info().handSlot, 38);
assert.equal(bot.listenerCount('end'), 0);
for (const [type, kind, handSlot] of [
['generic_9x3', 'chest', 56],
['generic_9x6', 'large_chest', 83],
['crafting', 'crafting', 39],
['enchantment', 'enchantment', 31],
['stonecutter', 'stonecutter', 31],
['furnace', 'furnace', 32],
['merchant', 'merchant', 32],
['lectern', 'lectern', null],
]) {
const previous = bot.currentWindow;
const window = open(type);
assert.equal(info.get_info().window.kind, kind);
assert.equal(info.get_info().handSlot, handSlot);
if (previous) assert.equal(previous.listenerCount('updateSlot'), 0);
if (kind === 'lectern') {
assert.equal(info.get_info().window.hasPlayerInventory, false);
assert.equal(info.get_info().window.inventoryStart, null);
assert.equal(info.get_info().window.slots.length, 1);
}
assert.equal(window.listenerCount('updateSlot'), 1);
}
open('furnace');
assert.equal(info.get_info().special.furnace.fuelRatio, null);
property(0, 40);
property(1, 80);
property(2, 50);
property(3, 200);
tick();
assert.equal(info.get_info().special.furnace.fuelRatio, 0.5);
assert.equal(info.get_info().special.furnace.progressRatio, 0.25);
open('furnace');
assert.deepEqual(info.get_info().window.properties, {});
// Properties can arrive before windowOpen, while Mineflayer awaits slot contents.
bot.currentWindow = windows.createWindow(2, 'minecraft:enchantment', 'Enchant');
property(0, 30);
property(4, 5);
property(7, 3);
bot.emit('windowOpen', bot.currentWindow);
tick();
const option = info.get_info().special.enchantment.options[0];
assert.deepEqual(option, {index: 0, requiredLevel: 30, levelCost: 1, lapisCost: 1, enchantmentId: 5, enchantmentLevel: 3});
assert.equal(info.get_info().special.enchantment.options[1].requiredLevel, null);
const merchant = open('merchant');
assert.equal(info.get_info().special.merchant.ready, false);
merchant.trades = [{
inputItem1: new Item(registry.itemsByName.emerald.id, 2),
outputItem: new Item(registry.itemsByName.bread.id, 1),
hasItem2: false, realPrice: 1, tradeDisabled: false, nbTradeUses: 0, maximumNbTradeUses: 12,
}];
bot._client.emit('trade_list');
tick();
const offers = info.get_info().special.merchant.offers;
assert.equal(offers[0].input1.type, registry.itemsByName.emerald.id);
assert.equal(offers[0].realPrice, 1);
assert.equal(offers[0].input2, null);
const lectern = open('lectern');
const book = new Item(registry.itemsByName.written_book.id, 1);
book.components = [{type: 'written_book_content', data: {
rawTitle: 'Title', author: 'Author', pages: [{content: {
type: 'compound', value: {text: {type: 'string', value: 'Page'}, color: {type: 'string', value: 'red'}},
}}],
}}];
lectern.updateSlot(0, book);
property(0, 0);
tick();
assert.equal(info.get_info().special.lectern.book.title, 'Title');
assert.equal(info.get_info().special.lectern.book.pages[0][0].message, 'Page');
assert.equal(info.get_info().special.lectern.book.pages[0][0].font_color, '#FF5555');
assert.equal(info.get_info().special.lectern.page, 0);
const oldBook = {
type: book.type, name: 'written_book', displayName: 'Written Book', count: 1, metadata: 0,
nbt: {type: 'compound', value: {
title: {type: 'string', value: 'Legacy'}, author: {type: 'string', value: 'Writer'},
pages: {type: 'list', value: {type: 'string', value: ['{"text":"Old page"}']}},
long: {type: 'long', value: 123n}, bytes: Buffer.from([1, 2]),
}},
};
lectern.slots[0] = oldBook;
lectern.emit('updateSlot');
tick();
assert.equal(info.get_info().special.lectern.book.pages[0][0].message, 'Old page');
assert.equal(info.get_info().window.slots[0].details.extra.legacy.long, '123');
assert.deepEqual(info.get_info().window.slots[0].details.extra.legacy.bytes, [1, 2]);
assert.equal('nbt' in info.get_info().window.slots[0], false);
assert.doesNotThrow(() => JSON.stringify(info.get_info()));
assert.equal('icon' in info.get_info().window.slots[0], false);
const cyclic = {number: 1n};
cyclic.self = cyclic;
Object.defineProperty(cyclic, 'accessor', {enumerable: true, get() {throw new Error('must not invoke');}});
oldBook.components = [{type: 'custom_data', data: cyclic}];
lectern.emit('updateSlot');
tick();
const data = info.get_info().window.slots[0].details.extra.custom_data;
assert.deepEqual(data, {number: '1', self: null, accessor: null});
assert.equal(info.get_info().window.slots[0].truncated, true);
bot.currentWindow = null;
bot.emit('windowClose', lectern);
tick();
assert.equal(info.get_info().window.kind, 'inventory');
assert.deepEqual(info.get_info().special, {});
assert.equal(lectern.listenerCount('updateSlot'), 0);
let updates = 0;
const unsubscribe = info.on_update(() => updates++);
bot.inventory.selectedItem = new Item(registry.itemsByName.stone.id, 3);
bot._client.emit('set_slot');
tick();
assert.equal(info.get_info().cursorItem.count, 3);
assert.equal(info.get_info().cursorItem.slot, null);
bot.emit('physicsTick');
tick();
assert.equal(updates, 1);
unsubscribe();
bot.emit('physicsTick');
info.dispose();
tick();
assert.equal(bot.eventNames().length, 0);
assert.equal(bot._client.eventNames().length, 0);
assert.equal(bot.inventory.listenerCount('updateSlot'), 0);
assert.throws(() => info.on_update(() => {}), /已销毁/);
});
test('the real info manager loads inventory and owns its cleanup', async () => {
const bot = createBot();
const manager = await create_info_manager(bot);
assert.equal(bot.listenerCount('end'), 1);
assert.equal(manager.get_info('inventory').value.window.kind, 'inventory');
assert.ok(manager.get_info('inventory').i18n.fields.window.fields.slots.fields.type);
bot.emit('end');
manager.dispose();
assert.equal(bot.eventNames().length, 0);
assert.equal(bot._client.eventNames().length, 0);
assert.equal(bot.inventory.listenerCount('updateSlot'), 0);
});
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {test} from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {parse_inventory_item: parse} = await tsImport('../utils/inventory_item.ts', import.meta.url);
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const registry = mf('prismarine-registry')('1.21.4');
const Item = mf('prismarine-item')(registry);
const component = (type, data) => ({type, data});
const tag = (type, value) => ({type, value});
const plain = item => parse(item, 36, registry);
test('modern custom names and lore become rich logs with explicit false and no executable events', () => {
const item = new Item(registry.itemsByName.clock.id, 8);
item.components = [component('custom_name', tag('compound', {
text: tag('string', ''), bold: tag('byte', 1), italic: tag('byte', 1),
extra: tag('list', {type: 'compound', value: [{
text: tag('string', '菜单'), color: tag('string', 'gold'),
bold: tag('byte', 0), italic: tag('byte', 0), underlined: tag('byte', 0),
clickEvent: tag('compound', {action: tag('string', 'run_command'), value: tag('string', '/op me')}),
}]}),
})), component('lore', [tag('compound', {text: tag('string', '右键打开'), color: tag('string', 'gray')}), tag('string', '')])];
const result = plain(item);
assert.equal(result.count, 8);
assert.deepEqual(result.i18n, [{lang: 'zh-CN', name: '时钟', description: ''}]);
assert.equal(result.displayName[0].message, '菜单');
assert.equal(result.displayName[0].font_color, '#FFAA00');
assert.deepEqual(result.displayName[0].font_style, ['normal']);
assert.equal(result.displayName[0].clickEvent, undefined);
assert.equal(result.lore[0][0].message, '右键打开');
assert.deepEqual(result.lore[1], []);
assert.equal('components' in result, false);
assert.equal('nbt' in result, false);
assert.equal(result.truncated, false);
});
test('legacy NBT resolves names enchants damage and remaining unknown tags', () => {
const item = {
type: registry.itemsByName.diamond_sword.id, name: 'diamond_sword', count: 1, displayName: 'Diamond Sword',
nbt: tag('compound', {
display: tag('compound', {Name: tag('string', '{"text":"剑","color":"aqua"}'), Lore: tag('list', {type: 'string', value: ['{"text":"描述"}']})}),
Enchantments: tag('list', {type: 'compound', value: [{id: tag('string', 'minecraft:sharpness'), lvl: tag('short', 5)}]}),
Damage: tag('int', 20), Unbreakable: tag('byte', 1), HideFlags: tag('int', 1),
plugin: tag('compound', {menu: tag('string', 'main'), timestamp: tag('long', 123n)}),
}),
};
const result = plain(item);
assert.equal(result.displayName[0].message, '剑');
assert.equal(result.lore[0][0].message, '描述');
assert.deepEqual(result.enchants, [{name: 'sharpness', i18n: [{lang: 'zh-CN', name: '锋利', description: ''}], level: 5, hidden: true}]);
assert.equal(result.durability.used, 20);
assert.equal(result.durability.remaining, result.durability.max - 20);
assert.equal(result.unbreakable, true);
assert.deepEqual(result.details.extra.legacy.plugin, {menu: 'main', timestamp: '123'});
assert.equal(result.details.extra.legacy.Enchantments, undefined);
});
test('modern enchantments potions attributes and flags have structured values', () => {
const item = new Item(registry.itemsByName.diamond_sword.id, 1);
const id = registry.enchantmentsByName.sharpness.id;
item.components = [
component('item_name', {text: '物品名称'}),
component('enchantments', {enchantments: [{id, level: 4}], showTooltip: false}),
component('max_damage', 100), component('damage', 12), component('unbreakable', {}),
component('enchantment_glint_override', false),
component('potion_contents', {potionId: 3, customColor: 255, customEffects: [{id: 0, amplifier: 1, duration: 200}]}),
component('attribute_modifiers', {showTooltip: false, attributes: [{typeId: 1, name: 'bonus', value: 3, operation: 'add', slot: 'main_hand'}]}),
];
const result = plain(item);
assert.equal(result.displayName[0].message, '物品名称');
assert.deepEqual(result.enchants, [{id, name: 'sharpness', i18n: [{lang: 'zh-CN', name: '锋利', description: ''}], level: 4, hidden: true}]);
assert.deepEqual(result.durability, {used: 12, max: 100, remaining: 88});
assert.equal(result.glint, false);
assert.equal(result.details.potion.effects[0].amplifier, 1);
assert.equal(result.details.potion.effects[0].duration, 200);
assert.deepEqual(result.details.attributes.modifiers[0], {type: 1, name: 'bonus', amount: 3, operation: 'add', slot: 'main_hand', hidden: false, displayName: []});
});
test('nested modern containers and legacy bundles use parsed items rather than protocol slots', () => {
const item = new Item(registry.itemsByName.shulker_box.id, 1);
item.components = [component('container', {contents: [{itemCount: 4, itemId: registry.itemsByName.clock.id,
components: [component('custom_name', {text: '嵌套时钟'})]}]})];
const result = plain(item);
assert.equal(result.details.container[0].slot, 0);
assert.equal(result.details.container[0].count, 4);
assert.equal(result.details.container[0].displayName[0].message, '嵌套时钟');
const legacy = {type: registry.itemsByName.bundle.id, name: 'bundle', count: 1,
nbt: tag('compound', {Items: tag('list', {type: 'compound', value: [{id: tag('string', 'minecraft:stone'), Count: tag('byte', 3)}]})})};
assert.equal(plain(legacy).details.bundle[0].name, 'stone');
assert.equal(plain(legacy).details.bundle[0].count, 3);
});
test('written and writable books retain different page semantics and truncation is explicit', () => {
const written = new Item(registry.itemsByName.written_book.id, 1);
written.components = [component('written_book_content', {rawTitle: 'Book', author: 'Author', pages: [{content: tag('compound', {text: tag('string', 'Page')})}]})];
assert.equal(plain(written).book.pages[0][0].message, 'Page');
const writable = new Item(registry.itemsByName.writable_book.id, 1);
writable.components = [component('writable_book_content', {pages: [{content: '{"text":"literal"}'}]})];
assert.equal(plain(writable).book.pages[0][0].message, '{"text":"literal"}');
written.components.push(component('custom_data', {long: 'x'.repeat(100_000)}));
assert.equal(plain(written).truncated, true);
assert.doesNotThrow(() => JSON.stringify(plain(written)));
const cycle = {}; cycle.self = cycle;
written.components = [component('custom_data', cycle)];
assert.equal(plain(written).details.extra.custom_data.self, null);
assert.equal(plain(written).truncated, true);
});
test('legacy section colors and current tooltip component IDs are resolved', () => {
const legacy = {type: 1, count: 1, name: 'stone', displayName: 'Stone',
nbt: tag('compound', {display: tag('compound', {Name: tag('string', '§6§l菜单§r 普通')})})};
const result = plain(legacy);
assert.equal(result.displayName[0].message, '菜单');
assert.equal(result.displayName[0].font_color, '#FFAA00');
assert.deepEqual(result.displayName[0].font_style, ['bold']);
const latest = mf('prismarine-registry')('26.2');
const mappings = latest.protocol.types.SlotComponentType[1].mappings;
const enchantComponentId = Number(Object.keys(mappings).find(id => mappings[id] === 'enchantments'));
const item = {type: latest.itemsByName.diamond_sword.id, count: 1, components: [
component('enchantments', {enchantments: [{id: 0, level: 1}]}),
component('tooltip_display', {hideTooltip: false, hiddenComponents: [enchantComponentId]}),
component('attribute_modifiers', [{typeId: 0, name: 'test', value: 2, operation: 'add', slot: 'main_hand', display: {type: 'hidden'}}]),
]};
const parsed = parse(item, 0, latest);
assert.equal(parsed.enchants[0].hidden, true);
assert.equal(parsed.details.attributes.modifiers[0].amount, 2);
assert.equal(parsed.details.attributes.modifiers[0].hidden, true);
assert.deepEqual(parsed.details.tooltip.hiddenComponents, ['enchantments']);
});
test('item Chinese names use block keys then item keys and fall back to the identifier', () => {
assert.equal(plain({type: 1, name: 'stone', count: 1}).i18n[0].name, '石头');
assert.equal(plain({type: 1, name: 'diamond_sword', count: 1}).i18n[0].name, '钻石剑');
assert.equal(plain({type: 1, name: 'plugin_unknown_item', count: 1}).i18n[0].name, 'plugin_unknown_item');
const nested = plain({type: registry.itemsByName.bundle.id, name: 'bundle', count: 1,
components: [component('bundle_contents', {contents: [{itemId: 1, itemCount: 2}]})]});
assert.equal(nested.details.bundle[0].i18n[0].name, '石头');
});
test('stored and unknown enchantments include translated names or identifier fallbacks', () => {
const parsed = plain({type: registry.itemsByName.enchanted_book.id, count: 1, components: [component('stored_enchantments', {
enchantments: [{id: 'minecraft:mending', level: 1}, {id: 'plugin_custom', level: 2}, {id: 999999, level: 3}],
})]});
assert.equal(parsed.enchants[0].i18n[0].name, '经验修补');
assert.equal(parsed.enchants[1].i18n[0].name, 'plugin_custom');
assert.equal(parsed.enchants[2].i18n[0].name, '999999');
});
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {init: DropAll} = await tsImport('../bot/function/item/drop_slot_all_item.ts', import.meta.url);
const {init: DropNumber} = await tsImport('../bot/function/item/drop_slot_item_number.ts', import.meta.url);
const {init: UseItem} = await tsImport('../bot/function/item/use_item.ts', import.meta.url);
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const registry = mf('prismarine-registry')('1.21.4');
const windows = mf('prismarine-windows')(registry);
const Item = mf('prismarine-item')(registry);
function botFixture(container = false) {
const inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory');
const currentWindow = container ? windows.createWindow(1, 'minecraft:generic_9x3', 'Chest') : null;
const window = currentWindow ?? inventory;
window.updateSlot(9, new Item(1, 5));
const clicks = [];
return {inventory, currentWindow, clicks, async clickWindow(slot, mouseButton, mode) {
clicks.push([slot, mouseButton, mode]);
window.acceptClick({slot, mouseButton, mode, item: window.slots[slot]}, 0);
}};
}
test('drop functions use native drop clicks in inventory and container without closing', async () => {
for (const container of [false, true]) {
const bot = botFixture(container);
const window = bot.currentWindow ?? bot.inventory;
assert.equal(await new DropNumber().run(bot, [9, 2]), true);
assert.equal(window.slots[9].count, 3);
assert.deepEqual(bot.clicks, [[9, 0, 4], [9, 0, 4]]);
assert.equal(await new DropAll().run(bot, [9]), true);
assert.equal(window.slots[9], null);
assert.deepEqual(bot.clicks.at(-1), [9, 1, 4]);
assert.equal(await new DropAll().run(bot, [9]), false);
}
const bot = botFixture();
assert.equal(await new DropNumber().run(bot, [9, 100]), true);
assert.equal(bot.clicks.length, 5);
assert.equal(bot.inventory.slots[9], null);
});
test('drop functions reject invalid inputs, cursor items, failures and changed windows', async () => {
for (const [fn, valid, invalid] of [
[new DropAll(), [9], [[], [-1], [46], [1.5], [NaN], [9, 1]]],
[new DropNumber(), [9, 2], [[], [9], [-1, 2], [9, 0], [9, -1], [9, 1.5], [9, Infinity]]],
]) {
const bot = botFixture();
for (const args of invalid) assert.equal(await fn.run(bot, args), false);
assert.equal(bot.clicks.length, 0);
bot.inventory.selectedItem = new Item(2, 1);
assert.equal(await fn.run(bot, valid), false);
bot.inventory.selectedItem = null;
bot.clickWindow = async () => { throw Error('rejected'); };
assert.equal(await fn.run(bot, valid), false);
let calls = 0;
bot.clickWindow = async () => { calls++; bot.currentWindow = {}; };
assert.equal(await fn.run(bot, valid), false);
assert.equal(calls, 1);
}
const bot = botFixture();
bot.clickWindow = async () => {};
assert.equal(await new DropNumber().run(bot, [9, 2]), false);
assert.equal(await new DropAll().run(bot, [9]), false);
});
test('using an item selects the requested hand and rejects empty or unsupported hands', async () => {
const bot = botFixture();
const used = [];
bot.heldItem = new Item(1, 1);
bot.inventory.slots[45] = new Item(2, 1);
bot.supportFeature = () => false;
bot.activateItem = hand => used.push(hand);
const fn = new UseItem();
assert.equal(await fn.run(bot, [false]), true);
assert.equal(await fn.run(bot, [true]), true);
assert.deepEqual(used, [false, true]);
for (const args of [[], [0], ['false'], [false, true]]) assert.equal(await fn.run(bot, args), false);
bot.heldItem = null;
assert.equal(await fn.run(bot, [false]), false);
bot.supportFeature = () => true;
assert.equal(await fn.run(bot, [true]), false);
bot.supportFeature = () => false;
bot.inventory.slots[45] = null;
assert.equal(await fn.run(bot, [true]), false);
bot.heldItem = new Item(1, 1);
bot.activateItem = () => { throw Error('failed'); };
assert.equal(await fn.run(bot, [false]), false);
assert.deepEqual(used, [false, true]);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import {mkdtemp, readdir, readFile, rm, writeFile, stat} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
const compiled = await build({
entryPoints: [fileURLToPath(new URL('../utils/logger.ts', import.meta.url))],
bundle: true,
platform: 'node',
format: 'esm',
write: false,
});
const {Logger} = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString('base64')}`);
test('closing logger drains pending records, closes file and ignores late logs', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-logger-'));
try {
const logger = new Logger(directory);
for (let i = 0; i < 20; i++) logger.log([logger.info(String(i))]);
const closed = logger.close();
assert.equal(logger.close(), closed);
logger.log([logger.info('late')]);
await closed;
const [name] = await readdir(directory);
const lines = (await readFile(path.join(directory, name), 'utf8')).trim().split('\n');
assert.deepEqual(lines.map(line => JSON.parse(line).log[0].message),
Array.from({length: 20}, (_, i) => String(i)));
} finally {
await rm(directory, {recursive: true, force: true});
}
});
test('disk history sorts dates and numeric rotations and skips malformed records', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-history-'));
const record = id => ({id, timestamp: 1, log: [{message: id, font_color: '#ffffff', font_style: ['normal']}]});
try {
await Promise.all([
writeFile(path.join(directory, '2026-10-16_log_99.log'), JSON.stringify(record('older')) + '\n'),
writeFile(path.join(directory, '2026-10-17_log.log'), JSON.stringify(record('base')) + '\n'),
writeFile(path.join(directory, '2026-10-17_log_9.log'), JSON.stringify(record('nine')) + '\n'),
writeFile(path.join(directory, '2026-10-17_log_10.log'), [
JSON.stringify(record('ten')), 'broken json', 'null', '{}',
'{"id":"bad","timestamp":"1","log":[]}',
JSON.stringify(record('latest')),
].join('\n')),
writeFile(path.join(directory, 'unrelated.log'), JSON.stringify(record('ignored'))),
]);
assert.deepEqual((await Logger.read_recent_log(directory, 10)).map(item => item.id),
['older', 'base', 'nine', 'ten', 'latest']);
assert.deepEqual(await Logger.read_recent_log(directory, 2), [record('ten'), record('latest')]);
assert.deepEqual(await Logger.read_recent_log(directory, 0), []);
const missing = path.join(directory, 'missing');
assert.deepEqual(await Logger.read_recent_log(missing, 10), []);
await assert.rejects(stat(missing), {code: 'ENOENT'});
} finally {
await rm(directory, {recursive: true, force: true});
}
});
test('disk history handles chunk boundaries, oversized lines and a bounded read budget', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-history-'));
const record = (id, message) => ({id, timestamp: 1, log: [{message, font_color: '#ffffff', font_style: ['normal']}]});
try {
const first = record('first', '中文'.repeat(25000));
const last = record('last', 'tail');
const filename = path.join(directory, '2026-10-17_log.log');
await writeFile(filename, [JSON.stringify(first), 'x'.repeat(1024 * 1024 + 1), JSON.stringify(last), ''].join('\n'));
assert.deepEqual(await Logger.read_recent_log(directory, 10), [first, last]);
await writeFile(filename, JSON.stringify(first) + '\n' + 'x'.repeat(17 * 1024 * 1024) + '\n' + JSON.stringify(last));
assert.deepEqual(await Logger.read_recent_log(directory, 10), [last]);
} finally {
await rm(directory, {recursive: true, force: true});
}
});
test('instance history includes immediate in-flight and queued records, deduplicates disk and survives close', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'node-bot-history-'));
const logger = new Logger(directory);
try {
for (let i = 0; i < 100; i++) logger.log([logger.info(String(i))]);
const immediate = logger.read_recent_log(100);
const recent = logger.read_recent_log(3);
const closed = logger.close();
const closing = logger.read_recent_log(100);
const records = await immediate;
assert.deepEqual(records.map(record => record.log[0].message),
Array.from({length: 100}, (_, i) => String(i)));
assert.equal(new Set(records.map(record => record.id)).size, 100);
assert.ok(records.every(record => typeof record.timestamp === 'number'));
assert.deepEqual((await recent).map(record => record.log[0].message), ['97', '98', '99']);
assert.deepEqual(await closing, records);
await closed;
assert.deepEqual(await Logger.read_recent_log(directory, 100), records);
assert.deepEqual(await logger.read_recent_log(100), records);
} finally {
await logger.close();
await rm(directory, {recursive: true, force: true});
}
});
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import vm from 'node:vm';
import test from 'node:test';
import {transformSync} from 'esbuild';
const source = await readFile(new URL('../index.ts', import.meta.url), 'utf8');
const lifecycle = source.slice(source.indexOf(' let reconnect_timer:'), source.indexOf(" main_server_socket.on('connect',"));
const disconnect = source.slice(source.indexOf(" main_server_socket.on('disconnect',"), source.indexOf(' main_server_socket.onAny('));
const code = transformSync(`${lifecycle}\n${disconnect}`, {loader: 'ts'}).code;
function setup(enabled = true, max_attempts = 2) {
const timers = new Map();
const handlers = new Map();
const state = {connects: 0, disconnects: 0};
const process = {once() {}};
let id = 0;
vm.runInNewContext(code, {
auto_reconnect: {enabled, max_attempts, interval: 100},
reconnect_interval: 100,
main_server_socket: {
on: (event, handler) => handlers.set(event, handler),
connect: () => state.connects++,
disconnect: () => state.disconnects++,
},
console_print: {log() {}, info() {}, error() {}, warning() {}},
clear_node_session() {},
get_bot_context_status: async () => [],
stop_bot_context: async () => {},
process,
setTimeout(callback, delay) {
if (delay === 10_000) return ++id;
assert.equal(delay, 100);
timers.set(++id, callback);
return id;
},
clearTimeout: id => timers.delete(id),
});
return {
state, process, timers,
disconnect: reason => handlers.get('disconnect')(reason),
tick() {
assert.equal(timers.size, 1);
const [id, callback] = timers.entries().next().value;
timers.delete(id);
callback();
},
};
}
test('server disconnect retries indefinitely without duplicate timers after the configured limit', () => {
const app = setup();
for (let attempt = 0; attempt < 12; attempt++) {
app.disconnect('io server disconnect');
app.disconnect('io server disconnect');
app.tick();
assert.equal(app.state.connects, attempt + 1);
}
app.disconnect('io server disconnect');
assert.equal(app.state.disconnects, 0);
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
});
test('disabled reconnect and a zero attempt limit still retry every disconnect reason', () => {
const app = setup(false, 0);
for (const reason of ['io server disconnect', 'io client disconnect', 'transport close', 'ping timeout', 'transport error']) {
app.disconnect(reason);
app.tick();
}
assert.equal(app.state.connects, 5);
app.disconnect('io client disconnect');
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
});
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
const compiled = await build({
entryPoints: [fileURLToPath(new URL('../utils/message_json_parser.ts', import.meta.url))],
bundle: true,
platform: 'node',
format: 'esm',
write: false,
});
const {MinecraftJsonParser: parser} = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString('base64')}`);
test('log fragments inherit styles and respect explicit false / zero overrides', () => {
const input = {text: 'parent', color: 'red', bold: 1, extra: [
{text: 'child', italic: true},
{text: 'plain', bold: 0},
{text: 'tail', bold: false},
]};
assert.deepEqual(parser.toLog(input), [
{message: 'parent', font_color: '#FF5555', font_style: ['bold']},
{message: 'child', font_color: '#FF5555', font_style: ['bold', 'italic']},
{message: 'plaintail', font_color: '#FF5555', font_style: ['normal']},
]);
assert.equal(input.extra[1].bold, 0);
});
test('translations keep parameter styles, trailing extras and chat message contents', () => {
const parsed = parser.parse({translate: 'chat.type.text', color: 'gold', with: [
{text: 'Alex', color: 'green'}, 'hello » world',
], extra: ['!']});
assert.equal(parsed.plainText, '<Alex> hello » world!');
assert.equal(parsed.username, 'Alex');
assert.equal(parsed.message, 'hello » world!');
assert.equal(parsed.log[1].font_color, '#55FF55');
assert.equal(parsed.log.at(-1).font_color, '#FFAA00');
assert.equal('html' in parsed, false);
assert.equal(parser.toHTML, undefined);
});
test('fallback translations support indexed parameters, literal percent and missing arguments', () => {
assert.equal(parser.extractPlainText({translate: 'missing.key', fallback: '%2$s %% %s %3$s',
with: [0, false]}), 'false % 0 %3$s');
});
test('ChatMessage uses lossless raw JSON, including arrays and inherited URL style', () => {
const raw = [{text: '链接', color: '#123456', click_event: {action: 'open_url', url: 'https://example.com'}},
'😀', {text: '<&>', underlined: 1}];
const parsed = parser.parse({text: 'lossy', json: raw, warn() {}});
assert.equal(parsed.plainText, '链接😀<&>');
assert.deepEqual(parsed.log, [
{message: '链接😀', font_color: '#123456', font_style: ['url'], clickEvent: {action: 'open_url', value: 'https://example.com'}},
{message: '<&>', font_color: '#123456', font_style: ['underline', 'url'], clickEvent: {action: 'open_url', value: 'https://example.com'}},
]);
});
test('empty, primitive and cyclic inputs are bounded and preserve valid text', () => {
assert.deepEqual(parser.toLog(null), []);
assert.equal(parser.extractPlainText([0, false, '']), '0false');
const cyclic = {text: 'x'};
cyclic.extra = [cyclic];
const text = parser.extractPlainText(cyclic);
assert.ok(text.length > 0 && text.length <= 65);
assert.deepEqual(parser.parseUsernameAndMessage('[rank] Alex » hello » world'),
{username: 'Alex', message: 'hello » world'});
});
test('logs retain inherited hover, click and insertion without merging different events', () => {
const result = parser.toLog({text: '', hover_event: {action: 'show_text', contents: {
translate: 'chat.type.text', with: ['Alex', 'tooltip'],
}}, click_event: {action: 'suggest_command', command: '/msg Alex '}, insertion: 'Alex', extra: [
{text: 'A'}, {text: 'B', hoverEvent: {action: 'show_item', contents: {id: 'minecraft:stone', count: 1}}},
{text: 'C', click_event: {action: 'copy_to_clipboard', text: 'copied'}},
]});
assert.equal(result.length, 3);
assert.deepEqual(result[0].hoverEvent, {action: 'show_text', value: '<Alex> tooltip', segments: [{message: '<Alex> tooltip', font_color: '#FFFFFF', font_style: ['normal']}]});
assert.deepEqual(result[0].clickEvent, {action: 'suggest_command', value: '/msg Alex '});
assert.equal(result[0].insertion, 'Alex');
assert.deepEqual(JSON.parse(result[1].hoverEvent.value), {id: 'minecraft:stone', count: 1});
assert.deepEqual(result[2].clickEvent, {action: 'copy_to_clipboard', value: 'copied'});
});
test('NBT disconnect reasons unwrap compound, list and string tags', () => {
const reason = {type: 'compound', value: {
text: {type: 'string', value: '登录失败: '},
color: {type: 'string', value: 'red'},
extra: {type: 'list', value: {type: 'compound', value: [
{text: {type: 'string', value: '连接超时'}, bold: {type: 'byte', value: 1}},
]}},
}};
assert.equal(parser.extractPlainText(reason), '登录失败: 连接超时');
assert.equal(parser.toLog(reason)[1].font_color, '#FF5555');
assert.deepEqual(parser.toLog(reason)[1].font_style, ['bold']);
assert.equal(parser.extractPlainText({type: 'string', value: '服务器已关闭'}), '服务器已关闭');
});
test('hover text preserves inherited colors and formats without recursive events', () => {
const result = parser.toLog({text: 'player', hoverEvent: {action: 'show_text', contents: {
text: '称号 ', color: 'gold', bold: true, extra: [
{text: 'Alex', color: 'aqua', bold: false, italic: true, hoverEvent: {action: 'show_text', value: 'nested'}, clickEvent: {action: 'run_command', value: '/nested'}},
],
}}});
assert.equal(result[0].hoverEvent.value, '称号 Alex');
assert.deepEqual(result[0].hoverEvent.segments, [
{message: '称号 ', font_color: '#FFAA00', font_style: ['bold']},
{message: 'Alex', font_color: '#55FFFF', font_style: ['italic']},
]);
const long = parser.toLog({text: 'player', hoverEvent: {action: 'show_text', contents: {text: 'x'.repeat(5000)}}});
assert.equal(long[0].hoverEvent.segments[0].message.length, 4096);
});
+857
View File
@@ -0,0 +1,857 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {setImmediate} from 'node:timers/promises';
import vm from 'node:vm';
import test from 'node:test';
import {transformSync} from 'esbuild';
import {Buffer} from 'node:buffer';
const source = await readFile(new URL('../index.ts', import.meta.url), 'utf8');
const code = transformSync(source.replace(/^import .*;\n/gm, ''), {loader: 'ts'}).code;
const configured = JSON.parse(await readFile(new URL('../config.json', import.meta.url), 'utf8'));
const relay_test_key = 'node-registration-test-secret-not-production';
const success = node_session_id => ({status: 'success', data: {name: 'Node', node_session_id}});
const deferred = () => {
let resolve, reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return {promise, resolve, reject};
};
async function setup({forward = false, start, plugins, functions, workflow_catalog, versions, status, init, connect_socket, stop, contexts = [], stop_bot,
node = configured.node, auto_reconnect = {...configured.main_server.auto_reconnect, interval: 100}} = {}) {
const handlers = new Map();
const signals = new Map();
const timers = new Map();
const timer_delays = new Map();
const deadlines = new Map();
const exits = [];
const stopped_bots = [];
const requests = [];
const bindings = [];
const logs = [];
const updates = [];
const workflow_reports = [];
const controls = [];
const relay_options = [];
const order = [];
let timer_id = 0;
let any;
let shutting_down = false;
const state = {sockets: 0, connects: 0, disconnects: 0, clears: 0, stops: 0, releases: 0, events: 0};
const socket = {
connected: false,
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(handler);
},
off(event, handler) { handlers.get(event)?.delete(handler); },
onAny(handler) { any = handler; },
connect() { state.connects++; connect_socket?.(); },
disconnect() {
state.disconnects++;
if (!socket.connected) return;
socket.connected = false;
dispatch('disconnect', 'io client disconnect');
},
timeout(ms) { assert.equal(ms, 5000); return socket; },
emitWithAck(event, payload) {
const pending = deferred();
requests.push({event, payload, ...pending});
order.push(event);
return pending.promise;
},
emit() {},
};
function dispatch(event, ...args) {
for (const handler of [...(handlers.get(event) ?? [])]) handler(...args);
}
let release;
const handle = {
bind_control(control) {
release?.();
controls.push(control);
order.push('bind_control');
const listener = (...args) => { state.events++; args.at(-1)?.(); };
control.on('forward_open_session', listener);
let active = true;
release = () => {
if (!active) return;
active = false;
state.releases++;
control.off('forward_open_session', listener);
};
return release;
},
async stop() { state.stops++; release?.(); await stop?.(); order.push('relay_stopped'); },
};
const process = {
once: (name, handler) => signals.set(name, handler),
exit: code => { exits.push(code); order.push('exit'); },
};
const config = {
...configured, node: {...node},
main_server: {...configured.main_server, auto_reconnect},
forward_server: {...configured.forward_server, key: relay_test_key, status: forward},
};
vm.runInNewContext(code, {
Buffer,
config,
io: (_url, options) => {
assert.equal(options.reconnection, false);
state.sockets++;
return socket;
},
init_event_handler_list: async () => { await init?.(); },
get_bot_context_status: async () => contexts,
stop_bot_context: async bot_id => {
stopped_bots.push(bot_id);
await stop_bot?.(bot_id);
order.push(`bot_stopped:${bot_id}`);
},
bind_node_session: (bound_socket, node_id, session) => {
assert.equal(bound_socket, socket);
bindings.push({node_id, session});
},
clear_node_session: bound_socket => {
assert.equal(bound_socket, socket);
state.clears++;
},
update_plugins: async () => { updates.push('plugins'); order.push('plugins'); await plugins?.(); },
update_function_list: async () => { updates.push('functions'); order.push('functions'); await functions?.(); },
update_workflow_catalog: async (bound_socket, node_id, is_current) => {
assert.equal(bound_socket, socket);
assert.equal(node_id, config.node.id);
assert.equal(is_current(), true);
workflow_reports.push({is_current});
updates.push('workflow_catalog');
order.push('workflow_catalog');
await workflow_catalog?.();
},
update_version_list: async () => { updates.push('versions'); order.push('versions'); await versions?.(); },
update_all_bot_status: async () => { updates.push('bots'); order.push('bots'); await status?.(); },
event_handler: async () => { state.events++; },
start_forward_server: async options => {
relay_options.push(options);
order.push('start');
const ready = start ? await start(handle) : handle;
ready.bind_control(options.control);
return ready;
},
console_print: {
log: message => logs.push(message),
info: message => message, error: message => message,
success: message => message, warning: message => message,
},
process,
setTimeout: (callback, delay) => {
if (shutting_down) {
assert.equal(delay, 10_000);
deadlines.set(++timer_id, callback);
return timer_id;
}
const interval = auto_reconnect?.interval;
assert.equal(delay, Number.isInteger(interval) && interval > 0 && interval <= 2_147_483_647 ? interval : 10_000);
timers.set(++timer_id, callback);
timer_delays.set(timer_id, delay);
return timer_id;
},
clearTimeout: id => { timers.delete(id); timer_delays.delete(id); deadlines.delete(id); },
});
await setImmediate();
return {
state, socket, process, config, timers, timer_delays, requests, bindings, logs, updates, workflow_reports, controls,
relay_options, order, handle, dispatch, deadlines, exits, stopped_bots,
connect() { socket.connected = true; dispatch('connect'); },
disconnect() { socket.connected = false; dispatch('disconnect', 'transport close'); },
event(event, ...args) { any(event, ...args); },
signal(name) { shutting_down = true; signals.get(name)(); },
tick() {
assert.equal(timers.size, 1);
const [id, callback] = timers.entries().next().value;
timers.delete(id);
timer_delays.delete(id);
callback();
},
};
}
test('registration binds the exact structured ACK and replaces sessions on reconnect and re-authentication', async () => {
const app = await setup();
app.event('bot_start', {});
assert.equal(app.state.events, 0);
app.connect();
assert.equal(app.state.clears, 1);
assert.equal(app.requests[0].event, 'register_node');
app.requests[0].resolve(success('first-session'));
await setImmediate();
assert.deepEqual(app.bindings, [{node_id: configured.node.id, session: 'first-session'}]);
const stages = ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots'];
assert.deepEqual(app.updates, stages);
assert.deepEqual(app.order, ['register_node', ...stages]);
assert.equal(app.workflow_reports.length, 1);
assert.equal(app.workflow_reports[0].is_current(), true);
app.event('bot_start', {});
assert.equal(app.state.events, 1);
app.disconnect();
assert.equal(app.state.clears, 2);
assert.equal(app.workflow_reports[0].is_current(), false);
app.event('bot_start', {});
assert.equal(app.state.events, 1);
app.tick();
app.connect();
app.requests[1].resolve(success('second-session'));
await setImmediate();
assert.deepEqual(app.updates, [...stages, ...stages]);
assert.equal(app.workflow_reports.length, 2);
assert.equal(app.workflow_reports[1].is_current(), true);
app.connect();
assert.equal(app.state.clears, 4);
app.requests[2].resolve(success('third-session'));
await setImmediate();
assert.deepEqual(app.bindings.map(binding => binding.session), ['first-session', 'second-session', 'third-session']);
assert.deepEqual(app.updates, [...stages, ...stages, ...stages]);
assert.equal(app.workflow_reports.length, 3);
assert.equal(app.workflow_reports[1].is_current(), false);
assert.equal(app.workflow_reports[2].is_current(), true);
});
test('workflow catalog reporting is awaited before versions, Bot status, and relay startup', async () => {
const pending = deferred();
const app = await setup({forward: true, workflow_catalog: () => pending.promise});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog']);
assert.equal(app.workflow_reports.length, 1);
assert.equal(app.relay_options.length, 0);
assert.equal(app.requests.length, 1);
pending.resolve();
await setImmediate();
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots']);
assert.deepEqual(app.order, ['register_node', ...app.updates, 'start', 'bind_control', 'bind_control', 'register_forward']);
assert.equal(app.workflow_reports.length, 1);
app.requests[1].resolve({status: 'success', data: 'Relay'});
await setImmediate();
assert.equal(app.timers.size, 0);
});
test('workflow catalog rejection and ACK timeout retry registration and report again after recovery', async () => {
for (const failure of ['report rejected secret-token', 'ACK timeout secret-token']) {
const pending = deferred();
let calls = 0;
const app = await setup({forward: true, contexts: [{bot_id: 1}],
workflow_catalog: () => ++calls === 1 ? pending.promise : undefined});
app.connect();
app.requests[0].resolve(success('failed-session'));
await setImmediate();
pending.reject(new Error(failure));
await setImmediate();
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog']);
assert.equal(app.relay_options.length, 0);
assert.equal(app.socket.connected, false);
assert.equal(app.state.disconnects, 1);
assert.equal(app.timers.size, 1);
assert.equal(app.workflow_reports[0].is_current(), false);
assert.ok(app.logs.some(message => message.includes('阶段: 工作流目录上报')));
assert.ok(!app.logs.join('\n').includes('secret-token'));
assert.equal(app.state.stops, 0);
assert.deepEqual(app.stopped_bots, []);
assert.deepEqual(app.exits, []);
assert.equal(app.process.exitCode, undefined);
app.tick();
app.connect();
assert.equal(app.requests[1].event, 'register_node');
app.requests[1].resolve(success('recovered-session'));
await setImmediate();
assert.equal(calls, 2);
assert.equal(app.workflow_reports.length, 2);
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog',
'plugins', 'functions', 'workflow_catalog', 'versions', 'bots']);
assert.equal(app.requests[2].event, 'register_forward');
app.requests[2].resolve({status: 'success', data: 'Relay'});
await setImmediate();
assert.equal(app.socket.connected, true);
assert.equal(app.timers.size, 0);
assert.equal(app.state.stops, 0);
assert.deepEqual(app.stopped_bots, []);
assert.deepEqual(app.exits, []);
}
});
test('stale workflow catalog completion or failure cannot continue stages or disconnect a newer session', async () => {
for (const rejected of [false, true]) {
const pending = deferred();
let calls = 0;
const app = await setup({forward: true,
workflow_catalog: () => ++calls === 1 ? pending.promise : undefined});
app.connect();
app.requests[0].resolve(success('old-session'));
await setImmediate();
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog']);
app.disconnect();
app.tick();
app.connect();
app.requests[1].resolve(success('new-session'));
await setImmediate();
app.requests[2].resolve({status: 'success', data: 'Relay'});
await setImmediate();
assert.equal(app.workflow_reports[0].is_current(), false);
assert.equal(app.workflow_reports[1].is_current(), true);
const order = [...app.order];
if (rejected) pending.reject(new Error('ACK timeout secret-token'));
else pending.resolve();
await setImmediate();
assert.deepEqual(app.order, order);
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog',
'plugins', 'functions', 'workflow_catalog', 'versions', 'bots']);
assert.equal(app.workflow_reports.length, 2);
assert.equal(app.socket.connected, true);
assert.equal(app.state.disconnects, 0);
assert.equal(app.timers.size, 0);
assert.deepEqual(app.bindings.map(binding => binding.session), ['old-session', 'new-session']);
assert.equal(app.relay_options.length, 1);
assert.equal(app.requests.length, 3);
assert.equal(app.process.exitCode, undefined);
assert.ok(!app.logs.join('\n').includes('阶段: 工作流目录上报'));
assert.ok(!app.logs.join('\n').includes('secret-token'));
}
});
test('failed relay startup reports a safe reason, retries startup, and registers after recovery', async () => {
let attempts = 0;
const app = await setup({forward: true, contexts: [{bot_id: 1}], start: async handle => {
if (++attempts === 1) throw new Error('A relay secret of at least 16 characters is required');
return handle;
}});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
assert.ok(app.logs.some(message => message.includes('阶段: 转发服务器启动') && message.includes('至少需要 16 个字符')));
assert.ok(!app.logs.some(message => message.includes('节点关闭失败或超时')));
assert.equal(app.state.stops, 0);
assert.equal(app.deadlines.size, 0);
assert.equal(app.process.exitCode, undefined);
assert.equal(app.timers.size, 1);
assert.deepEqual(app.stopped_bots, []);
app.tick();
app.connect();
app.requests[1].resolve(success('recovered-session'));
await setImmediate();
assert.equal(attempts, 2);
assert.equal(app.relay_options.length, 2);
assert.equal(app.requests[2].event, 'register_forward');
app.requests[2].resolve({status: 'success', data: 'Relay'});
await setImmediate();
assert.equal(app.timers.size, 0);
assert.equal(app.state.stops, 0);
assert.deepEqual(app.stopped_bots, []);
assert.deepEqual(app.exits, []);
});
test('relay listen errors disclose only safe error category and initialization stage', async () => {
for (const code of ['EADDRINUSE', 'EACCES']) {
const app = await setup({forward: true, start: async () => {
throw Object.assign(new Error('SECRET config/token/stack'), {code});
}});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
assert.ok(app.logs.some(message => message.includes('阶段: 转发服务器启动')
&& message.includes(code === 'EADDRINUSE' ? '端口已被占用' : '没有权限')));
assert.ok(!app.logs.join('\n').includes('SECRET'));
assert.ok(!app.logs.join('\n').includes('节点关闭失败或超时'));
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
}
});
test('legacy, missing and extra registration fields never authenticate and retry safely', async () => {
for (const response of [
{status: 'success', data: 'legacy name'},
{status: 'success', data: {name: 'Node'}},
{status: 'success', data: {name: 42, node_session_id: 'session'}},
{status: 'success', data: {name: 'Node', node_session_id: ' '}},
success('x'.repeat(257)),
success('\u00e9'.repeat(129)),
{status: 'success', data: {name: 'Node', node_session_id: 'session', token: 'secret-token'}},
{...success('session'), token: 'secret-token'},
]) {
const app = await setup();
app.connect();
app.requests[0].resolve(response);
await setImmediate();
assert.equal(app.bindings.length, 0);
assert.equal(app.updates.length, 0);
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
assert.ok(!app.logs.join('\n').includes('secret-token'));
}
});
test('registration accepts a 256-byte UTF-8 session ID and rejects unsafe configured Node IDs', async () => {
const app = await setup();
app.connect();
app.requests[0].resolve(success('\u00e9'.repeat(128)));
await setImmediate();
assert.equal(app.bindings[0].session, '\u00e9'.repeat(128));
const invalid = await setup({node: {...configured.node, id: Number.MAX_SAFE_INTEGER + 1}});
assert.equal(invalid.state.connects, 0);
assert.equal(invalid.state.sockets, 0);
assert.equal(invalid.requests.length, 0);
assert.equal(invalid.timers.size, 1);
assert.equal(invalid.process.exitCode, undefined);
invalid.config.node.id = configured.node.id;
invalid.tick();
await setImmediate();
assert.equal(invalid.state.sockets, 1);
assert.equal(invalid.state.connects, 1);
});
test('handler loading failures retry bootstrap without creating a socket and recover after success', async () => {
let attempts = 0;
const app = await setup({init: async () => {
if (++attempts <= 3) throw new Error('secret-token');
}});
for (let attempt = 1; attempt <= 3; attempt++) {
assert.equal(attempts, attempt);
assert.equal(app.state.sockets, 0);
assert.equal(app.state.connects, 0);
assert.equal(app.requests.length, 0);
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
app.tick();
await setImmediate();
}
assert.equal(attempts, 4);
assert.equal(app.state.sockets, 1);
assert.equal(app.state.connects, 1);
assert.equal(app.timers.size, 0);
assert.deepEqual(app.exits, []);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
test('missing or invalid reconnect settings use the default interval and keep retrying', async () => {
for (const auto_reconnect of [null, {}, {interval: 0}, {interval: -1}, {interval: 1.5},
{interval: '100'}, {interval: 2_147_483_648}, {enabled: false, max_attempts: -1, interval: 250}]) {
const app = await setup({auto_reconnect});
assert.equal(app.state.sockets, 1);
assert.equal(app.state.connects, 1);
app.dispatch('connect_error', new Error('secret-token'));
assert.deepEqual([...app.timer_delays.values()], [auto_reconnect?.interval === 250 ? 250 : 10_000]);
app.tick();
app.dispatch('connect_error', new Error('secret-token'));
assert.equal(app.state.connects, 2);
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
assert.deepEqual(app.exits, []);
}
});
test('synchronous initial and recurring connect failures keep retrying until connection succeeds', async () => {
let attempts = 0;
const app = await setup({auto_reconnect: {enabled: false, max_attempts: 0, interval: 100},
connect_socket: () => {
if (++attempts <= 4) throw new Error('secret-token');
}});
for (let attempt = 1; attempt <= 4; attempt++) {
assert.equal(app.state.connects, attempt);
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
app.tick();
}
assert.equal(app.state.connects, 5);
assert.equal(app.state.sockets, 1);
assert.equal(app.timers.size, 0);
app.connect();
app.requests[0].resolve(success('recovered-session'));
await setImmediate();
assert.deepEqual(app.bindings.map(binding => binding.session), ['recovered-session']);
assert.deepEqual(app.exits, []);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
test('plugin, function, version, and Bot status failures retry initialization and recover without stopping Bots', async () => {
for (const stage of ['plugins', 'functions', 'versions', 'status']) {
let calls = 0;
const app = await setup({contexts: [{bot_id: 1}], [stage]: async () => {
if (++calls === 1) throw new Error('secret-token');
}});
app.connect();
app.requests[0].resolve(success('failed-session'));
await setImmediate();
assert.equal(app.socket.connected, false);
assert.equal(app.timers.size, 1);
assert.equal(app.state.stops, 0);
assert.deepEqual(app.stopped_bots, []);
assert.equal(app.process.exitCode, undefined);
assert.ok(!app.logs.join('\n').includes('secret-token'));
app.event('bot_start', {});
assert.equal(app.state.events, 0);
app.tick();
app.connect();
app.requests[1].resolve(success('recovered-session'));
await setImmediate();
assert.equal(calls, 2);
assert.deepEqual(app.updates.slice(-5), ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots']);
assert.equal(app.timers.size, 0);
assert.equal(app.socket.connected, true);
app.event('bot_start', {});
assert.equal(app.state.events, 1);
assert.deepEqual(app.stopped_bots, []);
assert.deepEqual(app.exits, []);
}
});
test('node registration refusals and ACK timeouts continue retrying beyond disabled and exhausted settings', async () => {
const app = await setup({contexts: [{bot_id: 1}],
auto_reconnect: {enabled: false, max_attempts: 1, interval: 100}});
for (let attempt = 0; attempt < 6; attempt++) {
app.connect();
const request = app.requests[attempt];
assert.equal(request.event, 'register_node');
if (attempt % 2) request.reject(new Error('ACK timeout secret-token'));
else request.resolve({status: 'error', data: 401});
await setImmediate();
assert.equal(app.bindings.length, 0);
assert.equal(app.updates.length, 0);
assert.equal(app.timers.size, 1);
assert.equal(app.process.exitCode, undefined);
assert.deepEqual(app.stopped_bots, []);
app.tick();
}
app.connect();
app.requests[6].resolve(success('recovered-session'));
await setImmediate();
assert.deepEqual(app.bindings.map(binding => binding.session), ['recovered-session']);
assert.equal(app.timers.size, 0);
assert.deepEqual(app.exits, []);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
test('stale registration ACKs and failures cannot replace a newer session or terminate it', async () => {
for (const rejected of [false, true]) {
const app = await setup();
app.connect();
app.disconnect();
app.tick();
app.connect();
app.requests[1].resolve(success('current-session'));
await setImmediate();
if (rejected) app.requests[0].reject(new Error('secret-token'));
else app.requests[0].resolve(success('obsolete-session'));
await setImmediate();
assert.deepEqual(app.bindings.map(binding => binding.session), ['current-session']);
assert.equal(app.state.disconnects, 0);
assert.equal(app.process.exitCode, undefined);
assert.ok(!app.logs.join('\n').includes('secret-token'));
}
});
test('registration refusal and transport errors do not log response objects, URLs, or tokens', async () => {
const app = await setup();
const secret = 'https://private.example/?token=secret-token';
app.dispatch('connect_error', new Error(secret));
app.dispatch('error', {error: secret});
app.connect();
app.requests[0].resolve({status: 'error', data: {token: secret}});
await setImmediate();
assert.equal(app.process.exitCode, undefined);
assert.equal(app.timers.size, 1);
assert.equal(app.bindings.length, 0);
assert.ok(!app.logs.join('\n').includes(secret));
assert.ok(!app.logs.join('\n').includes(configured.main_server.host));
});
test('hosted relay starts once, registers only when ready, rebinds control, and accepts other target Nodes', async () => {
const ready = deferred();
const app = await setup({forward: true, start: () => ready.promise});
app.connect();
app.requests[0].resolve(success('host-session'));
await setImmediate();
assert.equal(app.requests.length, 1);
assert.equal(app.relay_options.length, 1);
const options = app.relay_options[0];
assert.equal(options.forward_id, configured.forward_server.id);
assert.equal(options.key, relay_test_key);
assert.ok(options.key.length >= 32);
assert.deepEqual(options.allowed_origins, configured.forward_server.allowed_origins);
assert.equal(options.path, configured.forward_server.path);
assert.equal(options.namespace, configured.forward_server.namespace);
assert.equal(options.http_server, undefined);
ready.resolve(app.handle);
await setImmediate();
assert.equal(app.requests[1].event, 'register_forward');
assert.ok(app.order.indexOf('bind_control') < app.order.indexOf('register_forward'));
const stale_control_callback = app.controls.at(-1);
const listener = () => app.state.events++;
stale_control_callback.on('test_control', listener);
app.dispatch('forward_open_session', {node_id: 900, node_session_id: 'other-node-session'}, () => {});
assert.equal(app.state.events, 1);
app.requests[1].resolve({status: 'success', data: 'Relay'});
await setImmediate();
app.disconnect();
app.dispatch('test_control');
assert.equal(app.state.events, 1);
app.tick();
app.connect();
app.requests[2].resolve(success('host-session-2'));
await setImmediate();
app.dispatch('test_control');
assert.equal(app.state.events, 1);
assert.equal(app.relay_options.length, 1);
assert.equal(app.requests[3].event, 'register_forward');
app.dispatch('forward_open_session', {node_id: 901, node_session_id: 'third-node-session'}, () => {});
assert.equal(app.state.events, 2);
stale_control_callback.off('test_control', listener);
app.signal('SIGINT');
await setImmediate();
assert.equal(app.state.stops, 1);
assert.equal(app.process.exitCode, 0);
});
test('disconnect during relay startup does not duplicate startup or register the stale connection', async () => {
const ready = deferred();
const app = await setup({forward: true, start: () => ready.promise});
app.connect();
app.requests[0].resolve(success('old-session'));
await setImmediate();
app.disconnect();
app.tick();
app.connect();
app.requests[1].resolve(success('new-session'));
await setImmediate();
ready.resolve(app.handle);
await setImmediate();
assert.equal(app.relay_options.length, 1);
assert.equal(app.requests.filter(request => request.event === 'register_forward').length, 1);
assert.equal(app.state.stops, 0);
});
test('SIGTERM waits for pending relay startup, stops its handle, and never registers', async () => {
const ready = deferred();
const app = await setup({forward: true, start: () => ready.promise});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
app.signal('SIGTERM');
assert.deepEqual(app.exits, []);
ready.resolve(app.handle);
await setImmediate();
assert.equal(app.state.stops, 1);
assert.equal(app.requests.length, 1);
assert.equal(app.process.exitCode, 0);
assert.equal(app.timers.size, 0);
assert.deepEqual(app.exits, [0]);
app.signal('SIGINT');
await setImmediate();
assert.equal(app.state.stops, 1);
assert.deepEqual(app.exits, [0]);
});
test('explicit signals await relay and all BotContext cleanup before one controlled exit', async () => {
for (const signal of ['SIGINT', 'SIGTERM']) {
const relay_stopped = deferred();
const bots_stopped = deferred();
const app = await setup({
forward: true, stop: () => relay_stopped.promise,
contexts: [{bot_id: 1}, {bot_id: 2}], stop_bot: () => bots_stopped.promise,
});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
app.signal(signal);
await setImmediate();
assert.deepEqual(app.stopped_bots, [1, 2]);
assert.equal(app.state.stops, 1);
assert.deepEqual(app.exits, []);
bots_stopped.resolve();
await setImmediate();
assert.deepEqual(app.exits, []);
relay_stopped.resolve();
await setImmediate();
assert.deepEqual(app.exits, [0]);
assert.ok(app.order.indexOf('relay_stopped') < app.order.indexOf('exit'));
assert.equal(app.deadlines.size, 0);
}
});
test('signal shutdown has a bounded deadline even if relay startup or Bot cleanup never settles', async () => {
const never = deferred();
const app = await setup({
forward: true, start: () => never.promise,
contexts: [{bot_id: 1}], stop_bot: () => never.promise,
});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
app.signal('SIGTERM');
await setImmediate();
assert.deepEqual(app.exits, []);
assert.deepEqual(app.stopped_bots, [1]);
assert.equal(app.deadlines.size, 1);
[...app.deadlines.values()][0]();
await setImmediate();
assert.deepEqual(app.exits, [1]);
assert.equal(app.deadlines.size, 0);
assert.equal(app.requests.length, 1);
});
test('signal cleanup failures wait for remaining cleanup and exit without leaking error contents', async () => {
const bots_stopped = deferred();
const app = await setup({
forward: true, stop: async () => { throw new Error('secret-token'); },
contexts: [{bot_id: 1}], stop_bot: () => bots_stopped.promise,
});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
app.signal('SIGINT');
await setImmediate();
assert.deepEqual(app.exits, []);
bots_stopped.resolve();
await setImmediate();
assert.deepEqual(app.exits, [1]);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
test('relay registration errors and ACK timeouts retry full registration with the same relay and Bots', async () => {
for (const failure of [486, {token: 'secret-token'}, 'timeout']) {
const app = await setup({forward: true, contexts: [{bot_id: 1}, {bot_id: 2}]});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
if (failure === 'timeout') app.requests[1].reject(new Error('ACK timeout secret-token'));
else app.requests[1].resolve({status: 'error', data: failure});
await setImmediate();
assert.equal(app.state.stops, 0);
assert.equal(app.socket.connected, false);
assert.equal(app.state.disconnects, 1);
assert.ok(app.state.clears >= 2);
assert.equal(app.process.exitCode, undefined);
assert.equal(app.timers.size, 1);
assert.deepEqual(app.stopped_bots, []);
assert.deepEqual(app.exits, []);
assert.ok(!app.logs.join('\n').includes('secret-token'));
app.tick();
app.connect();
assert.equal(app.requests[2].event, 'register_node');
app.requests[2].resolve(success('recovered-session'));
await setImmediate();
assert.equal(app.requests[3].event, 'register_forward');
assert.equal(app.relay_options.length, 1);
app.requests[3].resolve({status: 'success', data: 'Relay'});
await setImmediate();
assert.deepEqual(app.updates, ['plugins', 'functions', 'workflow_catalog', 'versions', 'bots',
'plugins', 'functions', 'workflow_catalog', 'versions', 'bots']);
assert.equal(app.timers.size, 0);
assert.equal(app.state.stops, 0);
assert.deepEqual(app.stopped_bots, []);
assert.equal(app.process.exitCode, undefined);
}
});
test('recurring errors keep retrying past disabled and exhausted settings without stopping relay or Bots', async () => {
const app = await setup({forward: true, contexts: [{bot_id: 5}, {bot_id: 6}],
auto_reconnect: {enabled: false, max_attempts: 1, interval: 100}});
app.connect();
app.requests[0].resolve(success('session'));
await setImmediate();
app.requests[1].resolve({status: 'success', data: 'Relay'});
await setImmediate();
app.disconnect();
for (let attempt = 0; attempt < 12; attempt++) {
app.tick();
app.dispatch('connect_error', new Error('secret-token'));
assert.equal(app.timers.size, 1);
}
await setImmediate();
assert.equal(app.state.stops, 0);
assert.equal(app.process.exitCode, undefined);
assert.equal(app.timers.size, 1);
assert.deepEqual(app.stopped_bots, []);
assert.deepEqual(app.exits, []);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
test('retry attempt count resets only after initialization and relay registration fully succeed', async () => {
const app = await setup({forward: true, auto_reconnect: {enabled: false, max_attempts: 1, interval: 100}});
for (let attempt = 0; attempt < 4; attempt++) {
app.connect();
app.requests[attempt * 2].resolve(success(`session-${attempt}`));
await setImmediate();
app.requests[attempt * 2 + 1].resolve(attempt < 3
? {status: 'error', data: 486}
: {status: 'success', data: 'Relay'});
await setImmediate();
if (attempt < 3) app.tick();
}
app.disconnect();
app.tick();
const attempts = app.logs.flatMap(message => {
const match = message.match(/正在尝试重连主服务器,第 (\d+) 次/);
return match ? [Number(match[1])] : [];
});
assert.deepEqual(attempts, [1, 2, 3, 1]);
assert.equal(app.relay_options.length, 1);
assert.equal(app.state.stops, 0);
assert.equal(app.process.exitCode, undefined);
});
test('signals cancel a pending retry and later connection callbacks cannot restart the Node', async () => {
for (const signal of ['SIGINT', 'SIGTERM']) {
const app = await setup({contexts: [{bot_id: 1}]});
app.dispatch('connect_error', new Error('secret-token'));
assert.equal(app.timers.size, 1);
app.signal(signal);
await setImmediate();
assert.equal(app.timers.size, 0);
assert.deepEqual(app.stopped_bots, [1]);
assert.deepEqual(app.exits, [0]);
app.dispatch('connect_error', new Error('secret-token'));
app.dispatch('error', new Error('secret-token'));
app.dispatch('disconnect', 'io client disconnect');
app.connect();
await setImmediate();
assert.equal(app.timers.size, 0);
assert.equal(app.requests.length, 0);
assert.equal(app.state.connects, 1);
assert.deepEqual(app.exits, [0]);
}
});
test('stale relay registration failures cannot stop a newly authenticated connection', async () => {
const app = await setup({forward: true});
app.connect();
app.requests[0].resolve(success('old-session'));
await setImmediate();
app.disconnect();
app.tick();
app.connect();
app.requests[2].resolve(success('new-session'));
await setImmediate();
app.requests[1].reject(new Error('secret-token'));
await setImmediate();
assert.equal(app.state.stops, 0);
assert.equal(app.process.exitCode, undefined);
assert.equal(app.relay_options.length, 1);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
test('obsolete initialization failures cannot stop a re-authenticated connection', async () => {
const pending = deferred();
let calls = 0;
const app = await setup({plugins: () => ++calls === 1 ? pending.promise : undefined});
app.connect();
app.requests[0].resolve(success('old-session'));
await setImmediate();
app.disconnect();
app.tick();
app.connect();
app.requests[1].resolve(success('new-session'));
await setImmediate();
pending.reject(new Error('secret-token'));
await setImmediate();
assert.equal(app.state.disconnects, 0);
assert.equal(app.process.exitCode, undefined);
assert.ok(!app.logs.join('\n').includes('secret-token'));
});
+139
View File
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import {build} from 'esbuild';
import {tsImport} from 'tsx/esm/api';
const root = fileURLToPath(new URL('../', import.meta.url));
const compiled = await build({
stdin: {contents: `export {handler as inject} from './event_handler/bot/inject_plugin.ts';
export {handler as uninject} from './event_handler/bot/uninject_plugin.ts';
export {handler as update} from './event_handler/bot/update_plugin_config.ts';
export {PluginManager, PluginError} from './bot/plugins/init.ts';
export {validate_code_map as codes} from './utils/validate_code_map.ts';
export * from './event_handler/init.ts';`, resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
define: {'import.meta.url': JSON.stringify(new URL('../event_handler/init.ts', import.meta.url).href)},
plugins: [{name: 'context', setup(build) {
build.onResolve({filter: /bot\/instance(?:\/index\.ts)?$/}, () => ({path: 'context', namespace: 'mock'}));
build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({contents: 'export const get_bot_context = id => globalThis.lookup(id);'}));
}}],
});
function setup() {
let context;
const calls = [], emitted = [];
const module = {exports: {}};
vm.runInNewContext(compiled.outputFiles[0].text, {
module, exports: module.exports, require: createRequire(import.meta.url), URL, process, structuredClone,
lookup(id) { calls.push(['lookup', id]); return context; },
console: {log() {}, error() { throw new Error('Unexpected logging'); }},
});
const api = module.exports;
const manager = Object.fromEntries(['inject', 'uninject', 'update_config'].map(name => [name, async (...args) => {
calls.push([name, ...args]);
}]));
context = {bot_status: 'offline', plugin_manager: manager};
return {...api, calls, manager, socket: {emit: (...args) => emitted.push(args)}, emitted,
context(value) { context = value; },
async run(name, data) {
const acks = [];
await api[name](this.socket, data, result => acks.push(structuredClone(result)));
assert.equal(acks.length, 1); return acks[0];
}};
}
const dto = operation => operation === 'inject' ? {bot_id: 1, path: 'runtime', name: 'auto_reconnect', config: {}}
: operation === 'update' ? {bot_id: 1, name: 'auto_reconnect', config: {}} : {bot_id: 1, name: 'auto_reconnect'};
test('missing ACK emits method_not_allowed and never inspects DTO or context', async () => {
const s = setup();
const data = new Proxy({}, {get() { throw new Error('must not read'); }});
for (const name of ['inject', 'uninject', 'update']) {
for (const ack of [undefined, null, {}, 1]) await s[name](s.socket, data, ack);
}
assert.equal(s.calls.length, 0);
assert.equal(s.emitted.length, 12);
for (const [event, payload] of s.emitted) {
assert.equal(event, 'error'); assert.equal(payload.error, s.codes.web.method_not_allowed);
}
});
test('exact ordinary DTO validation rejects malformed requests without getters or side effects', async () => {
const s = setup();
for (const name of ['inject', 'uninject', 'update']) {
const good = dto(name);
const getter = Object.defineProperty({...good}, 'bot_id', {enumerable: true, get() { throw new Error('getter secret'); }});
const malformed = [null, [], {}, {...good, extra: true}, {...good, bot_id: 0}, {...good, bot_id: 1.5},
{...good, bot_id: Number.MAX_SAFE_INTEGER + 1}, {...good, name: '../bad'},
Object.assign(Object.create({inherited: true}), good), Object.assign(Object.create(null), good), getter,
new Proxy(good, {}), {...good, [Symbol('extra')]: 1},
Object.assign(Object.create(Object.assign(Object.create(null), {constructor: Object})), good),
Object.assign(Object.create(new Proxy({}, {getOwnPropertyDescriptor() { throw new Error('prototype trap'); }})), good)];
if (name !== 'uninject') for (const config of [null, [], new Date(), {bad: undefined}, {bad: Infinity},
Object.defineProperty({}, 'secret', {enumerable: true, get() { throw new Error('secret'); }}),
{bad: Object.create({})}, {bad: Array(2)}, {bad: new Proxy([], {})}]) malformed.push({...good, config});
if (name === 'inject') for (const path of ['', '../runtime', '/runtime', 'a//b', 'a\\b', 'a.ts', 'a/%2e']) malformed.push({...good, path});
for (const data of malformed) assert.deepEqual(await s.run(name, data), {status: 'error', data: s.codes.web.invalid_parameter});
}
assert.deepEqual(s.calls, []);
});
test('offline operations route correct arguments and callback throws never retry', async () => {
const s = setup();
for (const name of ['inject', 'uninject', 'update']) {
assert.deepEqual(await s.run(name, dto(name)), {status: 'success', data: 0});
let count = 0;
await s[name](s.socket, dto(name), () => { count++; throw new Error('callback secret'); });
assert.equal(count, 1);
}
assert.deepEqual(structuredClone(s.calls[1]), ['inject', {path: 'runtime', name: 'auto_reconnect', config: {}}]);
assert.deepEqual(structuredClone(s.calls[5]), ['uninject', 'auto_reconnect']);
assert.deepEqual(structuredClone(s.calls[9]), ['update_config', 'auto_reconnect', {}]);
});
test('missing context, manager and typed or unexpected failures produce safe numeric errors', async () => {
const s = setup();
s.context(undefined);
assert.equal((await s.run('inject', dto('inject'))).data, s.codes.plugin.context_missing);
s.context({});
assert.equal((await s.run('inject', dto('inject'))).data, s.codes.plugin.manager_missing);
s.context({plugin_manager: s.manager});
for (const reason of ['INVALID_CONFIG', 'ALREADY_INSTALLED', 'NOT_INSTALLED', 'MANAGER_DISPOSED', 'LOAD_FAILED', 'INIT_FAILED', 'DISPOSE_FAILED']) {
for (const [name, method] of [['inject', 'inject'], ['uninject', 'uninject'], ['update', 'update_config']]) {
s.manager[method] = async () => { throw new s.PluginError(reason); };
assert.deepEqual(await s.run(name, dto(name)), {status: 'error', data: s.codes.plugin[reason.toLowerCase()]});
}
}
s.manager.inject = async () => { throw new Error('password=secret'); };
assert.deepEqual(await s.run('inject', dto('inject')), {status: 'error', data: s.codes.plugin.error});
});
test('handlers replace real offline plugin config using defaults and allow idempotent removal', async () => {
const s = setup();
const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url);
const listeners = new Map();
const context = {bot_status: 'offline', on(event, fn) { listeners.set(event, fn); },
off(event) { listeners.delete(event); }, cancel_reconnect() {}};
context.plugin_manager = new PluginManager(context);
s.context(context);
assert.equal((await s.run('inject', {...dto('inject'), config: {reconnect_delay: 20, reconnect_max_attempts: 2}})).status, 'success');
assert.equal((await s.run('update', {...dto('update'), config: {reconnect_delay: 50}})).status, 'success');
assert.deepEqual(context.plugin_manager.get_plugin('auto_reconnect').config, {reconnect_delay: 50, reconnect_max_attempts: 10});
assert.equal(listeners.size, 4);
for (let i = 0; i < 2; i++) assert.equal((await s.run('uninject', dto('uninject'))).status, 'success');
assert.equal(listeners.size, 0);
await context.plugin_manager.dispose();
});
test('dynamic dispatcher loads and invokes all three plugin handler exports', async () => {
const {init_event_handler_list, event_handler} = await tsImport('../event_handler/init.ts', import.meta.url);
await init_event_handler_list();
const emitted = [];
for (const name of ['inject_plugin', 'uninject_plugin', 'update_plugin_config']) {
await event_handler({emit: (...args) => emitted.push(args)}, name, null);
}
assert.equal(emitted.length, 3);
assert.deepEqual(emitted, Array.from({length: 3}, () => ['error', {error: 481}]));
});
+387
View File
@@ -0,0 +1,387 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import vm from 'node:vm';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
import {build} from 'esbuild';
import {tsImport} from 'tsx/esm/api';
const url = new URL('../bot/plugins/init.ts', import.meta.url);
const compiled = await build({
stdin: {contents: (await readFile(url, 'utf8')).replace('return import(url.href);', 'return globalThis.load(url);'), loader: 'ts', resolveDir: new URL('../bot/plugins/', import.meta.url).pathname},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
define: {'import.meta.url': JSON.stringify(url.href)},
});
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const i18n = [{lang: 'en', name: 'Test', description: ''}];
const item = (name = 'a', config = {}) => ({path: 'runtime', name, config});
function setup() {
const events = [];
const modules = new Map();
const options = {};
const module = {exports: {}};
const load = async url => {
const name = url.pathname.split('/').at(-1).slice(0, -3);
if (options.load_gate) await options.load_gate;
if (name === 'define' && options.directories) return {define: {path: url.pathname.split('/').at(-2), i18n}};
return modules.get(name) ?? {init: plugin(name)};
};
function plugin(name, fields = []) {
return class {
name = name;
config_template = {name, i18n, version: '1', config: fields};
status = {count: 0};
listeners = new Set();
on_states_update(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
async init(_context, config) {
events.push(`init:${name}`);
this.status.config = config;
if (options.init_gate) await options.init_gate;
if (options.fail === name) throw new Error('install failed');
}
get_status() { return this.status; }
async dispose() {
events.push(`dispose:${name}`);
if (options.dispose_fail === name) throw new Error('cleanup failed');
}
};
}
vm.runInNewContext(compiled.outputFiles[0].text, {
module, exports: module.exports, require: name => name === 'node:fs/promises'
? {readdir: async (url, config) => options.directories
? options.directories[url.pathname.split('/').filter(Boolean).at(-1)] ?? []
: createRequire(import.meta.url)(name).readdir(url, config)}
: createRequire(import.meta.url)(name), load, URL, process, structuredClone,
});
return {...module.exports, events, modules, options, plugin, manager: new module.exports.PluginManager({})};
}
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
test('update config looks up installed path inside queue and replaces with template defaults', async () => {
const {manager, modules, plugin, events, options} = setup();
const fields = ['x', 'y'].map(name => ({name, type: 'number', default: 1, required: true, i18n}));
modules.set('a', {init: plugin('a', fields)});
const gate = deferred(); options.init_gate = gate.promise;
const injection = manager.inject({...item('a', {x: 9, y: 8}), path: 'nested/category'});
const update = manager.update_config('a', {x: 3});
await flush(); assert.deepEqual(events, ['init:a']);
gate.resolve(); await injection; await update;
assert.deepEqual(manager.get_plugin('a').config, {x: 3, y: 1});
assert.equal(manager.get_plugin('a').path, 'nested/category');
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a']);
await assert.rejects(manager.update_config('a', {unknown: 'secret'}), {reason: 'INVALID_CONFIG'});
assert.deepEqual(manager.get_plugin('a').config, {x: 3, y: 1});
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a']);
await manager.uninject('a');
await assert.rejects(manager.update_config('a', {}), {reason: 'NOT_INSTALLED'});
await manager.uninject('a');
await manager.dispose();
await assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'});
});
test('queued update follows reinjection path and cannot resurrect a disposed context', async () => {
const {manager, options, events} = setup();
await manager.inject(item());
const gate = deferred(); options.load_gate = gate.promise;
const replacement = manager.re_inject({...item(), path: 'new/category'});
const update = manager.update_config('a', {});
gate.resolve(); await replacement; await update;
assert.equal(manager.get_plugin('a').path, 'new/category');
const blocked = deferred(); options.load_gate = blocked.promise;
const pending = assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'});
const queued = assert.rejects(manager.update_config('a', {}), {reason: 'MANAGER_DISPOSED'});
await flush(); const disposal = manager.dispose();
blocked.resolve(); await pending; await queued; await disposal;
assert.equal(manager.get_plugin('a').lifecycle, 'disabled');
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a', 'dispose:a', 'init:a', 'dispose:a']);
});
test('initialization and concurrent mutations share a queue; snapshots are detached', async () => {
const {manager, events, options} = setup();
const gate = deferred(); options.init_gate = gate.promise;
const initial = manager.init([item()]);
const second = manager.inject(item('b'));
await flush(); assert.deepEqual(events, ['init:a']);
gate.resolve(); await initial; await second;
const snapshot = manager.get_plugin('a'); snapshot.states.count = 99; snapshot.config.extra = true;
assert.equal(manager.get_plugin('a').states.count, 0);
assert.equal(manager.get_plugin('a').config.extra, undefined);
await assert.rejects(manager.inject(item()), /Duplicate/);
await manager.dispose();
assert.deepEqual(events, ['init:a', 'init:b', 'dispose:b', 'dispose:a']);
});
test('creation failure cleans partial candidate then rolls installed plugins back in reverse', async () => {
const {create_plugin_manager, options, events} = setup(); options.fail = 'c';
await assert.rejects(create_plugin_manager({}, [item('a'), item('b'), item('c')]), {reason: 'INIT_FAILED'});
assert.deepEqual(events, ['init:a', 'init:b', 'init:c', 'dispose:c', 'dispose:b', 'dispose:a']);
});
test('duplicate initial names fail before installation', async () => {
const {manager, events} = setup();
await assert.rejects(manager.init([item(), {...item(), path: 'other'}]), /Duplicate/);
assert.deepEqual(events, []);
});
test('replacement validates before old disposal; install failure retains error record', async () => {
const {manager, events, options, modules} = setup();
await manager.inject(item());
await assert.rejects(manager.re_inject(item('a', {unknown: true})), {reason: 'INVALID_CONFIG'});
assert.ok(manager.get_plugin('a')); assert.deepEqual(events, ['init:a']);
modules.set('a', {init: 1});
await assert.rejects(manager.re_inject(item()), {reason: 'LOAD_FAILED'});
assert.ok(manager.get_plugin('a'));
modules.clear(); options.fail = 'a';
await assert.rejects(manager.re_inject(item()), {reason: 'INIT_FAILED'});
assert.equal(manager.get_plugin('a').lifecycle, 'error');
assert.equal(manager.get_plugin('a').enabled, false);
assert.deepEqual(events, ['init:a', 'dispose:a', 'init:a', 'dispose:a']);
});
test('dispose during module load blocks initial, queued and new work', async () => {
const {manager, options, events} = setup();
const gate = deferred(); options.load_gate = gate.promise;
const first = assert.rejects(manager.inject(item()), /disposed/);
const queued = assert.rejects(manager.inject(item('b')), /disposed/);
await flush(); const disposal = manager.dispose();
assert.equal(manager.dispose(), disposal);
await assert.rejects(manager.inject(item('c')), /disposed/);
gate.resolve(); await first; await queued; await disposal;
assert.deepEqual(events, []);
});
test('dispose waits for in-flight init and cleans candidate; cleanup errors do not skip earlier plugins', async () => {
const {manager, options, events} = setup();
await manager.init([item('a'), item('b')]);
const gate = deferred(); options.init_gate = gate.promise; options.dispose_fail = 'b';
const pending = assert.rejects(manager.inject(item('c')), /disposed/);
await flush(); const disposal = manager.dispose();
const rejected = assert.rejects(disposal, /disposal failed/);
gate.resolve(); await pending; await rejected;
assert.deepEqual(events, ['init:a', 'init:b', 'init:c', 'dispose:c', 'dispose:b', 'dispose:a']);
assert.equal(manager.get_all_plugins().length, 3);
assert.ok(manager.get_all_plugins().every(record => !record.enabled));
});
test('validates all data types, defaults, optional nulls, required values and metadata', async () => {
const {manager, modules, plugin} = setup();
const values = {string: 'x', boolean: false, number: 3, datetime: '2026-10-20T12:00:00Z',
'string-select': 'x', 'number-select': 1, 'string-multi-select': ['x'], 'number-multi-select': [1],
'string-array': ['x'], 'number-array': [1]};
const fields = Object.entries(values).map(([type, value], index) => ({name: `f${index}`, type, default: value, required: true, i18n}));
fields.push({name: 'optional', type: 'string', default: null, required: false, i18n});
modules.set('a', {init: plugin('a', fields)});
await manager.inject(item());
assert.deepEqual(Object.values(manager.get_plugin('a').config), [...Object.values(values), null]);
for (const field of fields.slice(0, -1)) {
for (const bad of [null, {}, undefined]) await assert.rejects(manager.re_inject(item('a', {[field.name]: bad})), /Invalid plugin config/);
}
for (const bad of [NaN, Infinity, '1', [1]]) await assert.rejects(manager.re_inject(item('a', {f2: bad})), /Invalid plugin config/);
for (const bad of [[1], ['x', 1], Array(1)]) await assert.rejects(manager.re_inject(item('a', {f8: bad})), /Invalid plugin config/);
assert.ok(manager.get_plugin('a'));
fields[0].default = 1;
await assert.rejects(manager.re_inject(item()), {reason: 'LOAD_FAILED'});
await manager.dispose();
});
test('standalone scanner preserves wire metadata and auto reconnect defaults', async () => {
const {get_plugin_list, create_plugin_manager} = await tsImport('../bot/plugins/init.ts', import.meta.url);
const list = await get_plugin_list();
const reconnect = list.find(item => item.name === 'auto_reconnect');
assert.deepEqual(Object.keys(reconnect).sort(), ['name', 'path', 'path_config', 'plugin_config_template']);
const listeners = new Map();
const context = {on(event, fn) { listeners.set(event, fn); }, off(event) { listeners.delete(event); }, cancel_reconnect() {}};
const manager = await create_plugin_manager(context, [item('auto_reconnect')]);
assert.deepEqual(manager.get_plugin('auto_reconnect').config, {reconnect_delay: 1000, reconnect_max_attempts: 10});
await manager.dispose(); assert.equal(listeners.size, 0);
});
test('scanner skips missing init and rejects invalid exports, metadata and duplicate names', async () => {
const {get_plugin_list, options, modules, plugin, events} = setup();
const entry = (name, directory = false) => ({name, isDirectory: () => directory, isFile: () => !directory});
options.directories = {plugins: [entry('runtime', true)], runtime: [entry('define.ts'), entry('empty.ts'), entry('a.ts')]};
modules.set('define', {define: {path: 'runtime', i18n}});
modules.set('empty', {});
assert.equal((await get_plugin_list()).length, 1);
assert.deepEqual(events, []);
modules.set('empty', {init: undefined});
await assert.rejects(get_plugin_list(), /init class/);
modules.set('empty', {});
modules.set('a', {init: plugin('wrong')});
await assert.rejects(get_plugin_list(), /metadata/);
modules.delete('a');
options.directories.plugins.push(entry('other', true));
options.directories.other = [entry('define.ts'), entry('a.ts')];
await assert.rejects(get_plugin_list(), /Duplicate/);
});
test('lifecycle updates precede disposal, retain final states and support disabled/error retries', async () => {
const {manager, modules, plugin, options} = setup();
const Base = plugin('a');
const instances = [];
modules.set('a', {init: class extends Base {
constructor() { super(); instances.push(this); }
async dispose() {
assert.equal(manager.get_plugin('a').enabled, false);
assert.equal(manager.get_plugin('a').lifecycle, 'disabling');
this.status.count = 7;
await super.dispose();
}
}});
const updates = [];
manager.on_update(() => { throw new Error('listener failure'); });
manager.on_update(event => { event.states.count = 999; });
const unsubscribe = manager.on_update(event => updates.push(event));
await manager.inject(item());
assert.deepEqual(updates.map(event => [event.lifecycle, event.enabled]), [['initializing', false], ['enabled', true]]);
const count = updates.length;
manager.get_plugin('a'); manager.get_all_plugins();
assert.equal(updates.length, count);
await manager.uninject('a');
assert.deepEqual(updates.slice(-2).map(event => event.lifecycle), ['disabling', 'disabled']);
assert.equal(manager.get_plugin('a').states.count, 7);
assert.equal(instances[0].listeners.size, 0);
await manager.uninject('a');
assert.equal(updates.length, count + 2);
await manager.inject(item());
assert.notEqual(instances[0], instances[1]);
options.fail = 'a';
await assert.rejects(manager.update_config('a', {}), {reason: 'INIT_FAILED'});
assert.equal(manager.get_plugin('a').lifecycle, 'error');
assert.equal(manager.get_plugin('a').enabled, false);
assert.ok(instances.every(instance => instance.listeners.size === 0));
assert.equal(JSON.stringify(manager.get_plugin('a')).includes('install failed'), false);
options.fail = undefined;
unsubscribe(); unsubscribe();
const before = updates.length;
await manager.inject(item());
assert.equal(updates.length, before);
await manager.dispose();
assert.equal(manager.get_plugin('a').lifecycle, 'disabled');
assert.throws(() => manager.on_update(() => {}), /disposed/);
});
test('auto reconnect publishes live internal states, isolates clones and stops after unsubscribe/disposal', async () => {
const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url);
const {EventEmitter} = await import('node:events');
const context = new EventEmitter();
context.cancel_reconnect = () => {};
const manager = new PluginManager(context);
const updates = [];
const unsubscribe = manager.on_update(event => updates.push(event));
await manager.inject(item('auto_reconnect'));
assert.equal(updates.length, 2);
context.emit('status_update', 'online');
assert.equal(updates.length, 2);
context.emit('reconnect_attempt');
assert.equal(updates.at(-1).states.attempts, 1);
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
updates.at(-1).states.attempts = 99;
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
context.emit('connection_requested');
assert.equal(updates.at(-1).states.attempts, 0);
unsubscribe(); unsubscribe();
const before = updates.length;
context.emit('reconnect_attempt');
assert.equal(updates.length, before);
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
await manager.uninject('auto_reconnect');
context.emit('reconnect_attempt');
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 1);
assert.equal(context.listenerCount('reconnect_attempt'), 0);
await manager.inject(item('auto_reconnect'));
assert.equal(manager.get_plugin('auto_reconnect').states.attempts, 0);
await manager.dispose();
});
test('load failures leave sanitized retryable records while invalid input leaves records unchanged', async () => {
const {manager, modules, plugin} = setup();
modules.set('a', {init: 1});
await assert.rejects(manager.inject(item()), {reason: 'LOAD_FAILED'});
assert.deepEqual(manager.get_plugin('a'), {
name: 'a', path: 'runtime', config: {}, enabled: false, lifecycle: 'error', states: null,
});
await assert.rejects(manager.inject(item('a', {unknown: () => {}})), {reason: 'INVALID_CONFIG'});
assert.equal(manager.get_plugin('a').lifecycle, 'error');
modules.set('a', {init: plugin('a')});
await manager.inject(item());
assert.equal(manager.get_plugin('a').enabled, true);
await manager.dispose();
});
test('full snapshot reconcile preserves unchanged instances and resolves omitted defaults', async () => {
const {manager, modules, plugin, events} = setup();
modules.set('a', {init: plugin('a', [{name: 'x', type: 'number', default: 1, required: true, i18n}])});
await manager.init([item('a'), item('b')]);
const previous = manager.entries.get('a');
await manager.apply_desired([item('a', {x: 1}), item('c')]);
assert.equal(manager.entries.get('a'), previous);
assert.deepEqual(events, ['init:a', 'init:b', 'dispose:b', 'init:c']);
await manager.apply_desired([item('a', {x: 2}), item('c')]);
assert.deepEqual(events.slice(-2), ['dispose:a', 'init:a']);
assert.equal(manager.get_plugin('a').config.x, 2);
await manager.apply_desired([]);
assert.ok(manager.get_all_plugins().every(entry => entry.lifecycle === 'disabled'));
await manager.dispose();
});
test('reconcile validates every desired name, path, module and config before removals', async () => {
const {manager, modules, events} = setup();
await manager.init([item('a'), item('b')]);
for (const desired of [[item('a'), {...item('a'), path: 'other'}], [{...item('a'), path: 'other'}],
[item('a'), item('c', {unknown: true})], [item('a'), item('bad')]]) {
modules.set('bad', {init: 1});
await assert.rejects(manager.apply_desired(desired));
assert.deepEqual(events, ['init:a', 'init:b']);
assert.equal(manager.get_plugin('a').enabled, true);
assert.equal(manager.get_plugin('b').enabled, true);
}
await manager.dispose();
});
test('failed removal quarantines retained entry and blocks every reinstallation route', async () => {
const {manager, modules, plugin, options, events} = setup();
modules.set('a', {init: plugin('a', [{name: 'x', type: 'number', default: 1, required: true, i18n}])});
await manager.init([item('a'), item('b')]);
options.dispose_fail = 'a';
await assert.rejects(manager.apply_desired([item('a', {x: 2})]), {reason: 'DISPOSE_FAILED'});
assert.equal(manager.entries.get('a').quarantined, true);
assert.equal(manager.get_plugin('a').lifecycle, 'error');
assert.equal(manager.get_plugin('a').error.code, 'DISPOSE_FAILED');
assert.equal(manager.get_plugin('a').enabled, false);
for (const operation of [() => manager.apply_desired([item('a')]), () => manager.apply_desired([]),
() => manager.inject(item('a')), () => manager.re_inject(item('a')), () => manager.uninject('a')]) {
await assert.rejects(operation());
}
assert.deepEqual(events, ['init:a', 'init:b', 'dispose:a']);
const before = events.length;
await assert.rejects(manager.dispose(), {reason: 'DISPOSE_FAILED'});
assert.deepEqual(events.slice(before), ['dispose:b']);
});
test('failed candidate init cleanup quarantines and cannot be retried in same context', async () => {
const {manager, options, events} = setup();
options.fail = 'a'; options.dispose_fail = 'a';
await assert.rejects(manager.apply_desired([item('a')]), {reason: 'DISPOSE_FAILED'});
options.fail = undefined; options.dispose_fail = undefined;
await assert.rejects(manager.apply_desired([item('a')]), {reason: 'DISPOSE_FAILED'});
await assert.rejects(manager.inject(item('a')));
assert.deepEqual(events, ['init:a', 'dispose:a']);
await assert.rejects(manager.dispose(), {reason: 'DISPOSE_FAILED'});
});
test('reconcile serialization observes committed defaults and disposal interrupts preflight', async () => {
const {manager, options, events} = setup();
await manager.inject(item('a'));
const gate = deferred(); options.load_gate = gate.promise;
const first = assert.rejects(manager.apply_desired([item('b')]), {reason: 'MANAGER_DISPOSED'});
const queued = assert.rejects(manager.apply_desired([]), {reason: 'MANAGER_DISPOSED'});
await flush();
const disposing = manager.dispose();
gate.resolve(); await first; await queued; await disposing;
assert.deepEqual(events, ['init:a', 'dispose:a']);
});
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import vm from 'node:vm';
import test from 'node:test';
import {transformSync} from 'esbuild';
const source = await readFile(new URL('../service/server/update/plugins.ts', import.meta.url), 'utf8');
const code = transformSync(source.replace(/^import .*;\n/gm, '')
.replace('export async function', 'async function'), {loader: 'ts'}).code;
test('late plugin scan never reports through a replacement authenticated session', async () => {
let finish_scan;
let current = true;
const reports = [];
const socket = {connected: true, timeout() { return this; }, async emitWithAck(...args) {
reports.push(args); return {status: 'success', data: 0};
}};
const context = {socket, is_current: () => current,
get_plugin_list: () => new Promise(resolve => { finish_scan = resolve; }),
console_print: {log() {}, success() {}}};
const task = vm.runInNewContext(`${code}\nupdate_plugins(socket, 1, is_current);`, context);
current = false;
finish_scan([]);
await task;
assert.equal(reports.length, 0);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {init: PluginStates} = await tsImport('../bot/info/bot/plugin_states.ts', import.meta.url);
const {PluginManager} = await tsImport('../bot/plugins/init.ts', import.meta.url);
const {create_info_manager} = await tsImport('../bot/info/init.ts', import.meta.url);
const item = {name: 'auto_reconnect', path: 'runtime', config: {}};
function setup() {
const context = new EventEmitter();
context.cancel_reconnect = () => {};
const manager = new PluginManager(context);
let subscriptions = 0;
const source = {
get_all_plugins: () => manager.get_all_plugins(),
on_update(callback) {
subscriptions++;
const unsubscribe = manager.on_update(callback);
let active = true;
return () => {
if (!active) return;
active = false; subscriptions--; unsubscribe();
};
},
};
return {context, manager, source, subscriptions: () => subscriptions};
}
test('plugin info initially reads source, streams states without config and owns only its subscription', async () => {
const {context, manager, source, subscriptions} = setup();
await manager.inject(item);
const bot = new EventEmitter();
const info = new PluginStates(bot, {plugin_source: source});
assert.equal(bot.listenerCount('end'), 0);
assert.equal(subscriptions(), 1);
assert.deepEqual(Object.keys(info.get_info().plugins[0]).sort(), ['enabled', 'lifecycle', 'name', 'path', 'states']);
const updates = [];
info.on_update(() => { throw new Error('listener failed'); });
info.on_update(value => { value.plugins[0].states.attempts = 99; });
const unsubscribe = info.on_update(value => updates.push(value));
context.emit('reconnect_attempt');
assert.equal(updates[0].plugins[0].states.attempts, 1);
assert.equal(info.get_info().plugins[0].states.attempts, 1);
const detached = info.get_info(); detached.plugins.length = 0;
assert.equal(info.get_info().plugins.length, 1);
context.emit('status_update', 'connecting');
assert.equal(updates.length, 1);
await manager.uninject(item.name);
assert.equal(updates.at(-1).plugins[0].lifecycle, 'disabled');
assert.equal(updates.at(-1).plugins[0].states.attempts, 1);
unsubscribe(); unsubscribe();
const before = updates.length;
await manager.inject(item);
assert.equal(updates.length, before);
info.dispose(); info.dispose();
assert.equal(subscriptions(), 0);
context.emit('reconnect_attempt');
assert.equal(info.get_info().plugins[0].states.attempts, 0);
assert.throws(() => info.on_update(() => {}), /disposed/);
await manager.dispose();
});
test('missing source is explicitly unavailable and empty manager is available', async () => {
const missing = new PluginStates(new EventEmitter());
assert.deepEqual(missing.get_info(), {available: false, plugins: []});
const {manager, source} = setup();
const empty = new PluginStates(new EventEmitter(), {plugin_source: source});
assert.deepEqual(empty.get_info(), {available: true, plugins: []});
missing.dispose(); empty.dispose(); await manager.dispose();
});
test('InfoManager owns end cleanup and reconnect info uses the same plugin manager', async () => {
const {context, manager, source, subscriptions} = setup();
await manager.inject(item);
const bot = new EventEmitter();
bot._client = new EventEmitter();
const infos = await create_info_manager(bot, {plugin_source: source});
assert.equal(subscriptions(), 1);
const updates = [];
infos.on_update(event => {
if (event.name === 'plugin_states') event.value.plugins[0].states.attempts = 999;
});
infos.on_update(event => { if (event.name === 'plugin_states') updates.push(event.value); });
context.emit('reconnect_attempt');
assert.equal(updates.at(-1).plugins[0].states.attempts, 1);
assert.equal('config' in infos.get_info('plugin_states').value.plugins[0], false);
bot.emit('end');
assert.equal(subscriptions(), 0);
assert.equal(bot.listenerCount('end'), 0);
const before = updates.length;
context.emit('reconnect_attempt');
assert.equal(updates.length, before);
const next = new EventEmitter(); next._client = new EventEmitter();
const reconnected = await create_info_manager(next, {plugin_source: source});
assert.equal(reconnected.get_info('plugin_states').value.plugins[0].states.attempts, 2);
assert.equal(subscriptions(), 1);
reconnected.dispose(); reconnected.dispose();
assert.equal(subscriptions(), 0);
await manager.dispose();
});
+347
View File
@@ -0,0 +1,347 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {mkdtemp, mkdir, writeFile, symlink, rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import {performance} from 'node:perf_hooks';
import {test} from 'node:test';
import {register} from 'tsx/esm/api';
// One module graph is essential: separate tsImport namespaces would isolate the shared WeakMap meter.
register();
const {instrument_bot, measure_sync, ResourceInfo} = await import('../bot/info/resource_metrics.ts');
const {init: CpuInfo} = await import('../bot/info/instance/used_cpu.ts');
const {NetworkCounter, init: NetworkInfo} = await import('../bot/info/instance/used_network.ts');
const {estimate_memory, init: MemoryInfo} = await import('../bot/info/instance/used_memory.ts');
const {scan_storage, init: StorageInfo} = await import('../bot/info/instance/used_storage.ts');
const {create_info_manager} = await import('../bot/info/init.ts');
const {create_function_manager} = await import('../bot/function/init.ts');
function bot() {
const bot = new EventEmitter();
bot._client = new EventEmitter();
return bot;
}
test('sync meter deduplicates nested bot/client events and preserves invocation semantics', t => {
const clock = t.mock.method(performance, 'now');
let now = 0;
clock.mock.mockImplementation(() => now);
const b = bot();
const original = b.emit;
const clientOriginal = b._client.emit;
const meter = instrument_bot(b);
b._client.on('packet', function(a, c) {
assert.equal(this, b._client);
assert.deepEqual([a, c], [1, 2]);
now += 3;
b.emit('physicsTick');
now += 2;
});
b.on('physicsTick', () => { now += 5; });
assert.equal(b._client.emit('packet', 1, 2), true);
assert.equal(meter.total(), 10);
assert.equal(b.emit('absent'), false);
const error = new Error('same error');
b.on('failure', () => { now += 7; throw error; });
assert.throws(() => b.emit('failure'), e => e === error);
assert.equal(meter.total(), 17);
const other = bot();
other.on('x', () => { now += 100; });
other.emit('x');
assert.equal(meter.total(), 17);
meter.dispose(); meter.dispose();
assert.equal(b.emit, original);
assert.equal(b._client.emit, clientOriginal);
assert.equal(Object.hasOwn(b, 'emit'), false);
});
test('sync invocation never awaits promises and disposal respects replacement hooks', async t => {
let now = 0;
t.mock.method(performance, 'now', () => now);
const b = bot();
const meter = instrument_bot(b);
let release;
const pending = new Promise(resolve => { release = resolve; });
assert.equal(measure_sync(b, () => { now += 4; return pending; }), pending);
now += 500;
release(true);
await pending;
assert.equal(meter.total(), 4);
const replacement = () => 'replacement';
b.emit = replacement;
meter.dispose();
assert.equal(b.emit, replacement);
});
test('CPU samples actual elapsed deltas and cancels timer on disposal', async t => {
t.mock.timers.enable({apis: ['setInterval']});
let now = 0;
t.mock.method(performance, 'now', () => now);
const b = bot();
const info = new CpuInfo(b);
assert.equal(info.get_info().usage_percent, null);
b.on('physicsTick', () => { now += 20; });
b.emit('physicsTick');
now = 2000;
t.mock.timers.tick(1000);
assert.equal(info.get_info().usage_percent, 1);
assert.equal(info.get_info().sampled_time_ms, 20);
assert.equal(info.get_info().interval_ms, 2000);
assert.match(info.get_info().coverage, /协议解析/);
let updates = 0;
info.on_update(() => updates++);
info.dispose();
await Promise.resolve();
t.mock.timers.tick(5000);
assert.equal(updates, 0);
assert.equal(b.listenerCount('end'), 0);
assert.throws(() => info.on_update(() => {}), /disposed/);
});
test('function manager meters synchronous run invocation, with no promise wait', async t => {
const b = bot();
b.entity = {isValid: true};
b._client.state = 'play';
const manager = await create_function_manager({bot_instance: b, bot_status: 'online'});
const meter = instrument_bot(b);
t.after(() => { manager.dispose(); meter.dispose(); });
let now = 0;
t.mock.method(performance, 'now', () => now);
b.chat = () => { now += 12; };
assert.equal(await manager.run('bot', 'chat', ['hello']), true);
assert.equal(meter.total(), 12);
});
test('socket counters use elapsed intervals and retain observed totals across resets and gaps', () => {
const counter = new NetworkCounter();
const first = {bytesRead: 100, bytesWritten: 50};
assert.equal(counter.sample(undefined, 0).available, false);
assert.equal(counter.sample(first, 100).received_bytes_per_second, null);
first.bytesRead += 300; first.bytesWritten += 100;
const sample = counter.sample(first, 1600);
assert.equal(sample.received_bytes_per_second, 200);
assert.equal(sample.sent_bytes_per_second, 100 / 1.5);
assert.equal(sample.received_bytes, 400);
const second = {bytesRead: 20, bytesWritten: 10};
assert.equal(counter.sample(second, 2000).received_bytes_per_second, null);
assert.equal(counter.sample(undefined, 2500).received_bytes, 420);
second.bytesRead = 30;
assert.equal(counter.sample(second, 3000).received_bytes, 430);
second.bytesRead = 2;
assert.equal(counter.sample(second, 4000).received_bytes_per_second, null);
assert.equal(counter.sample(second, 5000).received_bytes, 432);
});
test('memory scans own descriptors, real columns, cycles and shared backing buffers without getters', async () => {
const backing = new ArrayBuffer(1024);
const column = {a: new Uint8Array(backing), b: new DataView(backing)};
Object.defineProperty(column.a, 'buffer', {get() { throw new Error('no getter'); }});
const b = bot();
b.registry = {large: new ArrayBuffer(100000)};
b.world = {getColumns: () => [{chunkX: 0, chunkZ: 0, column}], external: b.registry};
b.inventory = {slots: [column.a], registry: b.registry};
b.currentWindow = b.inventory;
b.entities = {one: {name: 'one'}};
b.players = {one: b.entities.one};
b.entities.one.self = b.entities.one;
const original = await estimate_memory(b);
assert.equal(original.partial, false);
assert.ok(original.total_bytes >= 1024 && original.total_bytes < 3000);
const duplicated = await estimate_memory({...b, currentWindow: {...b.inventory}});
assert.ok(duplicated.total_bytes > original.total_bytes);
Object.defineProperty(b.entities.one, 'danger', {get() { throw new Error('getter executed'); }});
assert.equal((await estimate_memory(b)).partial, true);
assert.equal((await estimate_memory(b, () => true)).partial, true);
assert.equal((await estimate_memory(b, () => false, {nodes: 1, properties: 10, milliseconds: 1000})).partial, true);
b.world.getColumns = () => { throw new Error('unavailable'); };
assert.equal((await estimate_memory(b)).partial, true);
});
for (const hugeCategory of ['chunks', 'entities']) {
for (const cap of ['nodes', 'properties']) {
test(`memory reserves category budgets with huge ${hugeCategory} and a ${cap} cap`, async () => {
const b = bot();
// Wide objects exercise ownKeys; nested objects exercise independent node limits.
const huge = Object.fromEntries(Array.from({length: 10000}, (_, i) => [`key${i}`, {value: i}]));
b.world = {getColumns: () => [{column: hugeCategory === 'chunks' ? huge : {data: new Uint8Array(64)}}]};
b.entities = hugeCategory === 'entities' ? huge : {one: {name: 'entity'}};
b.inventory = {slots: [{name: 'stone'}]};
b.time = {age: 100n};
const limits = {nodes: 100000, properties: 100000, milliseconds: 10000, [cap]: 80};
const result = await estimate_memory(b, () => false, limits);
for (const category of ['chunks', 'entities', 'inventory', 'other']) {
assert.ok(result[`${category}_bytes`] > 0, category);
assert.equal(result.categories_complete[category], category !== hugeCategory, category);
}
assert.equal(result.partial, true);
assert.equal(result.total_bytes, result.chunks_bytes + result.entities_bytes
+ result.inventory_bytes + result.other_bytes);
});
}
}
test('memory deduplicates shared objects and backing stores across categories deterministically', async () => {
const sample = async size => {
const b = bot();
const backing = new ArrayBuffer(size);
const shared = {view: new Uint8Array(backing)};
shared.self = shared;
b.world = {getColumns: () => [{column: {shared, view: new DataView(backing)}}]};
b.entities = {shared, view: new Uint8Array(backing)};
b.inventory = {shared, view: new DataView(backing)};
b.game = {shared, backing};
return estimate_memory(b, () => false, {nodes: 1000, properties: 1000, milliseconds: 10000});
};
const small = await sample(100);
const large = await sample(1100);
assert.equal(large.total_bytes - small.total_bytes, 1000);
assert.deepEqual(large, await sample(1100));
assert.deepEqual(large.categories_complete, {chunks: true, entities: true, inventory: true, other: true});
assert.equal(large.partial, false);
});
test('memory scans only allowlisted other roots and reports omissions without calling getters', async () => {
const b = bot();
const roots = ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist'];
for (const key of roots) {
const result = await estimate_memory(Object.assign(bot(), {[key]: {data: 'owned'}}));
assert.ok(result.other_bytes > 0, key);
assert.equal(result.categories_complete.other, true);
}
b.unlisted = {data: new ArrayBuffer(1000000)};
assert.equal((await estimate_memory(b)).other_bytes, 0);
let getters = 0;
Object.defineProperty(b, 'game', {get() { getters++; return {}; }});
b.world = {};
Object.defineProperty(b.world, 'getColumns', {get() { getters++; return () => []; }});
b.entities = {one: {name: 'one'}};
Object.defineProperty(b.entities.one, 'danger', {get() { getters++; return 123; }});
const result = await estimate_memory(b);
assert.equal(getters, 0);
assert.deepEqual(result.categories_complete, {chunks: false, entities: false, inventory: true, other: false});
assert.equal(result.partial, true);
for (const limits of [{nodes: 0, properties: 100, milliseconds: 1000},
{nodes: 100, properties: 0, milliseconds: 1000},
{nodes: 100, properties: 100, milliseconds: 0}]) {
const capped = await estimate_memory(b, () => false, limits);
assert.equal(capped.categories_complete.entities, false);
assert.equal(capped.entities_bytes, 0);
assert.equal(capped.partial, true);
}
const cancelled = await estimate_memory(b, () => true);
assert.deepEqual(cancelled.categories_complete, {chunks: false, entities: false, inventory: false, other: false});
});
test('memory marks column and unsupported collection truncation per category', async () => {
const b = bot();
b.world = {getColumns: () => Array.from({length: 4097}, () => ({column: {}}))};
b.game = new Map([['data', 'omitted']]);
const result = await estimate_memory(b, () => false, {nodes: 50000, properties: 50000, milliseconds: 10000});
assert.deepEqual(result.categories_complete, {chunks: false, entities: true, inventory: true, other: false});
assert.equal(result.partial, true);
});
test('storage scans nested files without following symlinks and reports missing, error and bounds', async t => {
const directory = await mkdtemp(path.join(tmpdir(), 'mf-resource-'));
t.after(() => rm(directory, {recursive: true, force: true}));
await mkdir(path.join(directory, 'nested'));
await writeFile(path.join(directory, 'a'), 'abc');
await writeFile(path.join(directory, 'nested', 'b'), '12345');
await symlink(directory, path.join(directory, 'nested', 'loop'));
await symlink(path.join(directory, 'a'), path.join(directory, 'alias'));
assert.deepEqual(await scan_storage(directory), {available: true, total_bytes: 8, file_count: 2, partial: false});
assert.deepEqual(await scan_storage(path.join(directory, 'missing')),
{available: true, total_bytes: 0, file_count: 0, partial: false});
assert.equal((await scan_storage(path.join(directory, 'a'))).available, false);
assert.equal((await scan_storage(directory, () => true)).partial, true);
assert.equal((await scan_storage(directory, () => false, {entries: 2, depth: 32, milliseconds: 1000})).partial, true);
assert.equal((await scan_storage(directory, () => false, {entries: 100, depth: 1, milliseconds: 1000})).partial, true);
});
test('resource modules have complete field metadata, no end listeners and safe async disposal', async t => {
t.mock.timers.enable({apis: ['setInterval']});
const b = bot();
const infos = [new CpuInfo(b), new NetworkInfo(b), new MemoryInfo(b), new StorageInfo(b)];
assert.deepEqual(infos[2].get_info().categories_complete,
{chunks: false, entities: false, inventory: false, other: false});
assert.equal(infos[2].get_info().partial, true);
let updates = 0;
for (const info of infos) {
assert.equal(info.i18n.lang, 'zh-CN');
assert.match(info.i18n.name, /[\u4e00-\u9fff]/u);
assert.match(info.i18n.description, /[\u4e00-\u9fff]/u);
for (const key of Object.keys(info.get_info())) {
const field = info.i18n.fields[key];
assert.ok(field, key);
assert.equal(field.lang, 'zh-CN');
assert.notEqual(field.name, key);
assert.match(field.name, /[\u4e00-\u9fff]/u);
assert.match(field.description, /[\u4e00-\u9fff]/u);
}
const checkFields = (value, metadata) => {
for (const [key, entry] of Object.entries(value)) {
const field = metadata.fields[key];
assert.ok(field, key);
assert.equal(field.lang, 'zh-CN');
assert.match(field.name, /[\u4e00-\u9fff]/u);
assert.match(field.description, /[\u4e00-\u9fff]/u);
if (entry && typeof entry === 'object') checkFields(entry, field);
}
};
checkFields(info.get_info(), info.i18n);
info.on_update(() => updates++);
}
assert.equal(b.listenerCount('end'), 0);
for (const info of infos) { info.dispose(); info.dispose(); }
await Promise.resolve();
t.mock.timers.tick(60000);
assert.equal(updates, 0);
assert.equal(infos[3].get_info().available, false);
});
test('async resource sampling does not overlap or publish after disposal', async t => {
t.mock.timers.enable({apis: ['setInterval']});
let release;
let calls = 0;
class Sample extends ResourceInfo {
name = 'sample';
i18n = {lang: 'en', name: 'Sample', description: ''};
constructor() {
super(0);
this.start(1000, async () => {
calls++;
await new Promise(resolve => { release = resolve; });
this.publish(1);
}, true);
}
}
const sample = new Sample();
t.mock.timers.tick(30000);
assert.equal(calls, 1);
sample.dispose();
release();
await Promise.resolve();
await Promise.resolve();
assert.equal(sample.get_info(), 0);
t.mock.timers.tick(30000);
assert.equal(calls, 1);
});
test('InfoManager discovers all resource modules and owns wrappers through end/recreation', async () => {
const b = bot();
const original = b.emit;
const manager = await create_info_manager(b);
for (const name of ['used_cpu', 'used_memory', 'used_network', 'used_storage']) {
assert.ok(manager.get_info(name), name);
}
assert.equal(b.listenerCount('end'), 1);
assert.notEqual(b.emit, original);
b.emit('end');
assert.equal(b.emit, original);
assert.equal(b.listenerCount('end'), 0);
assert.equal(manager.get_all_info().length, 0);
const next = await create_info_manager(b);
next.dispose();
assert.equal(b.emit, original);
});
+177
View File
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {create_function_manager} = await tsImport('../bot/function/init.ts', import.meta.url);
const {init: Stop} = await tsImport('../bot/function/bot/stop_current_function.ts', import.meta.url);
const require = createRequire(import.meta.url);
const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3');
const turn = () => new Promise(resolve => setImmediate(resolve));
async function fixture(t) {
const bot = new EventEmitter();
bot.entity = {id: 1, position: new Vec3(0, 0, 0), height: 1.8, eyeHeight: 1.62, onGround: true};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.controlState = {};
bot.getControlState = key => bot.controlState[key] === true;
bot.setControlState = (key, value) => { bot.controlState[key] = value; };
bot.stopDigging = () => { bot.targetDigBlock = null; };
bot.deactivateItem = () => {};
bot.clearControlStates = () => { for (const key of Object.keys(bot.controlState)) bot.setControlState(key, false); };
const manager = await create_function_manager({bot_instance: bot, bot_status: 'online'});
t.after(() => manager.dispose());
return {bot, manager};
}
function clean(bot) {
assert.deepEqual(bot.eventNames(), []);
assert.deepEqual(bot._client.eventNames(), []);
}
test('stop bypasses a full queue, releases direction and persistent sneak, and never restarts pending keys', async t => {
const {bot, manager} = await fixture(t);
await manager.run('bot', 'squat_continue_switch', [true]);
const running = manager.run('bot', 'use_direction_key', ['forward', 5000]);
await turn();
assert.equal(bot.controlState.forward, true);
const queued = Array.from({length: 64}, () => manager.run('bot', 'use_direction_key', ['right', 5000]));
let queuedChecks = 0;
// Queue capacity applies to normal calls, never to the stop command.
await assert.rejects(manager.run('bot', 'jump', [], () => queuedChecks++), /队列已满/);
assert.equal(await manager.run('bot', 'stop_current_function', []), true);
assert.equal(await running, false);
assert.deepEqual(await Promise.all(queued), Array(64).fill(false));
await turn();
assert.equal(queuedChecks, 0);
assert.deepEqual(bot.controlState, {sneak: false, forward: false});
clean(bot);
const fresh = manager.run('bot', 'jump', []);
await turn();
bot.emit('physicsTick');
assert.equal(await fresh, true);
clean(bot);
});
test('stop validates no arguments and attempts every stop API even when earlier calls throw', async () => {
const stop = new Stop();
assert.deepEqual(stop.args, []);
for (const failing of ['stopDigging', 'deactivateItem', 'clearControlStates', null]) {
const calls = [];
const bot = {};
for (const name of ['stopDigging', 'deactivateItem', 'clearControlStates']) bot[name] = () => {
calls.push(name);
if (name === failing) throw Error(name);
};
assert.equal(await stop.run(bot, [1]), false);
assert.deepEqual(calls, []);
assert.equal(await stop.run(bot, []), failing === null);
assert.deepEqual(calls, ['stopDigging', 'deactivateItem', 'clearControlStates']);
}
});
test('stop settles pending look immediately and late entity look cannot attack or restart queued work', async t => {
const {bot, manager} = await fixture(t);
const target = {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 1, height: 2};
bot.entities = {2: target};
let finishLook;
bot.lookAt = () => new Promise(resolve => { finishLook = resolve; });
let attacks = 0;
bot.attack = () => { attacks++; };
const running = manager.run('entity', 'attack_nearest_entity', ['cow', 4, false, true]);
await turn();
let before = 0;
const queued = manager.run('bot', 'jump', [], () => before++);
assert.equal(await manager.run('bot', 'stop_current_function', []), true);
assert.equal(await running, false);
assert.equal(await queued, false);
clean(bot);
finishLook();
await turn();
assert.equal(attacks, 0);
assert.equal(before, 0);
clean(bot);
});
test('stop cancels container waits and block groups without fake disconnects or later group actions', async t => {
const {bot, manager} = await fixture(t);
let ends = 0;
bot.on('end', () => ends++);
bot.entities = {2: {id: 2, name: 'cow', position: new Vec3(2, 0, 0), width: 1, height: 2}};
bot.interactEntity = () => {};
const open = manager.run('entity', 'open_nearest_entity_container', ['cow', 4, false]);
await turn();
assert.equal(bot.listenerCount('windowOpen'), 1);
await manager.run('bot', 'stop_current_function', []);
assert.equal(await open, false);
assert.equal(bot.listenerCount('windowOpen'), 0);
const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0)};
bot.blockAt = () => block;
let finishUse;
let uses = 0;
bot.activateBlock = () => { uses++; return new Promise(resolve => { finishUse = resolve; }); };
const group = manager.run('block', 'use_position_group_block', [[2, 1, 0, 1, 2, 1, 0, 1]]);
await turn();
await manager.run('bot', 'stop_current_function', []);
assert.equal(await group, false);
finishUse();
await turn();
assert.equal(uses, 1);
assert.equal(ends, 0);
bot.removeAllListeners('end');
clean(bot);
});
test('stop prevents remaining inventory clicks after an already submitted click settles', async t => {
const {bot, manager} = await fixture(t);
for (const name of ['slot_to_slot', 'slot_switch_slot']) {
bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null};
let finishClick;
const clicks = [];
bot.clickWindow = slot => { clicks.push(slot); return new Promise(resolve => { finishClick = resolve; }); };
const running = manager.run('inventory', name, [0, 1]);
await turn();
assert.deepEqual(clicks, [0]);
await manager.run('bot', 'stop_current_function', []);
assert.equal(await running, false);
finishClick();
await turn();
assert.deepEqual(clicks, [0]);
}
clean(bot);
});
test('stop releases dig ownership during pending aim and stops an already active dig', async t => {
const {bot, manager} = await fixture(t);
const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0)};
bot.blockAt = () => block;
bot.canDigBlock = () => true;
let finishLook;
bot.lookAt = () => new Promise(resolve => { finishLook = resolve; });
let digs = 0;
let finishDig;
bot.dig = () => {
digs++;
bot.targetDigBlock = block;
return new Promise(resolve => { finishDig = resolve; });
};
const aiming = manager.run('block', 'destroy_position_block', [2, 1, 0, 1, true]);
await turn();
await manager.run('bot', 'stop_current_function', []);
assert.equal(await aiming, false);
clean(bot);
finishLook();
await turn();
assert.equal(digs, 0);
const digging = manager.run('block', 'destroy_position_block', [2, 1, 0, 1]);
await turn();
assert.equal(digs, 1);
assert.equal(bot.targetDigBlock, block);
await manager.run('bot', 'stop_current_function', []);
assert.equal(await digging, false);
assert.equal(bot.targetDigBlock, null);
finishDig();
await turn();
clean(bot);
});
+856
View File
@@ -0,0 +1,856 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {setImmediate, setTimeout as delay} from 'node:timers/promises';
import {tsImport} from 'tsx/esm/api';
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
const {create_workflow_run} = await tsImport('../bot/workflow/runtime/machine.ts', import.meta.url);
const {assert_json, resolve_value} = await tsImport('../bot/workflow/runtime/value.ts', import.meta.url);
const literal = value => ({kind: 'literal', value});
const ref = (root, ...path) => ({kind: 'ref', root, path});
const edge = (id, source, target, port = 'success') => ({id, source, port, target});
const query = (id, args = {}, extra = {}) => ({id, type: 'query', config: {source: id, args, ...extra}});
const fn = (id, args = []) => ({id, type: 'function', config: {path: 'test', name: id, args}});
const merge = (id, mode = 'all') => ({id, type: 'merge', config: {mode}});
const condition = (id, left, right) => ({id, type: 'condition', config: {condition: {op: 'lt', left, right}}});
const deps = overrides => ({
run_function: async () => true,
query: async (_source, args) => args,
sleep: async () => {},
log() {},
...overrides,
});
function definition(nodes, edges = [], extra = {}) {
return {
schema_version: 1, id: 'test_workflow', name: 'Workflow test', enabled: true,
start: {type: 'common', auto_start: 'never'}, entry: nodes[0].id, nodes, edges,
initial_vars: {}, policy: {on_busy: 'skip', max_queue: 0, max_steps: 100, timeout_ms: 5000},
...extra,
};
}
function start(t, config, dependencies = deps(), trigger = {}, notify) {
const run = create_workflow_run(compile_workflow(config), dependencies, trigger, notify);
t.after(() => run.stop());
return run;
}
function deferred() {
let resolve;
const promise = new Promise(finish => {resolve = finish;});
return {promise, resolve};
}
function until_aborted(signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) reject(signal.reason);
else signal.addEventListener('abort', () => reject(signal.reason), {once: true});
});
}
function completed(snapshot, branches = 1) {
assert.equal(snapshot.status, 'completed', JSON.stringify(snapshot.error));
assert.equal(snapshot.error, null);
assert.equal(snapshot.branches.length, branches);
assert.deepEqual(snapshot.active_nodes, []);
assert.ok(snapshot.ended_at >= snapshot.started_at);
return snapshot.branches[0];
}
test('workflow preserves the first failure while siblings drain past its deadline', {timeout: 5000}, async t => {
const abort_seen = deferred();
const slow_started = deferred();
const release = deferred();
const run = start(t, definition([query('fork'), query('bad'), query('slow')], [
edge('fork_bad', 'fork', 'bad'), edge('fork_slow', 'fork', 'slow'),
], {policy: {on_busy: 'skip', max_queue: 0, max_steps: 100, timeout_ms: 100}}), deps({
query: async (source, _args, signal) => {
if (source === 'fork') return null;
if (source === 'bad') {
await slow_started.promise;
throw Object.assign(new Error('first failure'), {code: 'FIRST_FAILURE'});
}
slow_started.resolve();
try {await until_aborted(signal);}
finally {abort_seen.resolve(); await release.promise;}
return null;
},
}));
t.after(() => release.resolve());
await abort_seen.promise;
await delay(125);
release.resolve();
const snapshot = await run.done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.stop_reason, null);
assert.equal(snapshot.error.code, 'FIRST_FAILURE');
});
test('workflow compiles JSON configs by value and resolves trigger, vars, outputs, last_result and inputs', {timeout: 5000}, async t => {
const config = definition([
query('read', {value: ref('trigger', 'nested', 'values', 1)}, {save_as: 'saved'}),
fn('use', [ref('vars', 'saved'), ref('outputs', 'read', 'output'), ref('last_result', 'output'),
ref('inputs', 'read_use', 'result', 'output'), ref('vars', 'initial')]),
{id: 'log', type: 'log', config: {level: 'info', message: ref('last_result', 'output')}},
], [edge('read_use', 'read', 'use'), edge('use_log', 'use', 'log')], {initial_vars: {initial: 7}});
const serialized = JSON.parse(JSON.stringify(config));
const graph = compile_workflow(serialized);
serialized.nodes[0].config.source = 'changed';
assert.equal(graph.nodes.get('read').config.source, 'read');
const calls = [], logs = [], events = [];
const trigger = {nested: {values: ['first', {answer: 42}]}};
const run = create_workflow_run(graph, deps({
query: async (_source, args, signal) => {assert.equal(signal.aborted, false); return args.value;},
run_function: async (path, name, args, signal) => {
assert.equal(signal.aborted, false); calls.push({path, name, args}); return true;
},
log: (level, message) => logs.push({level, message}),
}), trigger, event => events.push(event));
t.after(() => run.stop());
trigger.nested.values[1].answer = 0;
const snapshot = await run.done;
const branch = completed(snapshot);
assert.deepEqual(calls, [{path: 'test', name: 'use', args: Array(4).fill({answer: 42}).concat(7)}]);
assert.deepEqual(logs, [{level: 'info', message: 'true'}]);
assert.deepEqual(branch.vars, {initial: 7, saved: {answer: 42}});
assert.deepEqual(Object.keys(branch.outputs), ['read', 'use', 'log']);
assert.equal(snapshot.run_id, run.run_id);
assert.equal(snapshot.steps, 3);
assert.deepEqual(events.filter(event => event.type === 'node_finished').map(event => event.node_id), ['read', 'use', 'log']);
snapshot.branches[0].vars.initial = 999;
const copy = run.get_snapshot();
assert.equal(copy.branches[0].vars.initial, 7);
copy.branches.length = 0;
assert.equal(run.get_snapshot().branches.length, 1);
});
test('workflow forks every edge on a success port into independent variable and output state', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
query('fork'), query('left', {value: literal('L')}, {save_as: 'left_value'}),
query('right', {value: literal('R')}, {save_as: 'right_value'}),
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right')]),
deps({query: async (_source, args) => args.value ?? 'root'})).done;
completed(snapshot, 2);
const left = snapshot.branches.find(branch => branch.outputs.left);
const right = snapshot.branches.find(branch => branch.outputs.right);
assert.deepEqual(left.vars, {left_value: 'L'});
assert.deepEqual(right.vars, {right_value: 'R'});
assert.equal(left.outputs.right, undefined);
assert.equal(right.outputs.left, undefined);
assert.equal(left.outputs.fork.output, 'root');
assert.equal(right.outputs.fork.output, 'root');
});
test('workflow cannot resolve a sibling output even after that sibling has finished', {timeout: 5000}, async t => {
const sibling_finished = deferred();
const config = definition([
query('fork'), query('sibling'), query('wait'),
query('illegal', {value: ref('outputs', 'sibling', 'output')}),
], [edge('to_sibling', 'fork', 'sibling'), edge('to_wait', 'fork', 'wait'), edge('to_illegal', 'wait', 'illegal')]);
const snapshot = await start(t, config, deps({query: async source => {
if (source === 'wait') await sibling_finished.promise;
return source;
}}), {}, event => {
if (event.type === 'node_finished' && event.node_id === 'sibling') sibling_finished.resolve();
}).done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'VALUE_NOT_FOUND');
assert.equal(snapshot.error.node_id, 'illegal');
});
test('workflow all merge exposes inputs by incoming edge id and unions branch outputs and vars', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
query('fork'), query('left', {}, {save_as: 'left_value'}), query('right', {}, {save_as: 'right_value'}),
merge('join'), query('inspect', {left: ref('outputs', 'left', 'output'), right: ref('outputs', 'right', 'output'),
inputs: ref('outputs', 'join', 'output')}),
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('joined', 'join', 'inspect')]),
deps({query: async (source, args) => source === 'inspect' ? args : source})).done;
const branch = completed(snapshot);
assert.deepEqual(branch.vars, {left_value: 'left', right_value: 'right'});
assert.deepEqual(branch.outputs.join.output, {
left_join: {source: 'left', port: 'success', result: {status: 'success', output: 'left'}},
right_join: {source: 'right', port: 'success', result: {status: 'success', output: 'right'}},
});
assert.deepEqual(branch.outputs.inspect.output, {left: 'left', right: 'right', inputs: branch.outputs.join.output});
assert.equal(snapshot.steps, 5);
});
test('workflow any merge emits once per arrival without sharing sibling state', {timeout: 5000}, async t => {
const events = [];
const snapshot = await start(t, definition([
query('fork'), query('left'), query('right'), merge('join', 'any'),
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]),
deps({query: async source => source}), {}, event => events.push(event)).done;
completed(snapshot, 2);
assert.equal(events.filter(event => event.type === 'node_finished' && event.node_id === 'join').length, 2);
assert.deepEqual(snapshot.branches.map(branch => Object.keys(branch.outputs.join.output)[0]).sort(), ['left_join', 'right_join']);
for (const branch of snapshot.branches) assert.notEqual(Boolean(branch.outputs.left), Boolean(branch.outputs.right));
});
test('workflow routes false results and thrown dependency errors through error nodes', {timeout: 5000}, async t => {
for (const throwing of [false, true]) {
const calls = [];
const code = throwing ? 'TEST_FAILURE' : 'FUNCTION_RETURNED_FALSE';
const snapshot = await start(t, definition([
fn('fail'), {id: 'catch', type: 'error', config: {codes: [code]}},
query('recovered', {code: ref('last_result', 'error', 'code')}), query('unexpected'),
], [edge('failure', 'fail', 'catch', 'failure'), edge('caught', 'catch', 'recovered', 'error'),
edge('uncaught', 'catch', 'unexpected', 'unmatched')]), deps({
run_function: async () => {
if (throwing) throw Object.assign(new Error('expected dependency error'), {code});
return false;
},
query: async (source, args) => {calls.push(source); return args;},
})).done;
const branch = completed(snapshot);
assert.deepEqual(calls, ['recovered']);
assert.equal(branch.outputs.fail.status, 'failure');
assert.deepEqual(branch.outputs.recovered.output, {code});
}
});
test('workflow goto retries are bounded and exhaustion can be explicitly handled', {timeout: 5000}, async t => {
let attempts = 0;
const sleeps = [];
const snapshot = await start(t, definition([
fn('attempt'), {id: 'retry', type: 'goto', config: {max_retries: 2, delay_ms: 7}},
query('exhausted', {code: ref('last_result', 'error', 'code')}),
], [edge('failed', 'attempt', 'retry', 'failure'), edge('again', 'retry', 'attempt', 'retry'),
edge('finished', 'retry', 'exhausted', 'exhausted')]), deps({
run_function: async () => {attempts++; return false;},
sleep: async (mode, value, signal) => {assert.equal(signal.aborted, false); sleeps.push([mode, value]);},
})).done;
const branch = completed(snapshot);
assert.equal(attempts, 3);
assert.deepEqual(sleeps, [['milliseconds', 7], ['milliseconds', 7]]);
assert.deepEqual(branch.retry_counts, {retry: 2});
assert.deepEqual(branch.outputs.exhausted.output, {code: 'RETRY_EXHAUSTED'});
assert.equal(snapshot.steps, 7);
});
test('workflow all merge fails with MERGE_INCOMPLETE for mutually exclusive branches', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
condition('choose', literal(1), literal(2)), query('left'), query('right'), merge('join'),
], [edge('yes', 'choose', 'left', 'true'), edge('no', 'choose', 'right', 'false'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')])).done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'MERGE_INCOMPLETE');
assert.equal(snapshot.steps, 2);
assert.deepEqual(snapshot.active_nodes, []);
});
test('workflow conflicting branch variables fail with MERGE_CONFLICT', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
query('fork'), query('left', {value: literal(1)}, {save_as: 'shared'}),
query('right', {value: literal(2)}, {save_as: 'shared'}), merge('join'),
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]),
deps({query: async (_source, args) => args.value ?? null})).done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'MERGE_CONFLICT');
assert.equal(snapshot.error.node_id, 'join');
assert.equal(snapshot.steps, 4);
});
test('workflow nested forks join inner branches before the outer all merge', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
query('root'), query('inner_fork'), query('outer_right'), query('inner_left'), query('inner_right'),
merge('inner_join'), merge('outer_join'),
], [edge('to_inner', 'root', 'inner_fork'), edge('to_outer_right', 'root', 'outer_right'),
edge('to_inner_left', 'inner_fork', 'inner_left'), edge('to_inner_right', 'inner_fork', 'inner_right'),
edge('inner_left_join', 'inner_left', 'inner_join'), edge('inner_right_join', 'inner_right', 'inner_join'),
edge('inner_outer', 'inner_join', 'outer_join'), edge('right_outer', 'outer_right', 'outer_join')]),
deps({query: async source => source})).done;
const branch = completed(snapshot);
assert.deepEqual(Object.keys(branch.outputs.inner_join.output).sort(), ['inner_left_join', 'inner_right_join']);
assert.deepEqual(Object.keys(branch.outputs.outer_join.output).sort(), ['inner_outer', 'right_outer']);
assert.deepEqual(Object.keys(branch.outputs).sort(), ['inner_fork', 'inner_join', 'inner_left', 'inner_right', 'outer_join', 'outer_right', 'root']);
});
test('workflow repeated fork loops use a fresh all-merge group on every iteration', {timeout: 5000}, async t => {
const pairs = [];
const snapshot = await start(t, definition([
query('fork', {iteration: ref('vars', 'iteration')}),
query('left', {iteration: ref('outputs', 'fork', 'output')}),
query('right', {iteration: ref('outputs', 'fork', 'output')}), merge('join'),
query('advance', {inputs: ref('outputs', 'join', 'output')}, {save_as: 'iteration'}),
condition('again', ref('vars', 'iteration'), literal(3)), query('done'),
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('advance', 'join', 'advance'),
edge('check', 'advance', 'again'), edge('loop', 'again', 'fork', 'true'), edge('finish', 'again', 'done', 'false')],
{initial_vars: {iteration: 0}}), deps({query: async (source, args) => {
if (source === 'advance') {
const pair = [args.inputs.left_join.result.output, args.inputs.right_join.result.output];
pairs.push(pair); return pair[0] + 1;
}
return args.iteration ?? null;
}})).done;
const branch = completed(snapshot);
assert.deepEqual(pairs, [[0, 0], [1, 1], [2, 2]]);
assert.equal(branch.vars.iteration, 3);
assert.equal(snapshot.steps, 19);
});
test('workflow overlapping executions of the same fork never cross-pair all-merge inputs', {timeout: 5000}, async t => {
const arrivals = new Map();
const all_started = deferred();
const gates = new Map(['left:1', 'left:2', 'right:1', 'right:2'].map(key => [key, deferred()]));
const pairs = [];
const run = start(t, definition([
query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}),
query('fork', {value: ref('last_result', 'output')}),
query('left', {value: ref('outputs', 'fork', 'output')}),
query('right', {value: ref('outputs', 'fork', 'output')}), merge('join'),
query('collect', {inputs: ref('outputs', 'join', 'output')}),
], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'),
edge('a_fork', 'seed_a', 'fork'), edge('b_fork', 'seed_b', 'fork'),
edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('collect', 'join', 'collect')]),
deps({query: async (source, args, signal) => {
if (source === 'left' || source === 'right') {
const key = `${source}:${args.value}`;
arrivals.set(key, true);
if (arrivals.size === 4) all_started.resolve();
await Promise.race([gates.get(key).promise, until_aborted(signal)]);
}
if (source === 'collect') {
const pair = [args.inputs.left_join.result.output, args.inputs.right_join.result.output];
pairs.push(pair); return pair;
}
return args.value ?? null;
}}));
await all_started.promise;
// Release opposite generations first to force a tempting but invalid join.
for (const key of ['left:1', 'right:2', 'right:1', 'left:2']) {
gates.get(key).resolve();
await setImmediate();
}
completed(await run.done, 2);
assert.deepEqual(pairs.sort((a, b) => a[0] - b[0]), [[1, 1], [2, 2]]);
});
test('workflow retry bypassing the join owner preserves generations across repeated all merges', {timeout: 5000}, async t => {
const gates = new Map(['a:1', 'b:2', 'b:1', 'a:2'].map(key => [key, deferred()]));
const finished = new Map([...gates.keys()].map(key => [key, deferred()]));
const retry_started = deferred(), first_retry_join = deferred();
const joins = [], logs = [];
let arrivals = 0;
const config = definition([
query('root'), query('seed1', {value: literal(1)}), query('seed2', {value: literal(2)}),
query('s', {value: ref('last_result', 'output')}),
...['a', 'b'].map(id => query(id, {value: ref('outputs', 's', 'output'), outputs: ref('outputs')})),
merge('join'), {id: 'retry', type: 'goto', config: {max_retries: 1, delay_ms: 0}},
{id: 'end', type: 'log', config: {level: 'info', message: ref('outputs', 's', 'output')}},
], [edge('seed1', 'root', 'seed1'), edge('seed2', 'root', 'seed2'),
edge('seed1_s', 'seed1', 's'), edge('seed2_s', 'seed2', 's'),
edge('s_a', 's', 'a'), edge('s_b', 's', 'b'), edge('a_join', 'a', 'join'), edge('b_join', 'b', 'join'),
edge('join_retry', 'join', 'retry'), edge('retry_a', 'retry', 'a', 'retry'),
edge('retry_b', 'retry', 'b', 'retry'), edge('exhausted', 'retry', 'end', 'exhausted')]);
assert.equal(compile_workflow(config).join_owners.get('join'), 's');
const run = start(t, config, deps({query: async (source, args, signal) => {
if (source !== 'a' && source !== 'b') return args.value ?? null;
const round = Object.hasOwn(args.outputs, 'retry') ? 2 : 1;
if (round === 2) {
if (++arrivals === 4) retry_started.resolve();
await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]);
}
return {generation: args.value, round};
}, log: (level, message) => logs.push({level, message})}), {}, event => {
if (event.type !== 'node_finished') return;
if (event.node_id === 'join') {
const inputs = event.result.output;
joins.push([inputs.a_join.result.output, inputs.b_join.result.output]);
if (joins.length === 3) first_retry_join.resolve();
} else if (event.node_id === 'a' || event.node_id === 'b') {
if (event.result.output.round === 2) {
finished.get(`${event.node_id}:${event.result.output.generation}`).resolve();
}
}
});
await retry_started.promise;
const expected = (generation, round) => Array.from({length: 2}, () => ({generation, round}));
assert.deepEqual(joins.slice().sort((a, b) => a[0].generation - b[0].generation), [expected(1, 1), expected(2, 1)]);
// These arrivals would cross-pair if the first join discarded its lineage.
for (const key of ['a:1', 'b:2']) {
gates.get(key).resolve(); await finished.get(key).promise;
}
await setImmediate();
assert.equal(joins.length, 2);
gates.get('b:1').resolve();
await first_retry_join.promise;
assert.deepEqual(joins[2], expected(1, 2));
gates.get('a:2').resolve();
const snapshot = await run.done;
completed(snapshot, 2);
assert.deepEqual(joins[3], expected(2, 2));
assert.equal(snapshot.steps, 23);
assert.deepEqual(logs.sort((a, b) => a.message.localeCompare(b.message)),
[{level: 'info', message: '1'}, {level: 'info', message: '2'}]);
for (const branch of snapshot.branches) {
assert.deepEqual(branch.retry_counts, {retry: 1});
assert.equal(branch.outputs.retry.error.code, 'RETRY_EXHAUSTED');
assert.equal(branch.outputs.join.output.a_join.result.output.generation, branch.outputs.s.output);
assert.equal(branch.outputs.join.output.b_join.result.output.generation, branch.outputs.s.output);
}
});
test('workflow node timeout aborts and drains its dependency before routing failure', {timeout: 5000}, async t => {
let aborted = false, drained = false;
const snapshot = await start(t, definition([
{...query('slow'), timeout_ms: 100}, query('recover', {code: ref('last_result', 'error', 'code')}),
], [edge('timeout', 'slow', 'recover', 'failure')]), deps({query: async (source, args, signal) => {
if (source !== 'slow') {assert.equal(drained, true); return args;}
try {await until_aborted(signal);} finally {aborted = signal.aborted; await setImmediate(); drained = true;}
}})).done;
const branch = completed(snapshot);
assert.equal(aborted, true);
assert.equal(drained, true);
assert.equal(branch.outputs.slow.error.code, 'NODE_TIMEOUT');
assert.deepEqual(branch.outputs.recover.output, {code: 'NODE_TIMEOUT'});
});
test('workflow partial nested forks retain outer join generations across overlapping executions', {timeout: 5000}, async t => {
const leaves = ['inner_left', 'inner_right', 'outer_right'];
const keys = leaves.flatMap(id => [1, 2].map(value => `${id}:${value}`));
const gates = new Map(keys.map(key => [key, deferred()]));
const finished = new Map(keys.map(key => [key, deferred()]));
const all_started = deferred(), first_join = deferred();
const triples = [];
let arrivals = 0;
const run = start(t, definition([
query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}),
query('outer_fork', {value: ref('last_result', 'output')}),
query('partial', {value: ref('last_result', 'output')}),
...leaves.map(id => query(id, {value: ref('last_result', 'output')})), merge('join'),
query('collect', {inputs: ref('outputs', 'join', 'output')}),
], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'),
edge('a_outer', 'seed_a', 'outer_fork'), edge('b_outer', 'seed_b', 'outer_fork'),
edge('partial', 'outer_fork', 'partial'), edge('outer_right', 'outer_fork', 'outer_right'),
edge('inner_left', 'partial', 'inner_left'), edge('inner_right', 'partial', 'inner_right'),
...leaves.map(id => edge(`${id}_join`, id, 'join')), edge('collect', 'join', 'collect')]),
deps({query: async (source, args, signal) => {
if (leaves.includes(source)) {
if (++arrivals === 6) all_started.resolve();
await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]);
}
if (source === 'collect') {
const triple = leaves.map(id => args.inputs[`${id}_join`].result.output);
triples.push(triple); first_join.resolve(); return triple;
}
return args.value ?? null;
}}), {}, event => {
if (event.type === 'node_finished') finished.get(`${event.node_id}:${event.result.output}`)?.resolve();
});
await all_started.promise;
for (const key of ['inner_left:1', 'inner_right:2', 'outer_right:1']) {
gates.get(key).resolve(); await finished.get(key).promise;
}
await setImmediate();
assert.deepEqual(triples, []);
gates.get('inner_right:1').resolve();
await first_join.promise;
assert.deepEqual(triples, [[1, 1, 1]]);
gates.get('inner_left:2').resolve(); gates.get('outer_right:2').resolve();
completed(await run.done, 2);
assert.deepEqual(triples, [[1, 1, 1], [2, 2, 2]]);
});
test('workflow partial nested forks open fresh outer join generations over repeated rounds', {timeout: 5000}, async t => {
const leaves = ['inner_left', 'inner_right', 'outer_right'];
const triples = [];
const snapshot = await start(t, definition([
query('outer_fork', {value: ref('vars', 'round')}), query('partial', {value: ref('last_result', 'output')}),
...leaves.map(id => query(id, {value: ref('last_result', 'output')})), merge('join'),
query('advance', {inputs: ref('outputs', 'join', 'output')}, {save_as: 'round'}),
condition('again', ref('vars', 'round'), literal(3)), query('done'),
], [edge('partial', 'outer_fork', 'partial'), edge('outer_right', 'outer_fork', 'outer_right'),
edge('inner_left', 'partial', 'inner_left'), edge('inner_right', 'partial', 'inner_right'),
...leaves.map(id => edge(`${id}_join`, id, 'join')), edge('advance', 'join', 'advance'),
edge('check', 'advance', 'again'), edge('loop', 'again', 'outer_fork', 'true'), edge('done', 'again', 'done', 'false')],
{initial_vars: {round: 0}}), deps({query: async (source, args) => {
if (source === 'advance') {
const triple = leaves.map(id => args.inputs[`${id}_join`].result.output);
triples.push(triple); return triple[0] + 1;
}
return args.value ?? null;
}})).done;
assert.equal(completed(snapshot).vars.round, 3);
assert.deepEqual(triples, [[0, 0, 0], [1, 1, 1], [2, 2, 2]]);
assert.equal(snapshot.steps, 25);
});
test('workflow successive nested all joins preserve overlapping fork generations', {timeout: 5000}, async t => {
const gates = new Map(['x:1', 'x:2', 'c:1', 'c:2'].map(key => [key, deferred()]));
const finished = new Map([...gates.keys()].map(key => [key, deferred()]));
const all_started = deferred(), first_join = deferred();
let arrivals = 0;
const pairs = [];
const run = start(t, definition([
query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}),
query('fork', {value: ref('last_result', 'output')}),
...['a', 'b', 'c'].map(id => query(id, {value: ref('outputs', 'fork', 'output')})), merge('inner'),
query('x', {value: ref('outputs', 'fork', 'output')}), merge('outer'),
query('collect', {inputs: ref('outputs', 'outer', 'output')}),
], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'),
edge('a_fork', 'seed_a', 'fork'), edge('b_fork', 'seed_b', 'fork'),
...['a', 'b', 'c'].map(id => edge(`fork_${id}`, 'fork', id)),
edge('a_inner', 'a', 'inner'), edge('b_inner', 'b', 'inner'), edge('inner_x', 'inner', 'x'),
edge('x_outer', 'x', 'outer'), edge('c_outer', 'c', 'outer'), edge('collect', 'outer', 'collect')]),
deps({query: async (source, args, signal) => {
if (source === 'x' || source === 'c') {
if (++arrivals === 4) all_started.resolve();
await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]);
}
if (source === 'collect') {
const pair = [args.inputs.x_outer.result.output, args.inputs.c_outer.result.output];
pairs.push(pair); first_join.resolve(); return pair;
}
return args.value ?? null;
}}), {}, event => {
if (event.type === 'node_finished') finished.get(`${event.node_id}:${event.result.output}`)?.resolve();
});
await all_started.promise;
for (const key of ['x:1', 'c:2']) {
gates.get(key).resolve(); await finished.get(key).promise;
}
await setImmediate();
assert.deepEqual(pairs, []);
gates.get('c:1').resolve();
await first_join.promise;
assert.deepEqual(pairs, [[1, 1]]);
gates.get('x:2').resolve();
completed(await run.done, 2);
assert.deepEqual(pairs, [[1, 1], [2, 2]]);
});
test('workflow all merge accepts equal branch vars with different object key insertion order', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
query('fork'), query('left', {}, {save_as: 'shared'}), query('right', {}, {save_as: 'shared'}), merge('join'),
], [edge('left', 'fork', 'left'), edge('right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]), deps({query: async source => {
if (source === 'left') return {a: 1, nested: {x: 2, y: [3]}};
if (source === 'right') return {nested: {y: [3], x: 2}, a: 1};
return null;
}})).done;
assert.deepEqual(completed(snapshot).vars.shared, {a: 1, nested: {x: 2, y: [3]}});
});
test('workflow all merge keeps one branch write when its sibling retains the inherited value', {timeout: 5000}, async t => {
const branch = completed(await start(t, definition([
query('fork'), query('write', {}, {save_as: 'shared'}), query('unchanged'), merge('join'),
], [edge('write', 'fork', 'write'), edge('unchanged', 'fork', 'unchanged'),
edge('write_join', 'write', 'join'), edge('unchanged_join', 'unchanged', 'join')],
{initial_vars: {shared: {value: 0}, preserved: true}}),
deps({query: async source => source === 'write' ? {value: 1} : null})).done);
assert.deepEqual(branch.vars, {shared: {value: 1}, preserved: true});
});
test('workflow merge conflicts route through merge failure edges with the merge node identity', {timeout: 5000}, async t => {
const logs = [];
const branch = completed(await start(t, definition([
query('fork'), query('left', {}, {save_as: 'shared'}), query('right', {}, {save_as: 'shared'}), merge('join'),
{id: 'recover', type: 'log', config: {level: 'warning', message: ref('last_result', 'error', 'code')}},
], [edge('left', 'fork', 'left'), edge('right', 'fork', 'right'),
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('recover', 'join', 'recover', 'failure')]),
deps({query: async source => source,
log: (level, message) => logs.push({level, message})})).done);
assert.equal(branch.outputs.join.status, 'failure');
assert.equal(branch.outputs.join.error.code, 'MERGE_CONFLICT');
assert.equal(branch.outputs.join.error.node_id, 'join');
assert.deepEqual(logs, [{level: 'warning', message: 'MERGE_CONFLICT'}]);
});
test('workflow schedules at most 16 dependencies and resumes queued work as slots free', {timeout: 5000}, async t => {
const gates = Array.from({length: 32}, deferred);
const started = Array.from({length: 32}, deferred);
let active = 0, peak = 0, calls = 0;
const run = start(t, definition([query('fork'), query('work', {inputs: ref('inputs')})],
gates.map((_, index) => edge(`work_${index}`, 'fork', 'work'))), deps({query: async (source, args, signal) => {
if (source === 'fork') return null;
const index = Number(Object.keys(args.inputs)[0].slice('work_'.length));
calls++; active++; peak = Math.max(peak, active); started[index].resolve();
try {await Promise.race([gates[index].promise, until_aborted(signal)]); return index;}
finally {active--;}
}}));
await Promise.all(started.slice(0, 16).map(item => item.promise));
await setImmediate();
assert.equal(calls, 16);
assert.equal(active, 16);
assert.equal(run.get_snapshot().active_nodes.length, 16);
gates[0].resolve();
await started[16].promise;
assert.equal(calls, 17);
assert.equal(active, 16);
gates.forEach(item => item.resolve());
const snapshot = await run.done;
completed(snapshot, 32);
assert.equal(peak, 16);
assert.equal(active, 0);
assert.equal(calls, 32);
assert.equal(snapshot.steps, 33);
});
test('workflow token bound accepts 256 branches and rejects 257 before dispatching children', {timeout: 5000}, async t => {
for (const count of [256, 257]) {
let children = 0;
const config = definition([query('fork'), query('leaf')],
Array.from({length: count}, (_, index) => edge(`leaf_${index}`, 'fork', 'leaf')));
config.policy.max_steps = 512;
const snapshot = await start(t, config, deps({query: async source => {
if (source === 'leaf') children++;
return null;
}})).done;
if (count === 256) {
completed(snapshot, 256);
assert.equal(children, 256);
assert.equal(snapshot.steps, 257);
} else {
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'BRANCH_LIMIT_EXCEEDED');
assert.equal(children, 0);
assert.equal(snapshot.steps, 1);
}
}
});
test('workflow token bound includes completed branches and tokens waiting at all merges', {timeout: 5000}, async t => {
for (const waiting of [false, true]) {
const nodes = [query('root'), query('leaf'), query('expand'), ...(waiting ? [merge('join')] : [query('sink')])];
const target = waiting ? 'join' : 'sink';
const edges = [
...Array.from({length: 255}, (_, index) => edge(`leaf_${index}`, 'root', 'leaf')),
edge('expand', 'root', 'expand'), edge('expanded_a', 'expand', target), edge('expanded_b', 'expand', target),
...(waiting ? [edge('leaf_join', 'leaf', 'join')] : []),
];
const config = definition(nodes, edges);
config.policy.max_steps = 1024;
const snapshot = await start(t, config).done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'BRANCH_LIMIT_EXCEEDED');
assert.equal(snapshot.error.node_id, 'expand');
assert.equal(snapshot.steps, 257);
assert.equal(snapshot.branches.length, waiting ? 0 : 255);
assert.deepEqual(snapshot.active_nodes, []);
}
});
test('workflow completion scheduling advances a loop beside a pending sibling and drains at the step limit', {timeout: 5000}, async t => {
const pending_started = deferred();
let iterations = 0, drained = false;
const config = definition([query('fork'), query('pending'), query('loop')],
[edge('pending', 'fork', 'pending'), edge('loop', 'fork', 'loop'), edge('again', 'loop', 'loop')]);
config.policy.max_steps = 128;
const snapshot = await start(t, config, deps({query: async (source, _args, signal) => {
if (source === 'fork') return null;
if (source === 'pending') {
pending_started.resolve();
try {await until_aborted(signal);} finally {await setImmediate(); drained = true;}
}
await pending_started.promise;
return ++iterations;
}})).done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'STEP_LIMIT_EXCEEDED');
assert.equal(snapshot.steps, 128);
assert.equal(iterations, 126);
assert.equal(drained, true);
assert.deepEqual(snapshot.active_nodes, []);
});
test('workflow JSON guards reject prototypes, accessors and cycles without invoking getters', () => {
let reads = 0;
const accessor = Object.defineProperty({}, 'secret', {enumerable: true, get() {reads++; return 1;}});
const cyclic = {}; cyclic.self = cyclic;
for (const value of [Object.create({inherited: 1}), JSON.parse('{"__proto__":1}'), accessor, cyclic]) {
assert.throws(() => assert_json(value), {code: 'INVALID_JSON'});
}
const state = {trigger: accessor, vars: Object.create({inherited: 1}), outputs: {}, last_result: null, inputs: {}, retry_counts: {}};
assert.throws(() => resolve_value(ref('trigger', 'secret'), state), {code: 'VALUE_NOT_FOUND'});
assert.throws(() => resolve_value(ref('vars', 'inherited'), state), {code: 'VALUE_NOT_FOUND'});
assert.throws(() => resolve_value(ref('vars', '__proto__'), state), {code: 'INVALID_VALUE_SOURCE'});
assert.throws(() => resolve_value(literal(cyclic), state), {code: 'INVALID_JSON'});
assert.equal(reads, 0);
const shared = {value: [null, true, 2, 'json']};
assert.doesNotThrow(() => assert_json({left: shared, right: shared}));
assert.deepEqual(resolve_value(ref('trigger', 'value', 2), {...state, trigger: shared}), 2);
});
test('workflow code workers normalize oversized and non-JSON results into routable failures', {timeout: 5000}, async t => {
for (const source of ['return "x".repeat(1048577);', 'return undefined;', 'return NaN;',
'const value = {}; value.self = value; return value;']) {
const snapshot = await start(t, definition([
{id: 'code', type: 'code', config: {source}}, query('recover', {code: ref('last_result', 'error', 'code')}),
], [edge('failure', 'code', 'recover', 'failure')])).done;
const branch = completed(snapshot);
assert.equal(branch.outputs.code.status, 'failure');
assert.equal(branch.outputs.code.output, null);
assert.equal(branch.outputs.code.error.code, 'INVALID_JSON');
assert.equal(branch.outputs.code.error.node_id, 'code');
assert.deepEqual(branch.outputs.recover.output, {code: 'INVALID_JSON'});
}
});
test('workflow code worker infinite loop times out and routes NODE_TIMEOUT after termination', {timeout: 5000}, async t => {
const snapshot = await start(t, definition([
{id: 'code', type: 'code', timeout_ms: 150, config: {source: 'while (true) {}'}},
query('recover', {code: ref('last_result', 'error', 'code')}),
], [edge('timeout', 'code', 'recover', 'failure')])).done;
const branch = completed(snapshot);
assert.equal(snapshot.stop_reason, null);
assert.equal(branch.outputs.code.error.code, 'NODE_TIMEOUT');
assert.deepEqual(branch.outputs.recover.output, {code: 'NODE_TIMEOUT'});
});
test('workflow run timeout aborts dependencies and finishes as timed_out', {timeout: 5000}, async t => {
let drained = false;
const config = definition([query('slow')]);
config.policy.timeout_ms = 150;
const snapshot = await start(t, config, deps({query: async (_source, _args, signal) => {
try {await until_aborted(signal);} finally {await setImmediate(); drained = true;}
}})).done;
assert.equal(snapshot.status, 'timed_out');
assert.equal(snapshot.stop_reason, 'timeout');
assert.equal(drained, true);
assert.deepEqual(snapshot.active_nodes, []);
assert.equal(snapshot.branches.length, 0);
});
test('workflow cancellation drains all cooperative fork dependencies before stop or done resolves', {timeout: 5000}, async t => {
const started = Array.from({length: 3}, deferred);
const aborted = Array.from({length: 3}, deferred);
const cleanup = deferred();
let active = 0, drained = 0;
const block = async (index, signal) => {
active++; started[index].resolve();
try {await until_aborted(signal);} finally {
aborted[index].resolve(); await cleanup.promise; active--; drained++;
}
};
t.after(() => cleanup.resolve());
const run = start(t, definition([
query('fork'), fn('function_wait'), query('query_wait'),
{id: 'sleep_wait', type: 'sleep', config: {mode: 'ticks', value: 10}},
], [edge('function', 'fork', 'function_wait'), edge('query', 'fork', 'query_wait'), edge('sleep', 'fork', 'sleep_wait')]),
deps({run_function: (_path, _name, _args, signal) => block(0, signal),
query: (source, _args, signal) => source === 'fork' ? Promise.resolve(null) : block(1, signal),
sleep: (_mode, _value, signal) => block(2, signal)}));
await Promise.all(started.map(item => item.promise));
let stop_finished = false, done_finished = false;
const stopping = run.stop('disconnect').then(() => {stop_finished = true;});
run.done.then(() => {done_finished = true;});
await Promise.all(aborted.map(item => item.promise));
await setImmediate();
assert.equal(run.get_snapshot().status, 'stopping');
assert.equal(active, 3);
assert.equal(stop_finished, false);
assert.equal(done_finished, false);
cleanup.resolve();
await stopping;
const snapshot = await run.done;
assert.equal(snapshot.status, 'cancelled');
assert.equal(snapshot.stop_reason, 'disconnect');
assert.equal(active, 0);
assert.equal(drained, 3);
assert.deepEqual(snapshot.active_nodes, []);
assert.equal(snapshot.branches.length, 0);
await run.stop('manual');
assert.equal(run.get_snapshot().stop_reason, 'disconnect');
});
test('workflow unhandled failure aborts and drains sibling work before done resolves', {timeout: 5000}, async t => {
const started = deferred(), aborted = deferred(), cleanup = deferred();
let drained = false, settled = false;
t.after(() => cleanup.resolve());
const run = start(t, definition([query('fork'), fn('fail'), query('wait')],
[edge('fail', 'fork', 'fail'), edge('wait', 'fork', 'wait')]), deps({
run_function: async () => {await started.promise; return false;},
query: async (source, _args, signal) => {
if (source === 'fork') return null;
started.resolve();
try {await until_aborted(signal);} finally {
aborted.resolve(); await cleanup.promise; drained = true;
}
},
}));
run.done.then(() => {settled = true;});
await aborted.promise;
await setImmediate();
assert.equal(settled, false);
assert.equal(drained, false);
cleanup.resolve();
const snapshot = await run.done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'FUNCTION_RETURNED_FALSE');
assert.equal(drained, true);
assert.deepEqual(snapshot.active_nodes, []);
assert.equal(snapshot.branches.length, 1);
assert.equal(snapshot.branches[0].outputs.wait, undefined);
});
test('workflow max_steps terminates cycles at the configured execution budget', {timeout: 5000}, async t => {
const config = definition([query('loop')], [edge('again', 'loop', 'loop')]);
config.policy.max_steps = 5;
let calls = 0;
const snapshot = await start(t, config, deps({query: async () => ++calls})).done;
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.error.code, 'STEP_LIMIT_EXCEEDED');
assert.equal(snapshot.steps, 5);
assert.equal(calls, 5);
assert.deepEqual(snapshot.active_nodes, []);
});
test('workflow code worker returns JSON from trigger state', {timeout: 5000}, async t => {
const branch = completed(await start(t, definition([
{id: 'code', type: 'code', config: {source: 'return {value: state.trigger.value, initial: state.vars.initial};'}},
], [], {initial_vars: {initial: 8}}), deps(), {value: ['worker', 42]}).done);
assert.deepEqual(branch.outputs.code.output, {value: ['worker', 42], initial: 8});
});
test('workflow cancelling an infinite code loop terminates and drains its worker', {timeout: 5000}, async t => {
const entered = deferred();
const run = start(t, definition([
{id: 'loop', type: 'code', config: {source: 'while (true) {}'}},
]), deps(), {}, event => {
if (event.type === 'node_started' && event.node_id === 'loop') entered.resolve();
});
await entered.promise;
await delay(75);
assert.equal(run.get_snapshot().status, 'running');
await run.stop('manual');
const snapshot = await run.done;
assert.equal(snapshot.status, 'cancelled');
assert.deepEqual(snapshot.active_nodes, []);
assert.equal(snapshot.branches.length, 0);
});
test('workflow schema rejects malformed definitions, references, policies and graph topology', async t => {
const valid = () => definition([query('first'), query('last')], [edge('next', 'first', 'last')]);
const cases = [
['schema version', value => {value.schema_version = 2;}],
['duplicate node', value => {value.nodes.push(structuredClone(value.nodes[0]));}],
['duplicate edge', value => {value.edges.push(structuredClone(value.edges[0]));}],
['dangling source', value => {value.edges[0].source = 'missing';}],
['dangling target', value => {value.edges[0].target = 'missing';}],
['missing entry', value => {value.entry = 'missing';}],
['unreachable node', value => {value.nodes.push(query('unreachable'));}],
['invalid port', value => {value.edges[0].port = 'true';}],
['unknown node type', value => {value.nodes[0].type = 'unknown';}],
['zero step budget', value => {value.policy.max_steps = 0;}],
['fractional step budget', value => {value.policy.max_steps = 1.5;}],
['negative queue', value => {value.policy.max_queue = -1;}],
['invalid busy policy', value => {value.policy.on_busy = 'parallel';}],
['zero run timeout', value => {value.policy.timeout_ms = 0;}],
['zero node timeout', value => {value.nodes[0].timeout_ms = 0;}],
['invalid reference root', value => {value.nodes[0].config.args.x = ref('unknown');}],
['unsafe reference path', value => {value.nodes[0].config.args.x = ref('vars', '__proto__');}],
['negative reference index', value => {value.nodes[0].config.args.x = ref('trigger', -1);}],
['non-JSON literal', value => {value.nodes[0].config.args.x = literal(NaN);}],
['goto without retry target', value => {value.nodes[1] = {id: 'last', type: 'goto', config: {max_retries: 2, delay_ms: 0}};}],
['all merge with one input', value => {value.nodes[1] = merge('last');}],
['unsafe initial var', value => {value.initial_vars = JSON.parse('{"__proto__":1}');}],
];
for (const [name, mutate] of cases) await t.test(name, () => {
const value = valid(); mutate(value);
assert.throws(() => compile_workflow(value));
});
assert.doesNotThrow(() => compile_workflow(valid()));
});
+457
View File
@@ -0,0 +1,457 @@
import assert from 'node:assert/strict';
import {createHook} from 'node:async_hooks';
import {isBuiltin, createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
import {runInThisContext} from 'node:vm';
import test from 'node:test';
import {build} from 'esbuild';
import {tsImport} from 'tsx/esm/api';
const {get_workflow_catalog, validate_workflow_catalog} = await tsImport('../bot/workflow/catalog.ts', import.meta.url);
const {node_registry, start_registry, query_registry} = await tsImport('../bot/workflow/registry.ts', import.meta.url);
const {event_registry, normalize_event} = await tsImport('../bot/workflow/node_action/event_node/registry.ts', import.meta.url);
const {register_event_start} = await tsImport('../bot/workflow/start_node/event_node.ts', import.meta.url);
const {resolve_fields, validate_fields, validate_output} = await tsImport('../bot/workflow/runtime/template.ts', import.meta.url);
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
const {execute_node} = await tsImport('../bot/workflow/runtime/execute.ts', import.meta.url);
const node_names = ['code', 'condition', 'error', 'function', 'goto', 'log', 'merge', 'query', 'sleep'];
const start_names = ['common', 'event', 'timer'];
const event_names = ['chat', 'custom', 'health', 'message', 'spawn', 'whisper'];
const ports = {
function: ['failure', 'success'], query: ['failure', 'success'], sleep: ['failure', 'success'],
log: ['failure', 'success'], code: ['failure', 'success'], merge: ['failure', 'success'],
condition: ['failure', 'false', 'true'], error: ['error', 'failure', 'normal', 'unmatched'],
goto: ['exhausted', 'failure', 'retry'],
};
const names = items => items.map(item => item.name).sort();
const literal = value => ({kind: 'literal', value});
const translation = () => [{lang: 'en', name: 'Test field', description: 'A described test value.'}];
const field = (name, type, extra = {}) => ({name, type, required: false, i18n: translation(), ...extra});
const node_template = (catalog, name) => catalog.nodes.find(node => node.name === name);
const config_field = (catalog, node, name) => node_template(catalog, node).config.find(item => item.name === name);
function definition(type = 'sleep', config = {}, extra = {}) {
return {
schema_version: 1, id: 'catalog_test', name: 'Catalog test', enabled: true,
start: {type: 'common'}, entry: 'entry', nodes: [{id: 'entry', type, config}], edges: [], policy: {},
...extra,
};
}
function described(i18n, path) {
assert.ok(Array.isArray(i18n) && i18n.length > 0, `${path} translations`);
assert.equal(new Set(i18n.map(item => item.lang)).size, i18n.length, `${path} unique languages`);
for (const item of i18n) {
for (const key of ['lang', 'name', 'description']) {
assert.equal(typeof item[key], 'string', `${path}.${key}`);
assert.ok(item[key].trim(), `${path}.${key} cannot be empty`);
}
}
}
function described_fields(fields, path) {
assert.equal(new Set(fields.map(item => item.name)).size, fields.length, `${path} unique fields`);
for (const item of fields) {
described(item.i18n, `${path}.${item.name}`);
assert.equal(typeof item.required, 'boolean');
for (const option of item.options ?? []) described(option.i18n, `${path}.${item.name}.${option.value}`);
if (item.fields) described_fields(item.fields, `${path}.${item.name}`);
if (item.item) described_fields([item.item], `${path}.${item.name}.item`);
}
}
function described_output(output, path) {
described(output.i18n, path);
for (const [name, child] of Object.entries(output.properties ?? {})) described_output(child, `${path}.${name}`);
if (output.items) described_output(output.items, `${path}.items`);
if (output.additional_properties) described_output(output.additional_properties, `${path}.*`);
}
function matches_output(value, output, path = 'output') {
if (value === null && (output.nullable || output.type === 'json' || output.type === 'null')) return;
if (output.type === 'json') return;
if (output.type === 'object') {
assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), path);
for (const [key, child] of Object.entries(value)) {
const descriptor = output.properties?.[key] ?? output.additional_properties;
assert.ok(descriptor, `${path}.${key} must be described`);
matches_output(child, descriptor, `${path}.${key}`);
}
} else if (output.type === 'array') {
assert.ok(Array.isArray(value), path);
assert.ok(output.items, `${path} needs item descriptions`);
value.forEach((child, index) => matches_output(child, output.items, `${path}.${index}`));
} else assert.equal(typeof value, output.type, path);
}
test('runtime fixture ports and output values agree with all nine node declarations', async t => {
const state = {trigger: {}, vars: {}, outputs: {}, last_result: null, inputs: {}, retry_counts: {}};
const failed = {...state, last_result: {status: 'failure', output: false,
error: {code: 'TEST', message: 'fixture failure', node_id: 'prior'}}};
const deps = {
run_function: async () => true, query: async () => ({value: false}), sleep: async () => {},
log() {}, execute_code: async () => [0, false, null],
};
const fixtures = [
['function', {path: 'bot', name: 'chat', args: []}, state, 'success', true],
['query', {source: 'info', args: {}}, state, 'success', {value: false}],
['sleep', {mode: 'milliseconds', value: 0}, state, 'success', null],
['log', {level: 'info', message: literal(false)}, state, 'success', false],
['code', {source: 'return [0, false, null];'}, state, 'success', [0, false, null]],
['condition', {condition: {op: 'eq', left: literal(0), right: literal(0)}}, state, 'true', true],
['condition', {condition: {op: 'eq', left: literal(0), right: literal(false)}}, state, 'false', false],
['error', {}, state, 'normal', null], ['error', {}, failed, 'error', false],
['error', {codes: []}, failed, 'unmatched', false],
['goto', {max_retries: 1, delay_ms: 0}, state, 'retry', null],
['goto', {max_retries: 0, delay_ms: 0}, state, 'exhausted', null],
['merge', {mode: 'any'}, state, 'success', {}],
];
for (const [type, config, branch, port, output] of fixtures) await t.test(`${type}.${port}`, async () => {
const returned = await node_registry[type].execute({id: 'fixture', type, config}, structuredClone(branch), new AbortController().signal, deps);
assert.equal(returned.port, port);
assert.deepEqual(returned.result.output, output);
assert.ok(node_registry[type].config_template.ports.some(item => item.name === returned.port));
matches_output(returned.result.output, node_registry[type].config_template.output, `${type}.output`);
});
for (const type of ['function', 'condition', 'merge']) await t.test(`${type}.failure null output`, async () => {
const returned = await execute_node({id: 'fixture', type, config: {}, timeout_ms: 0}, structuredClone(state), new AbortController().signal, deps);
assert.equal(returned.port, 'failure');
assert.equal(returned.result.status, 'failure');
assert.equal(returned.result.output, null);
matches_output(returned.result.output, node_registry[type].config_template.output, `${type}.failure.output`);
});
});
test('info query output describes the actual value including null and falsy values', () => {
const module = query_registry.info;
for (const value of [null, false, 0, '', [], {nested: [false, 0, null]}]) {
const result = module.query_info({info_manager: {get_info(name) {
assert.equal(name, 'fixture');
return {name: 'fixture', i18n: translation(), value};
}}}, {name: 'fixture'});
assert.deepEqual(result, value);
matches_output(result, module.config_template.output);
}
for (const args of [{}, {name: ''}, {name: 0}]) {
assert.throws(() => resolve_fields(module.config_template.config, args));
assert.throws(() => module.query_info({info_manager: null}, args), {code: 'INVALID_QUERY_ARGUMENTS'});
}
});
test('catalog contains every registered node, start, event and query with recursive descriptions', () => {
const catalog = get_workflow_catalog();
assert.equal(catalog.catalog_version, 1);
assert.deepEqual(names(catalog.nodes), node_names);
assert.deepEqual(names(catalog.start_nodes), start_names);
assert.deepEqual(names(catalog.events), event_names);
assert.deepEqual(names(catalog.queries), ['info']);
for (const [registry, entries] of [
[node_registry, catalog.nodes], [start_registry, catalog.start_nodes],
[event_registry, catalog.events], [query_registry, catalog.queries],
]) {
assert.deepEqual(Object.keys(registry).sort(), names(entries));
for (const [name, module] of Object.entries(registry)) {
assert.equal(module.config_template.name, name);
assert.deepEqual(entries.find(item => item.name === name).config, module.config_template.config);
}
}
for (const item of [...catalog.nodes, ...catalog.start_nodes, ...catalog.events, ...catalog.queries]) {
assert.match(item.version, /^\d+\.\d+\.\d+$/);
described(item.i18n, item.name);
described_fields(item.config, item.name);
described_output(item.output, `${item.name}.output`);
assert.doesNotThrow(() => validate_fields(item.config));
assert.doesNotThrow(() => validate_output(item.output));
}
for (const node of catalog.nodes) {
assert.equal(typeof node_registry[node.name].execute, 'function');
assert.deepEqual(names(node.ports), ports[node.name]);
node.ports.forEach(port => described(port.i18n, `${node.name}.${port.name}`));
}
catalog.events.forEach(event => assert.equal(event.readiness, 'spawned'));
assert.equal(catalog.events.find(event => event.name === 'custom').custom, true);
assert.equal(catalog.queries[0].provider, 'info');
assert.equal(catalog.queries[0].config.find(item => item.name === 'name').provider, 'info');
described_fields(catalog.common.node_fields, 'common.node_fields');
described_fields(catalog.common.policy_fields, 'common.policy_fields');
described_output(catalog.common.result, 'common.result');
described_output(catalog.common.edge_input, 'common.edge_input');
assert.deepEqual(catalog.common.references.map(item => item.root).sort(), ['inputs', 'last_result', 'outputs', 'trigger', 'vars']);
catalog.common.references.forEach(item => described(item.i18n, item.root));
assert.doesNotThrow(() => validate_workflow_catalog(catalog));
});
test('catalog inspection imports and runs without creating Bot, timer or worker resources', async () => {
const compiled = await build({
entryPoints: [fileURLToPath(new URL('../bot/workflow/catalog.ts', import.meta.url))],
bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true,
});
assert.ok(!Object.keys(compiled.metafile.inputs).some(path => /bot\/(instance|registry)|forward_server/.test(path)));
const resources = [];
const hook = createHook({init(_id, type) { resources.push(type); }});
const require = createRequire(import.meta.url);
const module = {exports: {}};
const before = process.getActiveResourcesInfo().sort();
hook.enable();
try {
const load = runInThisContext(`(function(module, exports, require, setTimeout, setInterval) {\n${compiled.outputFiles[0].text}\n})`);
load(module, module.exports,
name => { assert.ok(isBuiltin(name), `unexpected runtime dependency ${name}`); return require(name); },
() => assert.fail('catalog started a timeout'),
() => assert.fail('catalog started an interval'));
for (let index = 0; index < 3; index++) module.exports.get_workflow_catalog({allow_code: index === 1});
} finally { hook.disable(); }
assert.deepEqual(process.getActiveResourcesInfo().sort(), before);
assert.deepEqual(resources.filter(type => /^(Timeout|WORKER|MESSAGEPORT|TCPWRAP|TCPSERVERWRAP|UDPWRAP)$/.test(type)), []);
});
test('catalog results are detached at every nested level and code is disabled unless explicitly enabled', () => {
const baseline = get_workflow_catalog();
const changed = get_workflow_catalog();
node_template(changed, 'sleep').i18n[0].name = 'changed';
config_field(changed, 'sleep', 'mode').options[0].i18n[0].description = 'changed';
config_field(changed, 'function', 'args').default.push(literal(9));
config_field(changed, 'query', 'args').default.changed = literal(false);
node_template(changed, 'merge').output.additional_properties.properties.source.i18n[0].description = 'changed';
changed.events[0].output.properties.event.i18n[0].description = 'changed';
changed.start_nodes[0].config.length = 0;
changed.queries[0].i18n.length = 0;
changed.common.node_fields[0].default = 2;
changed.common.result.properties.status.i18n[0].description = 'changed';
changed.common.references[0].root = 'changed';
node_template(changed, 'code').availability.available = true;
assert.deepEqual(get_workflow_catalog(), baseline);
assert.deepEqual(node_template(baseline, 'code').availability, {available: false, reason: 'CODE_EXECUTION_DISABLED'});
const enabled = get_workflow_catalog({allow_code: true});
assert.deepEqual(node_template(enabled, 'code').availability, {available: true});
enabled.nodes = enabled.nodes.filter(node => node.name !== 'code');
assert.deepEqual(get_workflow_catalog({allow_code: false}), baseline);
assert.equal(node_registry.code.config_template.availability.available, false);
});
test('compile applies documented defaults without mutating caller input or registry templates', () => {
const input = definition();
const original = structuredClone(input);
const catalog = get_workflow_catalog();
const graph = compile_workflow(input);
assert.deepEqual(graph.definition.start, {type: 'common', auto_start: 'never'});
assert.deepEqual(graph.nodes.get('entry').config, {mode: 'milliseconds', value: 1000});
assert.equal(graph.nodes.get('entry').timeout_ms, 30000);
assert.deepEqual(graph.definition.policy, {
max_concurrent_runs: 1, on_busy: 'skip', max_queue: 10, max_steps: 1000, timeout_ms: 60000,
});
assert.deepEqual(input, original);
graph.definition.start.auto_start = 'spawn';
graph.nodes.get('entry').config.value = 2;
graph.definition.policy.max_queue = 999;
assert.deepEqual(input, original);
assert.deepEqual(get_workflow_catalog(), catalog);
assert.equal(compile_workflow(input).nodes.get('entry').config.value, 1000);
const without_policy = definition();
delete without_policy.policy;
assert.deepEqual(compile_workflow(without_policy).definition.policy, resolve_fields(catalog.common.policy_fields, {}));
});
test('compile preserves explicit zero, false and complete valid configs', () => {
const input = definition('sleep', {mode: 'ticks', value: 0}, {
enabled: false, start: {type: 'common', auto_start: 'spawn'}, initial_vars: {zero: 0, flag: false, empty: ''},
policy: {max_concurrent_runs: 2, on_busy: 'queue', max_queue: 0, max_steps: 1, timeout_ms: 1},
});
input.nodes[0].timeout_ms = 1;
const original = structuredClone(input);
assert.deepEqual(compile_workflow(input).definition, original);
assert.deepEqual(input, original);
const fn = compile_workflow(definition('function', {path: 'bot', name: 'chat'}));
assert.deepEqual(fn.nodes.get('entry').config, {path: 'bot', name: 'chat', args: []});
const query = compile_workflow(definition('query', {source: 'info'}));
assert.deepEqual(query.nodes.get('entry').config, {source: 'info', args: {}});
const log = compile_workflow(definition('log', {message: literal(false)}));
assert.deepEqual(log.nodes.get('entry').config, {level: 'info', message: literal(false)});
});
test('compile preserves omitted error filters as match-all and explicit empty filters as match-none', async () => {
const state = {last_result: {status: 'failure', output: false, error: {code: 'TEST', message: 'test', node_id: 'prior'}}};
const signal = new AbortController().signal;
const omitted = compile_workflow(definition('error')).nodes.get('entry');
assert.equal(Object.hasOwn(omitted.config, 'codes'), false);
assert.equal((await node_registry.error.execute(omitted, state, signal, {})).port, 'error');
const empty = compile_workflow(definition('error', {codes: []})).nodes.get('entry');
assert.equal((await node_registry.error.execute(empty, state, signal, {})).port, 'unmatched');
});
test('compile rejects explicit invalid values rather than replacing them with defaults', async t => {
const cases = [
['sleep null', value => { value.nodes[0].config.value = null; }],
['sleep false', value => { value.nodes[0].config.value = false; }],
['sleep string', value => { value.nodes[0].config.value = '1000'; }],
['sleep negative', value => { value.nodes[0].config.value = -1; }],
['sleep fraction', value => { value.nodes[0].config.value = 0.5; }],
['sleep overflow', value => { value.nodes[0].config.value = 2147483648; }],
['invalid select', value => { value.nodes[0].config.mode = 'seconds'; }],
['null config', value => { value.nodes[0].config = null; }],
['array config', value => { value.nodes[0].config = []; }],
['node timeout zero', value => { value.nodes[0].timeout_ms = 0; }],
['node timeout null', value => { value.nodes[0].timeout_ms = null; }],
['invalid start choice', value => { value.start.auto_start = false; }],
['null policy', value => { value.policy = null; }],
['invalid busy choice', value => { value.policy.on_busy = 'replace'; }],
['negative queue', value => { value.policy.max_queue = -1; }],
['queue overflow', value => { value.policy.max_queue = 1001; }],
['zero steps', value => { value.policy.max_steps = 0; }],
['zero run timeout', value => { value.policy.timeout_ms = 0; }],
['zero concurrency', value => { value.policy.max_concurrent_runs = 0; }],
['concurrency overflow', value => { value.policy.max_concurrent_runs = 257; }],
];
for (const [name, mutate] of cases) await t.test(name, () => {
const input = definition(); mutate(input);
const original = structuredClone(input);
assert.throws(() => compile_workflow(input));
assert.deepEqual(input, original);
});
const invalid_nodes = [
['function', {}], ['function', {path: 'bot'}], ['function', {name: 'chat'}],
['function', {path: 'bot', name: 'chat', args: null}],
['function', {path: 'bot', name: 'chat', args: Array(101).fill(literal(0))}],
['query', {}], ['query', {source: 'info', args: []}],
['condition', {}], ['condition', {condition: {op: 'unknown'}}],
['log', {}], ['log', {level: 'debug', message: literal('test')}],
['code', {}], ['code', {source: false}], ['code', {source: 'x'.repeat(100001)}],
];
for (const [index, [type, config]] of invalid_nodes.entries()) await t.test(`${type} required fields and constraints ${index}`, () => {
assert.throws(() => compile_workflow(definition(type, config)));
});
assert.doesNotThrow(() => compile_workflow(definition('code', {source: 'return false;'})));
});
test('resolve_fields fills nested absent values and returns independent defaults', () => {
const child_fields = [field('count', 'number', {default: 7, integer: true, min: 0, max: 10}), field('flag', 'boolean', {default: true})];
const child = field('entry', 'object', {fields: child_fields});
const fields = [
field('count', 'number', {default: 5}), field('flag', 'boolean', {default: true}),
field('label', 'string', {default: 'fallback'}), field('settings', 'object', {fields: child_fields, default: {}}),
field('rows', 'array', {item: child, default: [{}]}), field('lookup', 'map', {item: child, default: {first: {}}}),
field('optional', 'string'),
];
const template_before = structuredClone(fields);
const input = {count: 0, flag: false, label: '', settings: {count: 0, flag: false}, rows: [{}], lookup: {first: {flag: false}}, extra: {ok: false}};
const before = structuredClone(input);
const result = resolve_fields(fields, input);
assert.deepEqual(result, {
count: 0, flag: false, label: '', settings: {count: 0, flag: false}, rows: [{count: 7, flag: true}],
lookup: {first: {count: 7, flag: false}}, extra: {ok: false},
});
assert.equal(Object.hasOwn(result, 'optional'), false);
result.rows[0].count = 9; result.extra.ok = true;
const defaults = resolve_fields(fields, {});
defaults.lookup.first.flag = false;
defaults.rows.push({count: 1, flag: false});
assert.deepEqual(resolve_fields(fields, {}).rows, [{count: 7, flag: true}]);
assert.deepEqual(input, before);
assert.deepEqual(fields, template_before);
for (const value of [null, '0', false, -1, 11, 0.5]) {
assert.throws(() => resolve_fields(child_fields, {count: value}), `invalid explicit count ${value}`);
}
let getters = 0;
const accessor = {get count() { getters++; return 1; }};
assert.throws(() => resolve_fields(child_fields, accessor));
assert.equal(getters, 0);
});
test('normalizers return exactly the keys and types described by event templates', async t => {
const fixtures = [
['message', [{json: {translate: 'chat.type.text', with: ['Alex', 'hello']}}, 'chat', 'sender-id', false],
{event: 'message', username: 'Alex', message: 'hello', plainText: '<Alex> hello', position: 'chat', sender: 'sender-id', verified: false}],
['message', [{text: 'system notice'}],
{event: 'message', username: '', message: 'system notice', plainText: 'system notice', position: '', sender: null, verified: null}],
['chat', ['Alex', 'hello', new Date()], {event: 'chat', args: ['Alex', 'hello', null], username: 'Alex', message: 'hello'}],
['whisper', ['Alex', 'secret'], {event: 'whisper', args: ['Alex', 'secret'], username: 'Alex', message: 'secret'}],
['spawn', [], {event: 'spawn', args: []}], ['health', [], {event: 'health', args: []}],
['custom', [0, false, null, {nested: ['ok']}], {event: 'custom', args: [0, false, null, {nested: ['ok']}]}],
];
for (const [index, [event, args, expected]] of fixtures.entries()) await t.test(`${event} ${index}`, () => {
const value = event_registry[event].normalize(args);
assert.deepEqual(value, expected);
assert.deepEqual(Object.keys(value).sort(), Object.keys(event_registry[event].config_template.output.properties).sort());
matches_output(value, event_registry[event].config_template.output);
assert.deepEqual(normalize_event(event, args), expected);
});
for (const event of ['chat', 'whisper']) {
assert.equal(normalize_event(event, [null, 'message']), undefined);
assert.equal(normalize_event(event, ['Alex', 0]), undefined);
}
assert.equal(normalize_event('message', [{text: 'x'.repeat(65537)}]), undefined);
assert.equal(normalize_event('custom', Array(33).fill(0)), undefined);
});
test('unknown and custom events sanitize unsafe arguments to null and detach JSON arguments', () => {
const cycle = {}; cycle.self = cycle;
let getters = 0;
const accessor = {get value() { getters++; return 1; }};
const nested = {values: [0, false, null]};
const args = [nested, undefined, () => {}, new Date(), NaN, Infinity, cycle, accessor, Symbol('test'), 1n];
const expected = {event: 'unknown_event', args: [nested, ...Array(9).fill(null)]};
const result = normalize_event('unknown_event', args);
assert.deepEqual(result, expected);
matches_output(result, event_registry.custom.config_template.output);
result.args[0].values[0] = 7;
assert.deepEqual(nested, {values: [0, false, null]});
assert.equal(getters, 0);
assert.deepEqual(normalize_event('custom', [null]), {event: 'custom', args: [null]});
assert.deepEqual(normalize_event('constructor', [false]), {event: 'constructor', args: [false]});
assert.deepEqual(normalize_event('any', ['chat', 'Alex', 'hi']), {event: 'any', args: ['chat', 'Alex', 'hi']});
assert.equal(normalize_event('unknown_event', ['x'.repeat(65537)]), undefined);
assert.equal(normalize_event('unknown_event', Array(33).fill(0)), undefined);
});
test('event start ignores workflow_update recursion directly and through any', () => {
const listeners = new Map();
const context = {
on(event, callback) { listeners.set(event, callback); },
off(event, callback) { if (listeners.get(event) === callback) listeners.delete(event); },
};
const received = [];
const config = definition('sleep', {}, {start: {type: 'event', event: 'any'}});
const dispose = register_event_start(context, config, trigger => received.push(trigger));
try {
listeners.get('any')('workflow_update', {run_id: 'test'});
assert.deepEqual(received, []);
listeners.get('any')('custom', {value: false});
assert.deepEqual(received, [{event: 'any', args: ['custom', {value: false}]}]);
assert.throws(() => register_event_start(context,
definition('sleep', {}, {start: {type: 'event', event: 'workflow_update'}}), () => {}));
} finally { dispose(); dispose(); }
assert.equal(listeners.size, 0);
});
test('catalog validator rejects duplicate entries, invalid translations, ports and recursive templates', async t => {
const cases = [
['version', value => { value.catalog_version = 2; }],
...['nodes', 'start_nodes', 'events', 'queries'].map(group => [`duplicate ${group}`, value => { value[group].push(structuredClone(value[group][0])); }]),
['entry version', value => { value.nodes[0].version = 'latest'; }],
['missing translation', value => { value.nodes[0].i18n = []; }],
['duplicate translation', value => { value.nodes[0].i18n.push({...value.nodes[0].i18n[0]}); }],
['blank description', value => { value.nodes[0].i18n[0].description = ' '; }],
['duplicate port', value => { value.nodes[0].ports.push(structuredClone(value.nodes[0].ports[0])); }],
['invalid port name', value => { value.nodes[0].ports[0].name = 'bad port'; }],
['missing failure port', value => { value.nodes[0].ports = value.nodes[0].ports.filter(port => port.name !== 'failure'); }],
['port description', value => { value.nodes[0].ports[0].i18n = []; }],
['duplicate field', value => { value.nodes[0].config.push(structuredClone(value.nodes[0].config[0])); }],
['field description', value => { config_field(value, 'sleep', 'value').i18n[0].description = ''; }],
['numeric default type', value => { config_field(value, 'sleep', 'value').default = false; }],
['numeric default range', value => { config_field(value, 'sleep', 'value').default = -1; }],
['numeric default integer', value => { config_field(value, 'sleep', 'value').default = 0.5; }],
['inverted range', value => { config_field(value, 'sleep', 'value').min = 2147483648; }],
['select default', value => { config_field(value, 'sleep', 'mode').default = 'seconds'; }],
['select choices missing', value => { config_field(value, 'sleep', 'mode').options = []; }],
['select choices duplicate', value => { const options = config_field(value, 'sleep', 'mode').options; options.push(structuredClone(options[0])); }],
['choice description', value => { config_field(value, 'sleep', 'mode').options[0].i18n[0].description = ''; }],
['array item missing', value => { delete config_field(value, 'function', 'args').item; }],
['array item description', value => { config_field(value, 'function', 'args').item.i18n = []; }],
['map item missing', value => { delete config_field(value, 'query', 'args').item; }],
['output description', value => { value.events[0].output.properties.event.i18n = []; }],
['array output item', value => { value.events.find(event => event.name === 'custom').output.properties.args.items.type = 'unknown'; }],
['wrong output items owner', value => { node_template(value, 'sleep').output.items = {type: 'json', i18n: translation()}; }],
['common field default', value => { value.common.node_fields[0].default = 0; }],
['common reference translation', value => { value.common.references[0].i18n = []; }],
];
for (const [name, mutate] of cases) await t.test(name, () => {
const catalog = get_workflow_catalog(); mutate(catalog);
assert.throws(() => validate_workflow_catalog(catalog));
});
});
+239
View File
@@ -0,0 +1,239 @@
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {EventEmitter} from 'node:events';
import {createRequire, isBuiltin} from 'node:module';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
const root = fileURLToPath(new URL('../', import.meta.url));
const require = createRequire(import.meta.url);
async function bundle(mock_catalog = false) {
const compiled = await build({
stdin: {contents: `export * from './bot/forward/stream.ts';
export * from './bot/forward/manager.ts';
export * from './utils/forward_protocol.ts';
export * from './bot/workflow/catalog.ts';
export * from './service/server/update/workflow_catalog.ts';
export {report_logs} from './utils/console_print.ts';`, resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true,
plugins: [{name: 'isolated-node', setup(build) {
build.onResolve({filter: /(?:instance\/index\.ts|^socket\.io-client$|console_print\.ts|path_utils\.ts)$/}, args =>
({path: args.path.includes('instance/') ? 'registry' : args.path === 'socket.io-client' ? 'socket'
: args.path.endsWith('path_utils.ts') ? 'path' : 'console', namespace: 'stub'}));
if (mock_catalog) build.onResolve({filter: /(?:^|\/)workflow\/catalog\.ts$/}, () =>
({path: 'catalog', namespace: 'stub'}));
build.onLoad({filter: /.*/, namespace: 'stub'}, ({path}) => ({loader: 'js', contents: {
registry: 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};',
socket: 'export const io = () => {throw Error("Unexpected real socket")};',
console: 'export const report_logs = []; export const console_print = {success: x => x, log: x => report_logs.push(x)};',
path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};',
catalog: 'let reader; export const set_catalog_reader = value => {reader = value}; export const get_workflow_catalog = (...args) => reader(...args);',
}[path]}));
}}],
});
assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server')));
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
assert.ok(isBuiltin(specifier), `Unexpected external import: ${specifier}`);
return require(specifier);
}, module, module.exports);
return module.exports;
}
const real = await bundle();
const controlled = await bundle(true);
const {BotStream, validate_rpc, validate_workflow_payload, error_ack} = real;
const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code);
const request = (payload = {}, scope = randomUUID(), deadline_at = Date.now() + 1000) => ({
request_id: randomUUID(), correlation_id: randomUUID(), method: 'workflow.catalog', payload,
forward_session_id: scope, deadline_at,
});
function stream(t, api = real, context, now = () => 100) {
const value = new api.BotStream(1, {now, source: {get: () => context, observe: () => () => {}}});
t.after(() => value.dispose());
return value;
}
const sample = () => ({catalog_version: 1, nodes: [{name: 'sample', config: [{name: 'nested', default: {value: 1}}]}],
start_nodes: [], events: [], queries: [], common: {references: []}});
test('catalog RPC accepts only an exact empty payload, including direct stream validation', t => {
const value = stream(t);
assert.equal(validate_rpc(request()).method, 'workflow.catalog');
assert.deepEqual(validate_workflow_payload('workflow.catalog', Object.create(null)), Object.create(null));
let reads = 0;
const accessor = Object.defineProperty({}, 'run_id', {enumerable: true, get() { reads++; return randomUUID(); }});
for (const payload of [undefined, null, [], 1, 'text', new Date(), Object.create({inherited: true}),
{run_id: randomUUID()}, {run_id: undefined}, {allow_code: true}, {extra: true}, {[Symbol('extra')]: true},
Object.defineProperty({}, 'hidden', {value: true}), accessor]) {
const raw = {...request(), payload};
fails(() => validate_rpc(raw));
fails(() => validate_workflow_payload('workflow.catalog', payload));
fails(() => value.workflow_catalog(payload, 200));
}
assert.equal(reads, 0);
fails(() => validate_workflow_payload('workflow.unknown', {}));
assert.deepEqual(validate_workflow_payload('workflow.snapshot', {}), {});
const run_id = randomUUID();
assert.deepEqual(validate_workflow_payload('workflow.snapshot', {run_id}), {run_id});
fails(() => validate_workflow_payload('workflow.snapshot', {run_id, extra: true}));
});
test('real catalog bundles independently and works offline or without any context, returning isolated clones', t => {
const absent = stream(t);
const offline = stream(t, real, Object.assign(new EventEmitter(), {bot_status: 'offline', bot_instance: null}));
const expected = real.get_workflow_catalog();
const result = absent.workflow_catalog({}, 200);
assert.equal(result.catalog_version, 1);
assert.ok(result.nodes.length > 0);
assert.deepEqual(result.nodes.find(node => node.name === 'code').availability,
{available: false, reason: 'CODE_EXECUTION_DISABLED'});
assert.deepEqual(result, expected);
assert.deepEqual(offline.workflow_catalog({}, 200), expected);
assert.notEqual(result, expected);
result.nodes[0].name = 'mutated';
result.common.references.length = 0;
assert.deepEqual(absent.workflow_catalog({}, 200), expected);
});
test('catalog reads pass no trusted code option and clone nested catalog values', t => {
const original = sample();
controlled.set_catalog_reader((...args) => { assert.deepEqual(args, []); return original; });
const value = stream(t, controlled);
const result = value.workflow_catalog({}, 200);
assert.deepEqual(result, original);
result.nodes[0].config[0].default.value = 2;
assert.equal(original.nodes[0].config[0].default.value, 1);
});
test('catalog deadlines, transport and disposal are checked before and after the read', t => {
let now = 100;
let reads = 0;
controlled.set_catalog_reader(() => { reads++; return sample(); });
const value = stream(t, controlled, undefined, () => now);
for (const deadline of [100, 99, NaN, Infinity, 1.5, undefined])
fails(() => value.workflow_catalog({}, deadline), 'TIMEOUT');
const stale = () => { throw Object.assign(Error('SECRET transport'), {code: 'SESSION_STALE'}); };
fails(() => value.workflow_catalog({}, 200, stale), 'SESSION_STALE');
assert.equal(reads, 0);
let checks = 0;
controlled.set_catalog_reader(() => { assert.equal(checks, 1); now = 200; return sample(); });
fails(() => value.workflow_catalog({}, 200, () => checks++), 'TIMEOUT');
assert.equal(checks, 2);
now = 100;
controlled.set_catalog_reader(() => sample());
checks = 0;
fails(() => value.workflow_catalog({}, 200, () => { if (++checks === 2) stale(); }), 'SESSION_STALE');
controlled.set_catalog_reader(() => { value.dispose(); return sample(); });
fails(() => value.workflow_catalog({}, 200), 'CANCELLED');
fails(() => value.workflow_catalog({}, 200), 'CANCELLED');
});
async function relay(t, value) {
const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1,
url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque',
expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000};
const socket = new EventEmitter();
socket.auth = {};
socket.io = {engine: {writeBuffer: []}};
socket.volatile = {emit() {}};
socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); };
socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); };
socket.timeout = () => ({emit(event, _payload, ack) {
const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command;
const data = event === 'forward_node_ready' ? {node_ready: true}
: {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false};
queueMicrotask(() => ack(null, {status: 'success', data}));
}});
const manager = new controlled.NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => value});
t.after(() => manager.stop());
assert.equal((await manager.ensure(command)).status, 'success');
const handler = socket.listeners('relay_request')[0];
return {socket, manager, rpc: (payload = {}, deadline = Date.now() + 1000) => new Promise(resolve =>
handler(request(payload, command.forward_session_id, deadline), resolve))};
}
test('manager dispatches context-free catalog RPC and sanitizes stale transport and read failures', async t => {
const catalog = sample();
let reads = 0;
controlled.set_catalog_reader(() => { reads++; return catalog; });
const value = stream(t, controlled, undefined, Date.now);
const channel = await relay(t, value);
assert.deepEqual(await channel.rpc(), {status: 'success', data: catalog});
assert.deepEqual(await channel.rpc({run_id: randomUUID()}), error_ack('INVALID_REQUEST'));
assert.deepEqual(await channel.rpc({}, Date.now() - 1), error_ack('TIMEOUT'));
assert.equal(reads, 1);
controlled.set_catalog_reader(() => { throw Error('SECRET catalog'); });
assert.deepEqual(await channel.rpc(), error_ack('INTERNAL_ERROR'));
controlled.set_catalog_reader(() => { channel.socket.disconnect(); return catalog; });
assert.deepEqual(await channel.rpc(), error_ack('SESSION_STALE'));
assert.deepEqual(await channel.rpc(), error_ack('SESSION_STALE'));
});
function reporter(t, response = {status: 'success', data: 0}) {
const reports = [];
const logs = controlled.report_logs;
logs.length = 0;
t.after(() => { logs.length = 0; });
const catalog = sample();
controlled.set_catalog_reader((...args) => { assert.deepEqual(args, []); return catalog; });
const socket = {connected: true, timeout(ms) { assert.equal(ms, 5000); return this; },
async emitWithAck(...args) { reports.push(args); return response; }};
const state = {current: true};
return {socket, state, catalog, reports, logs,
run: () => controlled.update_workflow_catalog(socket, 7, () => state.current)};
}
test('catalog reporter emits node metadata, accepts success ACK and supports the default session guard', async t => {
const app = reporter(t);
await app.run();
assert.deepEqual(app.reports, [['update_workflow_catalog', {id: 7, workflow_catalog: app.catalog}]]);
assert.equal(app.logs.length, 1);
assert.equal(app.logs[0], '工作流目录更新成功');
app.socket.connected = false;
await controlled.update_workflow_catalog(app.socket, 7);
assert.equal(app.reports.length, 1);
app.socket.connected = true;
await controlled.update_workflow_catalog(app.socket, 7);
assert.equal(app.reports.length, 2);
});
test('catalog reporter safely rejects malformed/error ACKs, read errors and transport failures', async t => {
for (const response of [null, {}, {status: 'unknown'}, {status: 'error', data: 'SECRET rejection'}]) {
const app = reporter(t, response);
await assert.rejects(app.run(), error => error.message === 'Workflow catalog report failed' && error.cause === undefined);
assert.equal(app.logs.length, 0);
}
const app = reporter(t);
app.socket.emitWithAck = async () => undefined;
await assert.rejects(app.run(), /^Error: Workflow catalog report failed$/);
app.socket.emitWithAck = async () => { throw Error('SECRET timeout'); };
await assert.rejects(app.run(), /^Error: Workflow catalog report failed$/);
controlled.set_catalog_reader(() => { throw Error('SECRET catalog path'); });
await assert.rejects(app.run(), /^Error: Workflow catalog report failed$/);
});
test('catalog reporter skips stale sessions before emission and ignores stale ACKs and rejections', async t => {
for (const change of ['disconnect', 'session']) {
const app = reporter(t);
const stale = () => { if (change === 'disconnect') app.socket.connected = false; else app.state.current = false; };
stale();
await app.run();
assert.equal(app.reports.length, 0);
app.socket.connected = app.state.current = true;
controlled.set_catalog_reader(() => { stale(); return app.catalog; });
await app.run();
assert.equal(app.reports.length, 0);
for (const reject of [false, true]) {
app.socket.connected = app.state.current = true;
controlled.set_catalog_reader(() => app.catalog);
let finish;
app.socket.emitWithAck = () => new Promise((resolve, fail) => { finish = reject ? fail : resolve; });
const task = app.run();
stale();
finish(reject ? Error('SECRET late ACK') : {status: 'success'});
await task;
assert.equal(app.logs.length, 0);
}
}
});
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
const {execute} = await tsImport('../bot/workflow/node/condition_node.ts', import.meta.url);
const {create_event_handler} = await tsImport('../bot/workflow/start_node/event_node.ts', import.meta.url);
const literal = value => ({kind: 'literal', value});
const ref = (...path) => ({kind: 'ref', root: 'trigger', path});
const eq = (left, value) => ({op: 'eq', left, right: literal(value)});
const leaf = name => eq(ref(name), true);
const group = (op, ...items) => ({op, items});
const state = trigger => ({trigger, vars: {}, outputs: {}, last_result: null, inputs: {}, retry_counts: {}});
const truth_table = Array.from({length: 8}, (_, bits) => ({
A: Boolean(bits & 4), B: Boolean(bits & 2), C: Boolean(bits & 1),
}));
function definition(condition, start = {type: 'common', auto_start: 'never'}) {
return {
schema_version: 1, id: 'conditions', name: 'Conditions', enabled: true, start,
entry: 'check', nodes: [{id: 'check', type: 'condition', config: {condition}}], edges: [],
initial_vars: {}, policy: {on_busy: 'skip', max_queue: 0, max_steps: 10, timeout_ms: 5000},
};
}
async function check_result(node, trigger, expected) {
const result = await execute(node, state(trigger), new AbortController().signal);
assert.deepEqual(result, {port: expected ? 'true' : 'false', result: {status: 'success', output: expected}});
}
test('compiled condition node requires all three AND leaves', async t => {
const graph = compile_workflow(definition(group('and', leaf('A'), leaf('B'), leaf('C'))));
for (const trigger of truth_table) await t.test(JSON.stringify(trigger), async () => {
await check_result(graph.nodes.get('check'), trigger, trigger.A && trigger.B && trigger.C);
});
});
test('compiled condition node OR matches the middle leaf and rejects no match', async t => {
const graph = compile_workflow(definition(group('or', leaf('A'), leaf('B'), leaf('C'))));
for (const trigger of [{A: false, B: true, C: false}, {A: false, B: false, C: false}]) {
await t.test(JSON.stringify(trigger), async () => {
await check_result(graph.nodes.get('check'), trigger, trigger.B);
});
}
});
test('compiled condition node preserves mixed nested (A AND B) OR C semantics', async t => {
const condition = group('or', group('and', leaf('A'), leaf('B')), leaf('C'));
const graph = compile_workflow(definition(condition));
for (const trigger of truth_table) await t.test(JSON.stringify(trigger), async () => {
await check_result(graph.nodes.get('check'), trigger, (trigger.A && trigger.B) || trigger.C);
});
});
test('compiled condition node does not resolve irrelevant missing references', async t => {
const missing = eq(ref('missing', 'value'), true);
for (const [label, condition, expected] of [
['AND stops at false', group('and', eq(literal(false), true), missing, missing), false],
['OR stops at true', group('or', eq(literal(true), true), missing, missing), true],
['nested OR skips missing AND leaf', group('or', group('and', leaf('A'), missing), leaf('C')), true],
['NOT preserves short circuit', {op: 'not', item: group('and', leaf('A'), missing)}, true],
]) await t.test(label, async () => {
const graph = compile_workflow(definition(condition));
await check_result(graph.nodes.get('check'), {A: false, C: true}, expected);
});
for (const op of ['and', 'or']) await t.test(`${op} resolves a relevant missing leaf`, async () => {
const condition = group(op, eq(literal(op === 'and'), true), missing);
const graph = compile_workflow(definition(condition));
await assert.rejects(() => execute(graph.nodes.get('check'), state({}), new AbortController().signal),
{code: 'VALUE_NOT_FOUND'});
});
});
for (const event of ['message', 'chat']) {
const args = (username, message) => event === 'message'
? [{json: {translate: 'chat.type.text', with: [{text: username}, {text: message}]}}, 'chat', 'sender-id', true]
: [username, message];
const event_definition = condition => compile_workflow(definition(eq(literal(true), true),
{type: 'event', event, condition})).definition;
test(`compiled ${event} event requires all three normalized field checks`, () => {
const condition = group('and', eq(ref('username'), 'Alice'),
{op: 'contains', left: ref('message'), right: literal('ready')}, eq(ref('event'), event));
const fired = [];
const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger));
handler(...args('Bob', 'ready'));
handler(...args('Alice', 'waiting'));
assert.deepEqual(fired, []);
handler(...args('Alice', 'ready now'));
assert.equal(fired.length, 1);
assert.equal(fired[0].event, event);
assert.equal(fired[0].username, 'Alice');
assert.equal(fired[0].message, 'ready now');
if (event === 'message') {
assert.equal(fired[0].plainText, '<Alice> ready now');
assert.equal(fired[0].position, 'chat');
assert.equal(fired[0].sender, 'sender-id');
assert.equal(fired[0].verified, true);
} else assert.deepEqual(fired[0].args, ['Alice', 'ready now']);
const wrong_event = group('and', ...condition.items.slice(0, 2), eq(ref('event'), 'other'));
create_event_handler(event_definition(wrong_event), trigger => fired.push(trigger))(...args('Alice', 'ready now'));
assert.equal(fired.length, 1, 'the third check must also prevent firing');
});
test(`compiled ${event} event OR matches the middle check and rejects no match`, () => {
const condition = group('or', eq(ref('username'), 'Alice'), eq(ref('message'), 'ready'),
eq(ref('event'), 'other'));
const fired = [];
const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger));
handler(...args('Bob', 'waiting'));
assert.deepEqual(fired, []);
handler(...args('Bob', 'ready'));
assert.equal(fired.length, 1);
assert.equal(fired[0].username, 'Bob');
assert.equal(fired[0].message, 'ready');
});
test(`compiled ${event} event preserves mixed nested conditions`, () => {
const condition = group('or', group('and', eq(ref('username'), 'Alice'), eq(ref('message'), 'ready')),
eq(ref('message'), 'override'));
const fired = [];
const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger));
handler(...args('Alice', 'waiting'));
handler(...args('Bob', 'ready'));
assert.deepEqual(fired, []);
handler(...args('Alice', 'ready'));
handler(...args('Bob', 'override'));
assert.deepEqual(fired.map(({username, message}) => [username, message]), [['Alice', 'ready'], ['Bob', 'override']]);
});
test(`compiled ${event} event short circuits missing references and skips relevant failures`, () => {
const missing = eq(ref('missing', 'value'), true);
const fired = [];
const handler = create_event_handler(event_definition(group('or',
group('and', eq(ref('username'), 'Alice'), missing), eq(ref('message'), 'override'), missing)),
trigger => fired.push(trigger));
handler(...args('Bob', 'override'));
assert.equal(fired.length, 1, 'irrelevant missing refs must not suppress a matching event');
assert.doesNotThrow(() => handler(...args('Alice', 'override')));
assert.doesNotThrow(() => handler(...args('Bob', 'waiting')));
assert.equal(fired.length, 1, 'relevant missing refs must skip the occurrence');
});
}
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import {EventEmitter, getEventListeners} from 'node:events';
import test from 'node:test';
import {register} from 'tsx/esm/api';
const loader = register({namespace: 'workflow-functions-test'});
const tsImport = loader.import;
test.after(() => loader.unregister());
const {create_function_manager, get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url);
const {WorkflowControlFunction} = await tsImport('../bot/function/workflow/shared.ts', import.meta.url);
const names = ['run_once', 'stop', 'trigger_start', 'trigger_stop', 'stop_run', 'reload'];
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
const {init} = await tsImport(`../bot/function/workflow/${name}.ts`, import.meta.url);
return [name, () => new init()];
})));
const turn = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => {
let resolve;
const promise = new Promise(yes => { resolve = yes; });
return {promise, resolve};
};
async function fixture(t, control) {
const bot = new EventEmitter();
bot.inventory = {slots: [{name: 'stone'}, null], selectedItem: null};
const calls = [];
const workflow = {control(request) {
calls.push(request);
return control?.(request) ?? {...request, workflow_id: request.workflow_id ?? null, state: 'accepted'};
}};
const context = {bot_instance: bot, bot_status: 'online', workflow_manager: workflow};
const manager = await create_function_manager(context);
t.after(() => manager.dispose());
return {bot, manager, context, workflow, calls};
}
test('discovery publishes six Chinese workflow functions without exposing execution metadata', async () => {
const category = (await get_function_list()).find(item => item.path === 'workflow');
assert.equal(category.name, 'workflow');
assert.equal(category.i18n[0].lang, 'zh-CN');
assert.ok(category.i18n[0].name && category.i18n[0].description);
assert.deepEqual(category.function_list.map(fn => fn.name).sort(), [...names].sort());
for (const fn of category.function_list) {
assert.equal(fn.i18n.lang, 'zh-CN');
assert.ok(fn.i18n.name && fn.i18n.description);
assert.deepEqual(fn.args.map(arg => [arg.name, arg.type]), [[fn.name === 'stop_run' ? 'run_id' : 'workflow_id', 'string']]);
assert.equal(fn.args[0].i18n.lang, 'zh-CN');
assert.ok(fn.args[0].i18n.name && fn.args[0].i18n.description);
assert.equal(Object.hasOwn(fn, 'execution'), false);
assert.equal(Object.hasOwn(fn.args[0], 'default'), false);
assert.equal(operations[fn.name]().execution, 'workflow_control');
}
});
test('each invocation submits one structured control request with a fresh UUID and no input', async t => {
const {manager, calls} = await fixture(t);
let checks = 0;
for (const name of [...names, ...names]) {
const id = name === 'stop_run' ? 'a347a828-fcdd-4ff5-ae88-c20ea39ca749' : 'workflow_1-x';
assert.equal(await manager.run('workflow', name, [id], () => checks++), true);
const request = calls.at(-1);
const key = name === 'stop_run' ? 'run_id' : 'workflow_id';
assert.deepEqual(Object.keys(request).sort(), ['action', 'command_id', key].sort());
assert.equal(request.action, name);
assert.equal(request[key], id);
assert.match(request.command_id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
}
assert.equal(new Set(calls.map(request => request.command_id)).size, 12);
assert.equal(checks, 12);
});
test('long ordinary execution and a full ordinary queue cannot block offline workflow control', async t => {
const {bot, manager, context, calls} = await fixture(t);
const click = deferred();
t.after(() => click.resolve());
const clicks = [];
bot.clickWindow = slot => { clicks.push(slot); return click.promise; };
let finished = false;
const running = manager.run('inventory', 'slot_to_slot', [0, 1]).then(result => { finished = true; return result; });
await turn();
assert.deepEqual(clicks, [0]);
let queuedChecks = 0;
const queued = Array.from({length: 64}, () => manager.run('inventory', 'slot_to_slot', [0, 1], () => queuedChecks++));
await assert.rejects(manager.run('inventory', 'slot_to_slot', [0, 1]), /队列已满/);
context.bot_status = 'disconnecting';
for (const name of ['stop', 'stop_run', 'reload']) {
const result = manager.run('workflow', name, ['target']);
assert.equal(calls.at(-1).action, name, 'control dispatches synchronously');
assert.equal(await Promise.race([result, turn().then(() => 'blocked')]), true);
}
assert.equal(finished, false);
assert.equal(queuedChecks, 0);
await assert.rejects(manager.run('bot', 'jump', []), /不在线/);
context.bot_status = 'online';
click.resolve();
assert.equal(await running, true);
assert.deepEqual(await Promise.all(queued), Array(64).fill(true));
assert.equal(queuedChecks, 64);
});
test('self-stop acknowledges before its own function node can finish and cancellation drains independently', async t => {
const controller = new AbortController();
const ownNode = deferred();
let stopFinished = false;
const {manager, calls} = await fixture(t, request => {
assert.equal(request.run_id, 'current_run');
// The stop task waits for this very function node, as the real runtime does.
void ownNode.promise.then(() => { controller.abort(); stopFinished = true; });
return {...request, workflow_id: 'self', state: 'accepted'};
});
const result = manager.run('workflow', 'stop_run', ['current_run'], undefined, {signal: controller.signal});
assert.equal(await Promise.race([result, turn().then(() => 'blocked')]), true);
assert.equal(calls.length, 1);
assert.equal(stopFinished, false);
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
ownNode.resolve();
await turn();
assert.equal(stopFinished, true);
});
test('a forged workflow_control flag on a non-workflow function cannot bypass the queue or online check', async t => {
// Simulate an ordinary loaded class declaring the same flag, without changing source files.
Object.defineProperty(Object.prototype, 'execution', {value: 'workflow_control', configurable: true});
try {
const {bot, manager, context, calls} = await fixture(t);
const click = deferred();
t.after(() => click.resolve());
bot.clickWindow = () => click.promise;
const running = manager.run('inventory', 'slot_to_slot', [0, 1]);
await turn();
let before = 0;
const queued = manager.run('inventory', 'slot_to_slot', [0, 1], () => before++);
await turn();
assert.equal(before, 0);
assert.deepEqual(calls, []);
context.bot_status = 'offline';
await assert.rejects(manager.run('bot', 'jump', []), /不在线/);
context.bot_status = 'online';
click.resolve();
assert.equal(await running, true);
assert.equal(await queued, true);
assert.equal(before, 1);
} finally { delete Object.prototype.execution; }
});
test('missing manager, rejected commands and control exceptions return false', async t => {
const {manager, context, workflow, calls} = await fixture(t);
for (const missing of [null, undefined]) {
context.workflow_manager = missing;
for (const name of names) assert.equal(await manager.run('workflow', name, ['target']), false);
}
context.workflow_manager = workflow;
workflow.control = () => { throw Error('not ready'); };
assert.equal(await manager.run('workflow', 'stop', ['target']), false);
workflow.control = () => ({state: 'failed', error: {code: 'NOT_FOUND', message: 'missing'}});
assert.equal(await manager.run('workflow', 'stop', ['target']), false);
workflow.control = () => ({state: 'completed'});
assert.equal(await manager.run('workflow', 'stop', ['target']), true);
assert.deepEqual(calls, []);
for (const name of names) assert.equal(await operations[name]().run(null, ['target']), false);
});
test('IDs reject malformed values and injection strings without calling control', async t => {
const {manager, calls} = await fixture(t);
for (const name of names) {
const fn = operations[name]();
const execution = {workflow: {control() { assert.fail('invalid input reached control'); }}};
for (const args of [null, [], [1], [{}], ['target', {}], ['target', 'extra']])
assert.equal(await fn.run(null, args, execution), false);
for (const id of ['', ' ', '../target', 'a/b', 'a.b', 'a b', 'x'.repeat(101), '__proto__', 'prototype',
'constructor', 'target\n', 'target\0', '${process.exit()}', '";process.exit();//']) {
assert.equal(await fn.run(null, [id], execution), false);
assert.equal(await manager.run('workflow', name, [id]), false);
}
for (const args of [[], [1], ['target', {}]])
await assert.rejects(manager.run('workflow', name, args), /参数/);
}
assert.deepEqual(calls, []);
});
test('permission callback, signal and manager or Bot changes prevent stale control dispatch', async t => {
for (const change of ['deny', 'abort', 'dispose', 'bot', 'manager']) {
await t.test(change, async t => {
const {manager, context, calls} = await fixture(t);
const controller = new AbortController();
const denied = Error('permission denied');
await assert.rejects(manager.run('workflow', 'stop', ['target'], () => {
if (change === 'deny') throw denied;
if (change === 'abort') controller.abort(denied);
if (change === 'dispose') manager.dispose();
if (change === 'bot') context.bot_instance = {};
if (change === 'manager') context.workflow_manager = {control() { assert.fail('replacement used'); }};
}, {signal: controller.signal}), change === 'deny' || change === 'abort' ? error => error === denied : undefined);
assert.deepEqual(calls, []);
});
}
const {manager, calls} = await fixture(t);
const controller = new AbortController();
controller.abort();
await assert.rejects(manager.run('workflow', 'stop', ['target'], () => assert.fail('pre-aborted callback'),
{signal: controller.signal}), {name: 'AbortError'});
assert.deepEqual(calls, []);
});
test('direct control checks cancellation, disposal and identity again after execution', async t => {
for (const change of ['abort', 'dispose', 'bot', 'manager']) {
await t.test(change, async t => {
const {manager, context, workflow, calls} = await fixture(t);
const controller = new AbortController();
workflow.control = request => {
calls.push(request);
if (change === 'abort') controller.abort();
if (change === 'dispose') manager.dispose();
if (change === 'bot') context.bot_instance = null;
if (change === 'manager') context.workflow_manager = null;
return {...request, state: 'accepted'};
};
await assert.rejects(manager.run('workflow', 'stop', ['target'], undefined, {signal: controller.signal}));
assert.equal(calls.length, 1);
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
});
}
});
test('execution context exposes only a bound control capability and rejects later manager replacement', async t => {
const original = WorkflowControlFunction.prototype.run;
let execution;
t.mock.method(WorkflowControlFunction.prototype, 'run', function(bot, args, context) {
execution = context;
return original.call(this, bot, args, context);
});
const {manager, context, workflow, calls} = await fixture(t);
workflow.control = function(request) {
assert.equal(this, workflow);
calls.push(request);
return {...request, state: 'accepted'};
};
assert.equal(await manager.run('workflow', 'reload', ['target']), true);
assert.deepEqual(Object.keys(execution), ['workflow']);
assert.deepEqual(Object.keys(execution.workflow), ['control']);
assert.notEqual(execution.workflow, workflow);
context.workflow_manager = null;
assert.throws(() => execution.workflow.control({command_id: 'stale', action: 'stop', workflow_id: 'target'}), /已变化/);
assert.equal(calls.length, 1);
});
+599
View File
@@ -0,0 +1,599 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {create_workflow_manager} = await tsImport('../bot/workflow/runtime/states_manager.ts', import.meta.url);
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
const {validate_workflow} = await tsImport('../bot/workflow/runtime/validate.ts', import.meta.url);
const turn = () => new Promise(resolve => setImmediate(resolve));
async function until(check) {
for (let i = 0; i < 100; i++) { if (check()) return; await turn(); }
assert.fail('Expected runtime state');
}
function context(ready = true) {
const events = new EventEmitter();
const bot = new EventEmitter();
if (ready) bot.entity = {id: 1};
return {events, bot_instance: bot, bot_status: 'online', info_manager: null,
function_manager: {run: async () => true}, logger: null, log() {},
on: events.on.bind(events), off: events.off.bind(events), emit: events.emit.bind(events)};
}
function definition(id = 'one', start = {type: 'common', auto_start: 'never'}) {
return {schema_version: 1, id, name: id, enabled: true, start, entry: 'entry',
nodes: [{id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1}}], edges: [],
policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}};
}
const state = (manager, id = 'one') => manager.get_runtime_snapshot().workflows.find(item => item.workflow_id === id);
let sequence = 0;
async function command(manager, action, extra = {}) {
const accepted = manager.control({command_id: `cmd_${++sequence}`, action, workflow_id: 'one', ...extra});
assert.equal(accepted.state, 'accepted');
await until(() => manager.get_command(accepted.command_id)?.state !== 'accepted');
return manager.get_command(accepted.command_id);
}
test('apply precompiles all graphs and revisions are immutable, ordered and idempotent', async () => {
const ctx = context();
const original = definition('one', {type: 'event', event: 'hello'});
const manager = create_workflow_manager(ctx, [original], {source_config_revision: 4});
try {
const run = manager.start('one');
manager.start('one');
const before = state(manager);
const invalid = definition('bad'); invalid.entry = 'missing';
await assert.rejects(manager.apply([{...original, name: 'new'}, invalid], 5));
assert.deepEqual(state(manager), before);
assert.equal(ctx.events.listenerCount('hello'), 1);
await manager.apply([structuredClone(original)], 4);
assert.deepEqual(state(manager), before);
await assert.rejects(manager.apply([original], 3), {code: 'STALE_SOURCE_REVISION'});
await assert.rejects(manager.apply([{...original, name: 'new'}], 4), {code: 'SOURCE_REVISION_CONFLICT'});
assert.equal(manager.get_snapshot(run.run_id).ended_at, null);
await manager.apply([original], 5);
assert.equal(manager.get_runtime_snapshot().source_config_revision, 5);
assert.deepEqual(state(manager), before);
} finally { await manager.dispose(); }
});
test('unversioned baseline zero is optional and explicit zero conflicts with changed content', async () => {
const original = definition();
const manager = create_workflow_manager(context(), [original]);
try {
await manager.apply([original], 0);
await assert.rejects(manager.apply([], 0), {code: 'SOURCE_REVISION_CONFLICT'});
await manager.apply([]);
await manager.apply([original]);
assert.equal(state(manager).generation, 2);
for (const revision of [-1, 1.5, NaN, Infinity, '1']) {
await assert.rejects(manager.apply([original], revision), {code: 'INVALID_SOURCE_REVISION'});
}
} finally { await manager.dispose(); }
});
test('changed and deleted workflows detach triggers, discard queues and await cancellation drain', async () => {
const ctx = context();
let release;
const drain = new Promise(resolve => { release = resolve; });
let signal;
ctx.function_manager.run = async (_path, _name, _args, check, options) => {
signal = options.signal;
await drain;
check(); return true;
};
const original = definition('one', {type: 'event', event: 'hello'});
original.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'chat', args: []}}];
const other = definition('other');
const manager = create_workflow_manager(ctx, [original, other]);
try {
const run = manager.start('one');
manager.start('one');
manager.start('other');
await until(() => signal);
let applied = false;
const applying = manager.apply([{...original, name: 'new'}, other], 1).then(() => { applied = true; });
await until(() => signal.aborted);
assert.equal(applied, false);
assert.equal(state(manager).trigger_state, 'reloading');
assert.equal(state(manager).queued_count, 0);
assert.equal(ctx.events.listenerCount('hello'), 0);
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_RELOADING'});
release();
await applying;
assert.equal(state(manager).generation, 2);
assert.equal(state(manager, 'other').generation, 1);
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'updated');
assert.equal(ctx.events.listenerCount('hello'), 1);
await manager.apply([other], 2);
assert.equal(state(manager), undefined);
assert.equal(ctx.events.listenerCount('hello'), 0);
await manager.apply([original, other], 3);
assert.equal(state(manager).generation, 3);
assert.equal(manager.get_snapshots().filter(item => item.workflow_id === 'one').length, 1);
} finally { release(); await manager.dispose(); }
});
for (const action of ['apply', 'reload']) {
for (const type of ['event', 'timer']) {
test(`${action} keeps the ${type} workflow blocked across reconnect until cancellation drains`, async t => {
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
const ctx = context();
let release;
const drain = new Promise(resolve => { release = resolve; });
const calls = [];
ctx.function_manager.run = async (_path, name, _args, check, {signal}) => {
calls.push({name, signal});
if (calls.length === 1) await drain;
check(); return true;
};
const start = type === 'event' ? {type, event: 'hello'} : {type, interval_ms: 100};
const original = definition('one', start);
original.policy.max_concurrent_runs = 2;
original.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'old', args: []}}];
const replacement = structuredClone(original);
replacement.nodes[0].config.name = 'new';
const manager = create_workflow_manager(ctx, [original], {max_concurrent_runs: 2});
try {
const old = manager.start('one');
await until(() => calls.length === 1);
let finished = false;
const transition = action === 'apply' ? manager.apply([replacement], 1)
: command(manager, 'reload', {expected_generation: 1});
const done = transition.then(result => { finished = true; return result; });
await until(() => calls[0].signal.aborted);
ctx.emit('end');
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
ctx.emit('spawn');
assert.equal(finished, false);
assert.equal(state(manager).generation, 1);
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'});
assert.equal(ctx.events.listenerCount('hello'), 0);
assert.equal(state(manager).next_fire_at, null);
ctx.emit('hello');
t.mock.timers.tick(300);
await turn();
assert.equal(calls.length, 1);
assert.equal(manager.get_snapshots().length, 1);
assert.equal(state(manager).queued_count, 0);
release();
const result = await done;
if (action === 'reload') assert.equal(result.state, 'completed');
assert.equal(state(manager).generation, 2);
assert.deepEqual(state(manager).active_run_ids, []);
assert.equal(manager.get_snapshot(old.run_id).stop_reason, action === 'apply' ? 'updated' : 'reloaded');
if (type === 'event') {
assert.equal(ctx.events.listenerCount('hello'), 1);
ctx.emit('hello');
} else {
assert.equal(state(manager).next_fire_at, 1400);
t.mock.timers.tick(100);
}
await until(() => calls.length === 2);
assert.equal(calls[1].name, action === 'apply' ? 'new' : 'old');
await until(() => state(manager).active_run_ids.length === 0);
assert.equal(manager.get_snapshots().length, 2);
} finally { release(); await manager.dispose(); }
});
}
test(`${action} leaves the old entry fenced with an error when draining fails`, async () => {
const ctx = context();
const original = definition('one', {type: 'event', event: 'hello'});
const replacement = {...original, name: 'new'};
const manager = create_workflow_manager(ctx, [original]);
const drain = manager.drain;
manager.drain = async () => { throw Object.assign(new Error('Drain failed'), {code: 'DRAIN_FAILED'}); };
try {
if (action === 'apply') await assert.rejects(manager.apply([replacement], 1), {code: 'DRAIN_FAILED'});
else assert.equal((await command(manager, 'reload')).error.code, 'DRAIN_FAILED');
assert.equal(state(manager).generation, 1);
assert.equal(state(manager).trigger_state, 'error');
assert.equal(state(manager).last_error.code, 'DRAIN_FAILED');
assert.equal(ctx.events.listenerCount('hello'), 0);
ctx.emit('end');
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
ctx.emit('spawn');
assert.equal(ctx.events.listenerCount('hello'), 0);
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'});
manager.drain = drain;
if (action === 'apply') await manager.apply([replacement], 1);
else assert.equal((await command(manager, 'reload')).state, 'completed');
assert.equal(state(manager).generation, 2);
assert.equal(ctx.events.listenerCount('hello'), 1);
} finally { manager.drain = drain; await manager.dispose(); }
});
}
test('common spawn fires once per bot connection and manual stop survives repeated spawn', async () => {
const ctx = context(false);
const config = definition('one', {type: 'common', auto_start: 'spawn'});
const manager = create_workflow_manager(ctx, [config]);
try {
ctx.bot_instance.entity = {id: 1};
ctx.emit('spawn'); ctx.emit('spawn');
assert.equal(manager.get_snapshots().length, 1);
assert.equal(state(manager).accepted_count, 1);
await command(manager, 'stop');
ctx.emit('spawn');
await manager.apply([structuredClone(config)], 1);
assert.equal(manager.get_snapshots().length, 1);
ctx.emit('end'); ctx.emit('spawn');
assert.equal(manager.get_snapshots().length, 1);
ctx.emit('instance_created');
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
ctx.emit('spawn');
assert.equal(manager.get_snapshots().length, 2);
assert.equal(state(manager).accepted_count, 1);
} finally { await manager.dispose(); }
});
test('event once only exhausts on acceptance, detaches before reentrant events, and can restart', async () => {
const ctx = context();
const config = definition('one', {type: 'event', event: 'hello', mode: 'once'});
config.policy.on_busy = 'skip';
const manager = create_workflow_manager(ctx, [config]);
try {
const occupied = manager.start('one');
ctx.emit('hello');
assert.equal(state(manager).accepted_count, 0);
assert.equal(state(manager).trigger_state, 'listening');
assert.equal(ctx.events.listenerCount('hello'), 1);
await manager.stop(occupied.run_id);
const off = manager.on_update(event => { if (event.type === 'run_updated') ctx.emit('hello'); });
ctx.emit('hello');
off();
assert.equal(state(manager).accepted_count, 1);
assert.equal(state(manager).trigger_state, 'exhausted');
assert.equal(ctx.events.listenerCount('hello'), 0);
assert.equal(manager.get_snapshots().length, 2);
assert.equal((await command(manager, 'trigger_start')).state, 'completed');
assert.equal(state(manager).accepted_count, 0);
assert.equal(ctx.events.listenerCount('hello'), 1);
assert.equal((await command(manager, 'trigger_start')).state, 'completed');
assert.equal(ctx.events.listenerCount('hello'), 1);
} finally { await manager.dispose(); }
});
test('queued event acceptance counts and trigger_stop leaves existing run and queue intact', async () => {
const ctx = context();
const config = definition('one', {type: 'event', event: 'hello', mode: 'once'});
const manager = create_workflow_manager(ctx, [config]);
try {
const run = manager.start('one');
ctx.emit('hello');
assert.equal(state(manager).accepted_count, 1);
assert.equal(state(manager).queued_count, 1);
await command(manager, 'trigger_stop');
assert.equal(state(manager).queued_count, 1);
assert.equal(manager.get_snapshot(run.run_id).ended_at, null);
assert.equal((await command(manager, 'stop_run', {run_id: run.run_id})).state, 'completed');
await until(() => manager.get_snapshots().length === 2);
assert.equal(state(manager).trigger_state, 'stopped');
assert.equal(state(manager).active_run_ids.length, 1);
} finally { await manager.dispose(); }
});
test('once event ignores an outer emitter callback already captured before nested exhaustion', async () => {
const ctx = context();
let nested = false;
ctx.on('hello', () => {
if (!nested) { nested = true; ctx.emit('hello'); }
});
const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello', mode: 'once'})]);
try {
ctx.emit('hello');
assert.equal(state(manager).accepted_count, 1);
assert.equal(state(manager).queued_count, 0);
assert.equal(manager.get_snapshots().length, 1);
} finally { await manager.dispose(); }
});
test('concurrent applies and controls execute in submission order with revision checks after drain', async () => {
const ctx = context();
const original = definition();
const manager = create_workflow_manager(ctx, [original]);
try {
const updated = {...original, name: 'version_2'};
const applying = manager.apply([updated], 2);
const accepted = manager.control({command_id: 'after_apply', action: 'reload', workflow_id: 'one', expected_generation: 2});
const stale = manager.apply([original], 1);
await applying;
await assert.rejects(stale, {code: 'STALE_SOURCE_REVISION'});
await until(() => manager.get_command(accepted.command_id).state !== 'accepted');
assert.equal(manager.get_command(accepted.command_id).state, 'completed');
assert.equal(state(manager).generation, 3);
assert.equal(manager.get_runtime_snapshot().source_config_revision, 2);
} finally { await manager.dispose(); }
});
test('offline apply stores new definitions and waits for a new connection to install auto triggers', async t => {
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
const ctx = context();
const config = definition('one', {type: 'event', event: 'old'});
const manager = create_workflow_manager(ctx, [config]);
try {
ctx.emit('end');
await manager.apply([{...config, start: {type: 'timer', interval_ms: 100}}], 1);
assert.equal(ctx.events.listenerCount('old'), 0);
assert.equal(state(manager).trigger_state, 'stopped');
assert.equal(state(manager).next_fire_at, null);
t.mock.timers.tick(1000);
assert.equal(manager.get_snapshots().length, 0);
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
ctx.emit('spawn');
assert.equal(state(manager).trigger_state, 'scheduled');
assert.equal(state(manager).next_fire_at, 2100);
} finally { await manager.dispose(); }
});
test('timers count accepted runs, retry skips, preserve schedules on equal apply, and reset on manual start', async t => {
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
const ctx = context();
const config = definition('one', {type: 'timer', interval_ms: 100, max_runs: 2});
config.policy.on_busy = 'skip';
const manager = create_workflow_manager(ctx, [config]);
try {
assert.equal(state(manager).next_fire_at, 1100);
t.mock.timers.tick(100);
assert.equal(state(manager).accepted_count, 1);
t.mock.timers.tick(100);
assert.equal(state(manager).accepted_count, 1);
assert.equal(state(manager).next_fire_at, 1300);
await manager.apply([structuredClone(config)], 1);
assert.equal(state(manager).next_fire_at, 1300);
const run = state(manager).active_run_ids[0];
await manager.stop(run);
t.mock.timers.tick(100);
assert.equal(state(manager).accepted_count, 2);
assert.equal(state(manager).trigger_state, 'exhausted');
assert.equal(state(manager).next_fire_at, null);
await command(manager, 'trigger_start');
const scheduled = state(manager).next_fire_at;
assert.equal(state(manager).accepted_count, 0);
await command(manager, 'trigger_start');
assert.equal(state(manager).next_fire_at, scheduled);
await command(manager, 'trigger_stop');
await manager.apply([config], 2);
assert.equal(state(manager).trigger_state, 'stopped');
t.mock.timers.tick(1000);
assert.equal(state(manager).accepted_count, 0);
} finally { await manager.dispose(); }
});
test('once timer queues one run and disconnect destroys all event listeners and timers until a new bot spawns', async t => {
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
const ctx = context();
const manager = create_workflow_manager(ctx, [definition('one', {type: 'timer', interval_ms: 100, mode: 'once'}),
definition('event', {type: 'event', event: 'hello'})]);
try {
manager.start('one');
t.mock.timers.tick(100);
assert.equal(state(manager).queued_count, 1);
assert.equal(state(manager).accepted_count, 1);
assert.equal(state(manager).trigger_state, 'exhausted');
ctx.emit('end');
assert.equal(ctx.events.listenerCount('hello'), 0);
assert.equal(state(manager).next_fire_at, null);
assert.equal(state(manager).queued_count, 0);
t.mock.timers.tick(500);
ctx.emit('spawn');
assert.equal(ctx.events.listenerCount('hello'), 0);
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
ctx.emit('spawn');
assert.equal(ctx.events.listenerCount('hello'), 1);
assert.equal(state(manager).accepted_count, 0);
assert.equal(state(manager).next_fire_at, 1700);
await manager.dispose();
t.mock.timers.tick(1000);
assert.equal(ctx.events.eventNames().length, 0);
} finally { await manager.dispose(); }
});
test('auto_start false and unavailable bot preserve stopped trigger state', async () => {
const ctx = context(false);
const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello', auto_start: false})]);
try {
const failed = await command(manager, 'trigger_start');
assert.equal(failed.state, 'failed');
assert.equal(failed.error.code, 'BOT_NOT_READY');
assert.equal(state(manager).trigger_state, 'stopped');
assert.equal(ctx.events.listenerCount('hello'), 0);
assert.equal((await command(manager, 'run_once')).error.code, 'BOT_NOT_READY');
ctx.bot_instance.entity = {id: 1}; ctx.emit('spawn');
assert.equal(ctx.events.listenerCount('hello'), 0);
await command(manager, 'trigger_start');
assert.equal(ctx.events.listenerCount('hello'), 1);
await command(manager, 'stop');
ctx.emit('spawn');
assert.equal(ctx.events.listenerCount('hello'), 0);
} finally { await manager.dispose(); }
});
test('controls return accepted before execution, deduplicate fingerprints and enforce generations', async () => {
const ctx = context();
const manager = create_workflow_manager(ctx, [definition()]);
try {
const request = {command_id: 'once', action: 'run_once', workflow_id: 'one', input: {nested: {x: 1}}};
const accepted = manager.control(request);
assert.equal(accepted.state, 'accepted');
assert.equal(manager.get_snapshots().length, 0);
assert.deepEqual(manager.control(structuredClone(request)), accepted);
assert.throws(() => manager.control({...request, input: {x: 2}}), {code: 'COMMAND_ID_CONFLICT'});
request.input.nested.x = 2;
await until(() => manager.get_command('once').state === 'completed');
const result = manager.get_command('once');
assert.equal(result.result.status, 'started');
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1);
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshot(result.result.run_id).ended_at !== null);
assert.equal(manager.get_snapshot(result.result.run_id).branches[0].trigger.nested.x, 1);
const stale = await command(manager, 'reload', {expected_generation: 99});
assert.equal(stale.error.code, 'WORKFLOW_GENERATION_CONFLICT');
assert.equal(state(manager).generation, 1);
assert.equal((await command(manager, 'trigger_start')).error.code, 'INVALID_TRIGGER_TYPE');
assert.equal((await command(manager, 'stop_run', {run_id: 'missing'})).error.code, 'WORKFLOW_RUN_NOT_FOUND');
} finally { await manager.dispose(); }
});
test('stop clears workflow queue and runs while stop_run leaves the trigger running', async () => {
const ctx = context();
const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello'})]);
try {
const first = manager.start('one'); manager.start('one');
await command(manager, 'stop_run', {run_id: first.run_id});
assert.equal(state(manager).trigger_state, 'listening');
await until(() => state(manager).active_run_ids.length === 1);
ctx.emit('hello');
await command(manager, 'stop');
assert.equal(state(manager).trigger_state, 'stopped');
assert.equal(state(manager).queued_count, 0);
assert.equal(state(manager).active_run_ids.length, 0);
assert.equal(ctx.events.listenerCount('hello'), 0);
} finally { await manager.dispose(); }
});
test('workflow stop rejects direct starts during deferred cancellation drain', async () => {
const ctx = context();
let release;
const drain = new Promise(resolve => { release = resolve; });
let signal;
ctx.function_manager.run = async (_path, _name, _args, check, options) => {
signal = options.signal; await drain; check(); return true;
};
const config = definition();
config.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'chat', args: []}}];
const manager = create_workflow_manager(ctx, [config]);
try {
manager.start('one'); await until(() => signal);
const accepted = manager.control({command_id: 'draining_stop', action: 'stop', workflow_id: 'one'});
await until(() => signal.aborted);
assert.equal(manager.get_command(accepted.command_id).state, 'accepted');
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'});
release();
await until(() => manager.get_command(accepted.command_id).state === 'completed');
assert.equal(state(manager).active_run_ids.length, 0);
assert.equal(state(manager).queued_count, 0);
} finally { release(); await manager.dispose(); }
});
test('reload uses the latest stored graph, cancels and drains, then resets generation and trigger', async () => {
const ctx = context();
const original = definition('one', {type: 'event', event: 'old'});
const manager = create_workflow_manager(ctx, [original]);
try {
await manager.apply([{...original, start: {type: 'event', event: 'new'}}], 1);
const run = manager.start('one'); manager.start('one');
const result = await command(manager, 'reload', {expected_generation: 2});
assert.equal(result.result.generation, 3);
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'reloaded');
assert.equal(state(manager).queued_count, 0);
assert.equal(state(manager).accepted_count, 0);
assert.equal(ctx.events.listenerCount('old'), 0);
assert.equal(ctx.events.listenerCount('new'), 1);
assert.equal(manager.get_runtime_snapshot().source_config_revision, 1);
await manager.apply([{...original, start: {type: 'event', event: 'new'}}], 1);
assert.equal(state(manager).generation, 3);
} finally { await manager.dispose(); }
});
test('a workflow can submit its own stop or reload without waiting on its cancellation drain', async () => {
for (const action of ['stop', 'reload']) {
const ctx = context();
const config = definition();
config.nodes = [{id: 'entry', type: 'function', config: {path: 'workflow', name: action, args: []}}];
let manager;
ctx.function_manager.run = async () => {
assert.equal(manager.control({command_id: 'self', action, workflow_id: 'one'}).state, 'accepted');
return true;
};
manager = create_workflow_manager(ctx, [config]);
try {
manager.start('one');
await until(() => manager.get_command('self')?.state === 'completed');
assert.equal(state(manager).generation, action === 'reload' ? 2 : 1);
assert.equal(state(manager).active_run_ids.length, 0);
} finally { await manager.dispose(); }
}
});
test('self reload loops are limited to ten reloads within ten seconds', async t => {
t.mock.timers.enable({apis: ['Date'], now: 1000});
const ctx = context();
const config = definition('one', {type: 'common', auto_start: 'spawn'});
config.nodes = [{id: 'entry', type: 'function', config: {path: 'workflow', name: 'reload', args: []}}];
let manager;
let count = 0;
ctx.function_manager.run = async () => {
manager.control({command_id: `self_${++count}`, action: 'reload', workflow_id: 'one'});
return true;
};
manager = create_workflow_manager(ctx, [config]);
try {
await until(() => manager.get_command('self_11')?.state === 'failed');
assert.equal(manager.get_command('self_11').error.code, 'WORKFLOW_RELOAD_RATE_LIMIT');
assert.equal(state(manager).generation, 11);
assert.equal(count, 11);
t.mock.timers.tick(10_000);
assert.equal((await command(manager, 'reload')).state, 'completed');
} finally { await manager.dispose(); }
});
test('pending controls are bounded at 32 and completed command history at 100', async () => {
const manager = create_workflow_manager(context(), [definition()]);
try {
for (let i = 0; i < 32; i++) manager.control({command_id: `pending_${i}`, action: 'trigger_stop', workflow_id: 'one'});
assert.throws(() => manager.control({command_id: 'overflow', action: 'stop', workflow_id: 'one'}),
{code: 'WORKFLOW_CONTROL_BUSY'});
await until(() => manager.get_command('pending_31')?.state === 'failed');
for (let i = 0; i < 105; i++) await command(manager, 'trigger_stop');
await turn();
assert.equal(manager.get_runtime_snapshot().commands.length, 100);
assert.equal(manager.get_command('pending_0'), undefined);
} finally { await manager.dispose(); }
});
test('runtime updates reach context and subscribers, snapshots contain no node output and are cloned', async () => {
const ctx = context();
const manager = create_workflow_manager(ctx, [definition()]);
let local = 0, context_events = 0;
ctx.on('workflow_update', event => { if (event.type === 'runtime_updated') context_events++; });
const off = manager.on_update(event => {
if (event.type === 'runtime_updated') { local++; event.snapshot.workflows.length = 0; }
});
try {
manager.start('one', {secret: 'hidden'}); manager.start('one');
await command(manager, 'run_once', {input: {secret: 'hidden'}});
await turn();
const snapshot = manager.get_runtime_snapshot();
assert.ok(local > 0 && context_events > 0);
assert.equal(snapshot.workflows.length, 1);
assert.equal(snapshot.workflows[0].active_run_ids.length, 1);
assert.equal(snapshot.workflows[0].queued_count, 2);
assert.equal(JSON.stringify(snapshot).includes('secret'), false);
assert.equal(JSON.stringify(snapshot).includes('outputs'), false);
snapshot.workflows[0].generation = 999;
assert.equal(state(manager).generation, 1);
const cmd = snapshot.commands[0]; cmd.state = 'failed';
assert.equal(manager.get_command(cmd.command_id).state, 'completed');
} finally { off(); await manager.dispose(); }
});
test('trigger fields validate strictly and metadata defaults compile correctly', () => {
for (const type of ['event', 'timer']) {
const start = type === 'event' ? {type, event: 'hello'} : {type, interval_ms: 100};
const graph = compile_workflow(definition('one', start));
assert.equal(graph.definition.start.auto_start, true);
assert.equal(graph.definition.start.mode, type === 'event' ? 'continuous' : 'repeat');
if (type === 'timer') assert.equal(graph.definition.start.max_runs, null);
for (const auto_start of [null, 0, 'true']) {
assert.throws(() => compile_workflow(definition('one', {...start, auto_start})));
assert.throws(() => validate_workflow(definition('one', {...start, auto_start})));
}
for (const mode of [null, true, 'invalid']) assert.throws(() => compile_workflow(definition('one', {...start, mode})));
}
for (const max_runs of [0, -1, 100001, 1.5, '2', false]) {
assert.throws(() => compile_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs})));
assert.throws(() => validate_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs})));
}
for (const max_runs of [null, 1, 100000]) compile_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs}));
});
+367
View File
@@ -0,0 +1,367 @@
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {EventEmitter} from 'node:events';
import {createRequire, isBuiltin} from 'node:module';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
const root = fileURLToPath(new URL('../', import.meta.url));
const compiled = await build({
stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './bot/forward/manager.ts';
export * from './utils/forward_protocol.ts';`, resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true,
plugins: [{name: 'isolated-node', setup(build) {
build.onResolve({filter: /(?:instance\/index\.ts|^socket\.io-client$|console_print\.ts|path_utils\.ts)$/}, args =>
({path: args.path.includes('instance/') ? 'registry' : args.path === 'socket.io-client' ? 'socket'
: args.path.endsWith('path_utils.ts') ? 'path' : 'console', namespace: 'stub'}));
build.onLoad({filter: /.*/, namespace: 'stub'}, ({path}) => ({loader: 'js', contents: {
registry: 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};',
socket: 'export const io = () => {throw Error("Unexpected real socket")};',
console: 'export const console_print = {success: x => x, log() {}};',
path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};',
}[path]}));
}}],
});
assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server')));
const require = createRequire(import.meta.url);
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
assert.ok(isBuiltin(specifier), `Unexpected external import: ${specifier}`);
return require(specifier);
}, module, module.exports);
const {BotStream, NodeForwardManager, validate_rpc, validate_workflow_payload, error_ack} = module.exports;
const actions = ['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'];
const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code);
const request = (method, payload, scope = randomUUID(), deadline_at = Date.now() + 2000) => ({
request_id: randomUUID(), correlation_id: randomUUID(), method, payload, forward_session_id: scope, deadline_at,
});
const control = (action = 'run_once', context_generation = randomUUID()) => ({
command_id: randomUUID(), context_generation, action,
...(action === 'stop_run' ? {run_id: randomUUID()} : {workflow_id: 'gather'}),
});
function fixture(t) {
let now = 100;
const calls = [];
const command = {command_id: randomUUID(), action: 'run_once', workflow_id: 'gather', state: 'accepted'};
const runtime = {source_config_revision: 7, workflows: [{workflow_id: 'gather', generation: 3,
enabled: true, start_type: 'event', trigger_state: 'listening', accepted_count: 2, max_runs: null,
next_fire_at: null, active_run_ids: [randomUUID()], queued_count: 1, last_error: null}], commands: [command]};
const manager = {
control(input) { calls.push(['control', input]); return command; },
get_command(id) { calls.push(['command', id]); return id === command.command_id ? command : undefined; },
get_runtime_snapshot() { calls.push(['runtime']); return runtime; },
};
const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat() {}}, workflow_manager: manager});
let current = context;
const observers = new Set();
const source = {get: () => current, observe(_id, callback) { observers.add(callback); return () => observers.delete(callback); }};
const stream = new BotStream(1, {source, now: () => now});
t.after(() => stream.dispose());
return {stream, manager, runtime, command, calls, context,
generation: stream.snapshot().context_generation,
set(context) { current = context; for (const listener of observers) listener(context); },
time(value) { now = value; },
};
}
test('hot workflow protocol accepts exact action-specific payloads and safe generations', () => {
for (const action of actions) {
const payload = control(action);
const valid = request('workflow.control', payload);
assert.equal(validate_rpc(valid), valid);
assert.equal(validate_workflow_payload('workflow.control', payload), payload);
if (action !== 'stop_run') for (const expected_generation of [1, Number.MAX_SAFE_INTEGER]) {
assert.equal(validate_rpc(request('workflow.control', {...payload, expected_generation})).payload.expected_generation,
expected_generation);
}
for (const key of ['command_id', 'context_generation', 'action', action === 'stop_run' ? 'run_id' : 'workflow_id']) {
const missing = {...payload}; delete missing[key];
fails(() => validate_rpc(request('workflow.control', missing)));
}
fails(() => validate_rpc(request('workflow.control', {...payload, extra: true})));
fails(() => validate_rpc(request('workflow.control', {...payload, [action === 'stop_run' ? 'workflow_id' : 'run_id']: randomUUID()})));
if (action !== 'run_once') fails(() => validate_rpc(request('workflow.control', {...payload, input: {}})));
if (action === 'stop_run') fails(() => validate_rpc(request('workflow.control', {...payload, expected_generation: 1})));
}
const payload = control();
for (const expected_generation of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '1', null, undefined])
fails(() => validate_rpc(request('workflow.control', {...payload, expected_generation})));
for (const action of ['', 'start', 'delete', 'constructor', null, undefined, 1])
fails(() => validate_rpc(request('workflow.control', {...payload, action})));
for (const workflow_id of ['', '../x', 'a.b', 'a/b', 'a b', 'x'.repeat(101), '__proto__', 'constructor', 'prototype', 1])
fails(() => validate_rpc(request('workflow.control', {...payload, workflow_id})));
assert.equal(validate_rpc(request('workflow.control', {...payload, workflow_id: 'x'.repeat(100)})).method, 'workflow.control');
});
test('runtime and command reads reject extra fields and require both UUID identities', () => {
assert.equal(validate_rpc(request('workflow.runtime', {})).method, 'workflow.runtime');
for (const payload of [undefined, null, [], 1, {workflow_id: 'gather'}, {command_id: randomUUID()},
{[Symbol('extra')]: true}, Object.defineProperty({}, 'hidden', {value: true})])
fails(() => validate_rpc(request('workflow.runtime', payload)));
const payload = {command_id: randomUUID(), context_generation: randomUUID()};
assert.equal(validate_rpc(request('workflow.command', payload)).payload, payload);
for (const key of ['command_id', 'context_generation']) {
for (const value of ['', 'not-a-uuid', 'x'.repeat(36), null, undefined, 1]) {
fails(() => validate_rpc(request('workflow.command', {...payload, [key]: value})));
fails(() => validate_rpc(request('workflow.control', {...control(), [key]: value})));
}
const missing = {...payload}; delete missing[key];
fails(() => validate_rpc(request('workflow.command', missing)));
}
fails(() => validate_rpc(request('workflow.control', {...control('stop_run'), run_id: 'invalid'})));
for (const key of ['workflow_id', 'run_id', 'action', 'input', 'expected_generation', 'extra'])
fails(() => validate_rpc(request('workflow.command', {...payload, [key]: true})));
});
test('run_once input is strict plain JSON with an exact 64 KiB UTF-8 serialized boundary', () => {
const payload = control();
const input = {text: 'x'.repeat(65_536 - Buffer.byteLength(JSON.stringify({text: ''})))};
assert.equal(Buffer.byteLength(JSON.stringify(input)), 65_536);
assert.equal(validate_rpc(request('workflow.control', {...payload, input})).payload.input, input);
fails(() => validate_rpc(request('workflow.control', {...payload, input: {text: input.text + 'x'}})));
const multibyte = {text: '\u00e9'.repeat(32_768)};
assert.ok(JSON.stringify(multibyte).length < 65_536);
assert.ok(Buffer.byteLength(JSON.stringify(multibyte)) > 65_536);
fails(() => validate_rpc(request('workflow.control', {...payload, input: multibyte})));
const cycle = {}; cycle.self = cycle;
const sparse = Array(2);
let deep = {}; for (let index = 0; index < 66; index++) deep = {child: deep};
for (const input of [null, [], 'text', undefined, new Date(), Object.create({inherited: true}), cycle, deep,
{sparse}, {value: NaN}, {value: Infinity}, {value: 1n}, {value: undefined}, {value() {}},
JSON.parse('{"__proto__":1}'), {nested: {constructor: 1}}, {[Symbol('key')]: 1},
Object.defineProperty({}, 'hidden', {value: 1})])
fails(() => validate_rpc(request('workflow.control', {...payload, input})));
const plain = Object.assign(Object.create(null), {nested: {values: [false, 0, null]}});
assert.equal(validate_rpc(request('workflow.control', {...payload, input: plain})).payload.input, plain);
});
test('hot payload descriptors are rejected without invoking enumerable or hidden accessors', async t => {
for (const [method, base, keys] of [
['workflow.control', control(), ['command_id', 'context_generation', 'action', 'workflow_id', 'expected_generation', 'input', 'extra']],
['workflow.command', {command_id: randomUUID(), context_generation: randomUUID()}, ['command_id', 'context_generation', 'extra']],
['workflow.runtime', {}, ['extra']],
]) for (const key of keys) for (const enumerable of [true, false]) {
await t.test(`${method}.${key} enumerable=${enumerable}`, () => {
let reads = 0;
const payload = Object.defineProperty({...base}, key, {enumerable, get() { reads++; return base[key]; }});
fails(() => validate_rpc(request(method, payload)));
assert.equal(reads, 0);
});
}
for (const enumerable of [true, false]) {
let reads = 0;
const input = Object.defineProperty({}, 'secret', {enumerable, get() { reads++; return 1; }});
fails(() => validate_rpc(request('workflow.control', {...control(), input})));
assert.equal(reads, 0);
}
});
test('stream control is synchronous, strips context identity and clones input and accepted command', t => {
const app = fixture(t);
const payload = {...control('run_once', app.generation), expected_generation: 3, input: {nested: {value: 1}}};
app.command.command_id = payload.command_id;
app.manager.control = input => {
app.calls.push(input);
assert.equal(Object.hasOwn(input, 'context_generation'), false);
input.input.nested.value = 2;
return app.command;
};
const result = app.stream.workflow_control(payload, 200);
assert.equal(result instanceof Promise, false);
assert.deepEqual(result, app.command);
assert.notEqual(result, app.command);
assert.equal(payload.input.nested.value, 1);
assert.deepEqual(app.calls[0], {command_id: payload.command_id, action: 'run_once', workflow_id: 'gather',
expected_generation: 3, input: {nested: {value: 2}}});
result.state = 'completed';
assert.equal(app.command.state, 'accepted');
});
test('stream runtime and command reads work offline, deeply clone state and return null for unknown command', t => {
const app = fixture(t);
app.context.bot_status = 'offline'; app.context.bot_instance = null;
const state = app.stream.workflow_runtime({}, 200);
const payload = {command_id: app.command.command_id, context_generation: app.generation};
const accepted = app.stream.workflow_command(payload, 200);
assert.deepEqual(state, app.runtime);
assert.deepEqual(accepted, app.command);
state.workflows[0].active_run_ids.push(randomUUID()); state.commands[0].state = 'failed';
accepted.state = 'completed';
assert.equal(app.runtime.workflows[0].active_run_ids.length, 1);
assert.equal(app.command.state, 'accepted');
app.command.state = 'completed'; app.command.result = {status: 'started', run_id: randomUUID()};
const completed = app.stream.workflow_command(payload, 200);
assert.deepEqual(completed, app.command);
completed.result.run_id = 'mutated';
assert.notEqual(app.command.result.run_id, 'mutated');
app.command.state = 'failed'; delete app.command.result;
app.command.error = {code: 'GENERATION_MISMATCH', message: 'Workflow generation changed'};
const failed = app.stream.workflow_command(payload, 200);
assert.deepEqual(failed, app.command);
failed.error.message = 'mutated';
assert.equal(app.command.error.message, 'Workflow generation changed');
assert.equal(app.stream.workflow_command({...payload, command_id: randomUUID()}, 200), null);
});
test('stale context generations reject control and command lookup before any manager dispatch', t => {
const app = fixture(t);
const original = app.generation;
assert.match(original, /^[a-f0-9-]{36}$/i);
for (const generation of [randomUUID(), original]) {
if (generation === original) {
app.set(Object.assign(new EventEmitter(), app.context));
assert.notEqual(app.stream.snapshot().context_generation, original);
}
fails(() => app.stream.workflow_control(control('run_once', generation), 200), 'CANCELLED');
fails(() => app.stream.workflow_command({command_id: app.command.command_id, context_generation: generation}, 200), 'CANCELLED');
}
assert.deepEqual(app.calls, []);
});
test('hot stream APIs reject invalid payloads, missing managers and unavailable optional controls', async t => {
for (const name of ['runtime', 'control', 'command']) await t.test(name, t => {
const app = fixture(t);
const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation)
: {command_id: app.command.command_id, context_generation: app.generation};
const invoke = (input = payload) => app.stream[`workflow_${name}`](input, 200);
fails(() => invoke({...payload, extra: true}));
assert.deepEqual(app.calls, []);
app.context.workflow_manager = null;
fails(invoke, 'INTERNAL_ERROR');
app.context.workflow_manager = {};
fails(invoke, 'INTERNAL_ERROR');
app.set(undefined);
fails(invoke, 'NO_CONTEXT');
app.stream.dispose();
fails(invoke, 'CANCELLED');
});
});
test('hot stream APIs enforce deadlines, transport and identity before and after synchronous dispatch', async t => {
for (const name of ['runtime', 'control', 'command']) {
for (const change of ['deadline', 'context', 'instance', 'manager', 'disposed', 'transport']) {
await t.test(`${name}: ${change}`, t => {
const app = fixture(t);
const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation)
: {command_id: app.command.command_id, context_generation: app.generation};
let stale = false;
const transport = () => { if (stale) throw Object.assign(Error('SECRET transport'), {code: 'SESSION_STALE'}); };
const invoke = deadline => app.stream[`workflow_${name}`](payload, deadline, transport);
for (const deadline of [100, 99, NaN, Infinity, 1.5, undefined]) fails(() => invoke(deadline), 'TIMEOUT');
stale = true;
fails(() => invoke(200), 'SESSION_STALE');
assert.deepEqual(app.calls, []);
stale = false;
const key = {runtime: 'get_runtime_snapshot', control: 'control', command: 'get_command'}[name];
let dispatched = 0;
app.manager[key] = () => {
dispatched++;
if (change === 'deadline') app.time(200);
if (change === 'context') app.set(Object.assign(new EventEmitter(), app.context));
if (change === 'instance') app.context.bot_instance = {};
if (change === 'manager') app.context.workflow_manager = {};
if (change === 'disposed') app.stream.dispose();
if (change === 'transport') stale = true;
return name === 'runtime' ? app.runtime : app.command;
};
fails(() => invoke(200), change === 'deadline' ? 'TIMEOUT' : change === 'transport' ? 'SESSION_STALE' : 'CANCELLED');
assert.equal(dispatched, 1);
});
}
}
});
async function relay(t, stream) {
const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1,
url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque',
expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000};
const socket = new EventEmitter();
socket.auth = {}; socket.io = {engine: {writeBuffer: []}}; socket.volatile = {emit() {}};
socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); };
socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); };
socket.timeout = () => ({emit(event, _payload, ack) {
const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command;
const data = event === 'forward_node_ready' ? {node_ready: true}
: {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false};
queueMicrotask(() => ack(null, {status: 'success', data}));
}});
const manager = new NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => stream});
t.after(() => manager.stop());
assert.equal((await manager.ensure(command)).status, 'success');
return {socket, manager, rpc: (method, payload, deadline = Date.now() + 2000) => new Promise(resolve =>
socket.emit('relay_request', request(method, payload, command.forward_session_id, deadline), resolve))};
}
test('real NodeForwardManager dispatches accepted controls, command polling and runtime in invocation order', async t => {
const app = fixture(t); app.time(Date.now());
const channel = await relay(t, app.stream);
const snapshot = await channel.rpc('bot.snapshot', {});
assert.equal(snapshot.status, 'success');
const generation = snapshot.data.context_generation;
assert.equal(generation, app.generation);
const payload = {...control('run_once', generation), expected_generation: 3, input: {value: 1}};
app.command.command_id = payload.command_id;
assert.deepEqual(await channel.rpc('workflow.control', payload), {status: 'success', data: app.command});
assert.deepEqual(await channel.rpc('workflow.command', {command_id: payload.command_id, context_generation: generation}),
{status: 'success', data: app.command});
app.command.state = 'completed'; app.command.result = {status: 'queued', request_id: randomUUID()};
app.context.bot_status = 'offline'; app.context.bot_instance = null;
const completed = await channel.rpc('workflow.command', {command_id: payload.command_id, context_generation: generation});
assert.deepEqual(completed, {status: 'success', data: app.command});
completed.data.result.request_id = 'mutated';
const runtime = await channel.rpc('workflow.runtime', {});
assert.deepEqual(runtime, {status: 'success', data: app.runtime});
runtime.data.workflows[0].active_run_ids.length = 0;
assert.equal(app.runtime.workflows[0].active_run_ids.length, 1);
assert.deepEqual(await channel.rpc('workflow.command', {command_id: randomUUID(), context_generation: generation}),
{status: 'success', data: null});
assert.deepEqual(app.calls.map(([name]) => name), ['control', 'command', 'command', 'runtime', 'command']);
assert.deepEqual(app.calls[0][1], {command_id: payload.command_id, action: 'run_once', workflow_id: 'gather',
expected_generation: 3, input: {value: 1}});
});
test('Node dispatch rejects malformed, stale-context and expired hot requests before manager invocation', async t => {
const app = fixture(t); app.time(Date.now());
const channel = await relay(t, app.stream);
for (const [method, payload, expected, deadline] of [
['workflow.runtime', {extra: true}, 'INVALID_REQUEST'],
['workflow.control', {...control('trigger_stop', app.generation), input: {}}, 'INVALID_REQUEST'],
['workflow.control', control(), 'CANCELLED'],
['workflow.command', {command_id: app.command.command_id, context_generation: randomUUID()}, 'CANCELLED'],
['workflow.runtime', {}, 'TIMEOUT', Date.now() - 1],
['workflow.control', control('reload', app.generation), 'TIMEOUT', Date.now() - 1],
['workflow.command', {command_id: app.command.command_id, context_generation: app.generation}, 'TIMEOUT', Date.now() - 1],
]) assert.deepEqual(await channel.rpc(method, payload, deadline), error_ack(expected));
assert.deepEqual(app.calls, []);
});
test('Node sanitizes workflow manager failures and retains only approved control error codes', async t => {
const app = fixture(t); app.time(Date.now());
const channel = await relay(t, app.stream);
for (const [method, key, payload, code] of [
['workflow.runtime', 'get_runtime_snapshot', {}, 'INTERNAL_ERROR'],
['workflow.command', 'get_command', {command_id: app.command.command_id, context_generation: app.generation}, 'INTERNAL_ERROR'],
['workflow.control', 'control', control('run_once', app.generation), 'INVALID_REQUEST'],
]) {
app.manager[key] = () => { throw Error('SECRET manager /private/path password'); };
assert.deepEqual(await channel.rpc(method, payload), error_ack(code));
}
app.manager.control = () => { throw Object.assign(Error('SECRET rate limit'), {code: 'RATE_LIMITED'}); };
assert.deepEqual(await channel.rpc('workflow.control', control('reload', app.generation)), error_ack('RATE_LIMITED'));
app.manager.control = () => { throw Object.assign(Error('SECRET custom error'), {code: 'PRIVATE_MANAGER_CODE'}); };
assert.deepEqual(await channel.rpc('workflow.control', control('stop', app.generation)), error_ack('INVALID_REQUEST'));
});
test('Node transport guards reject hot results when the relay disconnects during manager dispatch', async t => {
for (const name of ['runtime', 'control', 'command']) await t.test(name, async t => {
const app = fixture(t); app.time(Date.now());
const channel = await relay(t, app.stream);
const key = {runtime: 'get_runtime_snapshot', control: 'control', command: 'get_command'}[name];
const payload = name === 'runtime' ? {} : name === 'control' ? control('run_once', app.generation)
: {command_id: app.command.command_id, context_generation: app.generation};
app.manager[key] = () => { channel.socket.disconnect(); return name === 'runtime' ? app.runtime : app.command; };
assert.deepEqual(await channel.rpc(`workflow.${name}`, payload), error_ack('SESSION_STALE'));
});
});
+173
View File
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {EventEmitter} from 'node:events';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
const compiled = await build({
entryPoints: [fileURLToPath(new URL('../bot/info/bot/workflow_states.ts', import.meta.url))],
bundle: true, platform: 'node', format: 'cjs', write: false,
});
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
assert.fail(`Unexpected workflow info runtime dependency: ${specifier}`);
}, module, module.exports);
const WorkflowStates = module.exports.init;
function fixture(t, legacy = false) {
const command = {command_id: randomUUID(), action: 'trigger_start', workflow_id: 'gather', state: 'accepted'};
const runtime = {source_config_revision: 5, workflows: [{workflow_id: 'gather', generation: 2, enabled: true,
start_type: 'event', trigger_state: 'listening', accepted_count: 0, max_runs: null,
next_fire_at: null, active_run_ids: [], queued_count: 0, last_error: null}], commands: [command]};
const run = {run_id: randomUUID(), workflow_id: 'gather', status: 'running', steps: 1, active_nodes: ['query'],
started_at: 1, ended_at: null, stop_reason: null, error: null,
...(!legacy ? {trigger: {secret: 'PRIVATE_RUN_TRIGGER'}, trigger_available: true} : {}),
branches: [{trigger: {secret: 'PRIVATE_TRIGGER'}, vars: {secret: 'PRIVATE_VARS'},
outputs: {secret: 'PRIVATE_OUTPUT'}, inputs: {secret: 'PRIVATE_INPUT'}, last_result: null, retry_counts: {}}]};
const runs = [run];
const listeners = new Set();
let unsubscribes = 0;
const source = {get_snapshots: () => runs,
...(!legacy ? {get_runtime_snapshot: () => runtime} : {}),
on_update(callback) {
listeners.add(callback);
return () => { unsubscribes++; listeners.delete(callback); };
},
};
const bot = new EventEmitter();
const info = new WorkflowStates(bot, {workflow_source: source});
t.after(() => info.dispose());
return {info, bot, source, runtime, command, run, runs, listeners,
unsubscribes: () => unsubscribes,
emit(event) { for (const listener of [...listeners]) listener(event); },
};
}
test('workflow_states reads runtime workflows and commands and excludes run branch state from streamed info', t => {
t.mock.timers.enable({apis: ['setTimeout']});
const app = fixture(t);
assert.equal(app.info.name, 'workflow_states');
assert.equal(app.bot.listenerCount('end'), 0);
assert.equal(app.listeners.size, 1);
const {branches, trigger, ...summary} = app.run;
const initial = app.info.get_info();
assert.deepEqual(initial, {available: true, ...app.runtime, runs: [summary]});
assert.equal(initial.runs[0].trigger_available, true);
assert.equal(Object.hasOwn(initial.runs[0], 'trigger'), false);
assert.equal(Object.hasOwn(initial.runs[0], 'branches'), false);
assert.equal(JSON.stringify(initial).includes('PRIVATE_'), false);
initial.workflows[0].active_run_ids.push('mutated');
initial.commands[0].state = 'failed'; initial.runs[0].active_nodes.push('mutated');
assert.deepEqual(app.runtime.workflows[0].active_run_ids, []);
assert.equal(app.command.state, 'accepted');
assert.deepEqual(app.run.active_nodes, ['query']);
const updates = [];
app.info.on_update(() => { throw Error('listener failure'); });
app.info.on_update(value => { value.commands[0].state = 'failed'; value.runs.length = 0; });
app.info.on_update(value => updates.push(value));
app.run.status = 'completed'; app.run.ended_at = 2; app.run.active_nodes = [];
app.command.state = 'completed'; app.command.result = {generation: 2};
app.emit({type: 'run_updated', snapshot: app.run});
assert.equal(updates.length, 0);
t.mock.timers.tick(250);
assert.equal(updates.length, 1);
assert.equal(updates[0].runs[0].status, 'completed');
assert.equal(updates[0].commands[0].state, 'completed');
assert.equal(Object.hasOwn(updates[0].runs[0], 'branches'), false);
assert.equal(Object.hasOwn(updates[0].runs[0], 'trigger'), false);
assert.equal(updates[0].runs[0].trigger_available, true);
assert.equal(JSON.stringify(updates[0]).includes('PRIVATE_'), false);
updates[0].commands[0].result.generation = 99;
assert.equal(app.info.get_info().commands[0].result.generation, 2);
});
test('runtime_updated publishes trigger and command changes with no runs and ignores node-only events', t => {
t.mock.timers.enable({apis: ['setTimeout']});
const app = fixture(t);
app.runs.length = 0;
const updates = [];
const unsubscribe = app.info.on_update(value => updates.push(value));
assert.deepEqual(updates, []);
app.runtime.source_config_revision = 6;
app.runtime.workflows[0].trigger_state = 'stopped';
app.command.state = 'completed'; app.command.result = {generation: 2};
app.emit({type: 'runtime_updated', snapshot: app.runtime});
t.mock.timers.tick(250);
assert.deepEqual(updates, [{available: true, ...app.runtime, runs: []}]);
app.emit({type: 'node_started', run_id: app.run.run_id, node_id: 'query', execution: 1});
app.emit({type: 'node_finished', run_id: app.run.run_id, node_id: 'query', execution: 1,
result: {status: 'success', output: true}});
assert.equal(updates.length, 1);
unsubscribe(); unsubscribe();
app.emit({type: 'runtime_updated', snapshot: app.runtime});
assert.equal(updates.length, 1);
});
test('workflow info disposes only its own source subscription once and preserves its last detached snapshot', t => {
const app = fixture(t);
const updates = [];
app.info.on_update(value => updates.push(value));
const late = [...app.listeners][0];
const before = app.info.get_info();
app.info.dispose(); app.info.dispose();
assert.equal(app.unsubscribes(), 1);
assert.equal(app.listeners.size, 0);
late({type: 'runtime_updated', snapshot: app.runtime});
app.emit({type: 'run_updated', snapshot: app.run});
assert.deepEqual(updates, []);
assert.deepEqual(app.info.get_info(), before);
const detached = app.info.get_info(); detached.workflows.length = 0;
assert.deepEqual(app.info.get_info(), before);
assert.throws(() => app.info.on_update(() => {}), /disposed/);
});
test('workflow info preserves optional legacy source fallback and distinguishes absent from empty sources', t => {
const missing = new WorkflowStates(new EventEmitter());
t.after(() => missing.dispose());
assert.deepEqual(missing.get_info(), {available: false, source_config_revision: 0, workflows: [], commands: [], runs: []});
const app = fixture(t, true);
const {branches, ...summary} = app.run;
assert.deepEqual(app.info.get_info(), {available: true, source_config_revision: 0, workflows: [], commands: [],
runs: [{...summary, trigger_available: false}]});
app.runs.length = 0;
app.emit({type: 'run_updated', snapshot: app.run});
assert.deepEqual(app.info.get_info(), {available: true, source_config_revision: 0, workflows: [], commands: [], runs: []});
});
test('live state includes only 20 completed commands while preserving accepted controls', t => {
const app = fixture(t);
const commands = Array.from({length: 100}, () => ({command_id: randomUUID(), action: 'run_once',
workflow_id: 'gather', state: 'completed'}));
const pending = [app.command, {...app.command, command_id: randomUUID()}];
app.runtime.commands = [pending[0], ...commands, pending[1]];
app.emit({type: 'runtime_updated', snapshot: app.runtime});
const state = app.info.get_info();
assert.equal(state.commands.length, 22);
assert.deepEqual(state.commands.filter(command => command.state === 'completed'), commands.slice(-20));
assert.deepEqual(state.commands.filter(command => command.state === 'accepted'), pending);
assert.equal(app.runtime.commands.length, 102);
});
test('workflow pushes coalesce a burst into its latest snapshot and skip unchanged duplicates', t => {
t.mock.timers.enable({apis: ['setTimeout']});
const app = fixture(t), updates = [];
app.info.on_update(value => updates.push(value));
let baseline_bytes = 0;
for (let index = 1; index <= 1000; index++) {
app.run.steps = index;
app.emit({type: 'run_updated', snapshot: app.run});
baseline_bytes += Buffer.byteLength(JSON.stringify(app.info.get_info()));
}
assert.equal(updates.length, 0);
assert.equal(app.info.get_info().runs[0].steps, 1000);
t.mock.timers.tick(250);
assert.equal(updates.length, 1);
assert.equal(updates[0].runs[0].steps, 1000);
const optimized_bytes = Buffer.byteLength(JSON.stringify(updates[0]));
assert.ok(optimized_bytes < baseline_bytes / 100);
for (let index = 0; index < 1000; index++) app.emit({type: 'runtime_updated', snapshot: app.runtime});
t.mock.timers.tick(250);
assert.equal(updates.length, 1);
t.diagnostic(`Synthetic 1000-update burst: ${baseline_bytes} JSON bytes -> ${optimized_bytes} JSON bytes; duplicate burst: 0 extra pushes`);
});
+732
View File
@@ -0,0 +1,732 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {create_workflow_manager} = await tsImport('../bot/workflow/runtime/states_manager.ts', import.meta.url);
const {init_workflow} = await tsImport('../bot/workflow/init.ts', import.meta.url);
const {parse_message_trigger} = await tsImport('../bot/workflow/node_action/event_node/message.ts', import.meta.url);
function context(ready = true) {
const events = new EventEmitter();
const bot = new EventEmitter();
if (ready) bot.entity = {id: 1};
return {
bot_instance: bot, bot_status: 'online', function_manager: {run: async () => true}, info_manager: null,
workflow_manager: null, bot_config: {}, logger: null, log() {},
on: events.on.bind(events), off: events.off.bind(events),
emit: events.emit.bind(events), events,
};
}
function definition(id = 'one', node = {id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1}}, extra = {}) {
return {
schema_version: 1, id, name: id, enabled: true, start: {type: 'common', auto_start: 'never'},
entry: 'entry', nodes: [node], edges: [],
policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}, ...extra,
};
}
function deferred() {
let resolve;
const promise = new Promise(finish => { resolve = finish; });
return {promise, resolve};
}
function function_definition(id) {
return definition(id, {id: 'entry', type: 'function', config: {path: 'bot', name: id, args: []}});
}
const turn = () => new Promise(resolve => setImmediate(resolve));
async function until(check) {
for (let index = 0; index < 100; index++) {
if (check()) return;
await turn();
}
assert.fail('Expected workflow state was not reached');
}
const ended = snapshot => snapshot?.ended_at !== null && snapshot?.ended_at !== undefined;
test('manager validates disabled graphs, duplicate ids and recursive workflow event', () => {
const ctx = context();
assert.throws(() => create_workflow_manager(ctx, [definition(), definition()]));
assert.throws(() => create_workflow_manager(ctx, [definition('off', undefined, {enabled: false, entry: 'missing'})]));
assert.throws(() => create_workflow_manager(ctx, [definition('off', undefined,
{enabled: false, start: {type: 'event', event: 'workflow_update'}})]));
assert.equal(ctx.events.eventNames().length, 0);
});
test('manager validates global and definition concurrency limits before installing listeners', async () => {
const ctx = context();
for (const max_concurrent_runs of [0, 257, -1, 1.5, NaN, Infinity, '2']) {
assert.throws(() => create_workflow_manager(ctx, [definition()], {max_concurrent_runs}),
{code: 'INVALID_CONCURRENCY_LIMIT'});
assert.equal(ctx.events.eventNames().length, 0);
}
for (const max_concurrent_runs of [0, 257, -1, 1.5, '2', null]) {
const config = definition();
config.policy.max_concurrent_runs = max_concurrent_runs;
assert.throws(() => create_workflow_manager(ctx, [config]), /max_concurrent_runs|invalid concurrent run limit/);
assert.equal(ctx.events.eventNames().length, 0);
}
for (const max_concurrent_runs of [1, 256]) {
const config = definition();
config.policy.max_concurrent_runs = max_concurrent_runs;
const manager = create_workflow_manager(ctx, [config], {max_concurrent_runs});
await manager.dispose();
assert.equal(ctx.events.eventNames().length, 0);
}
});
test('manager requires spawn and resets readiness on status disconnect', async () => {
const ctx = context(false);
const manager = create_workflow_manager(ctx, [definition()]);
try {
assert.throws(() => manager.start('missing'), {code: 'WORKFLOW_NOT_FOUND'});
assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'});
ctx.bot_instance.entity = {id: 1};
assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'});
ctx.emit('spawn');
const run = manager.start('one');
assert.equal(run.status, 'started');
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1);
ctx.bot_status = 'disconnecting';
ctx.emit('status_update', 'disconnecting');
await until(() => ended(manager.get_snapshot(run.run_id)));
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disconnect');
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
ctx.bot_status = 'online';
ctx.emit('status_update', 'online');
assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'});
} finally { await manager.dispose(); }
assert.equal(ctx.events.eventNames().length, 0);
});
test('manager queues or skips repeated definitions while distinct workflows run together', async () => {
const ctx = context();
const first = definition('one');
first.policy.max_queue = 1;
const skip = definition('skip');
skip.policy.on_busy = 'skip';
const manager = create_workflow_manager(ctx, [first, skip]);
try {
const running = manager.start('one');
const trigger = {nested: {value: 7}};
assert.equal(manager.start('one', trigger).status, 'queued');
trigger.nested.value = 99;
assert.equal(manager.start('one').status, 'skipped');
const distinct = manager.start('skip');
assert.equal(distinct.status, 'started');
assert.equal(manager.start('skip').status, 'skipped');
assert.equal(manager.get_snapshots().length, 2);
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
await manager.stop(running.run_id);
await until(() => manager.get_snapshots().length === 3);
const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'one'
&& snapshot.run_id !== running.run_id);
assert.equal(ended(manager.get_snapshot(distinct.run_id)), false);
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
ctx.bot_instance.emit('physicsTick');
await until(() => ended(manager.get_snapshot(queued.run_id)) && ended(manager.get_snapshot(distinct.run_id)));
assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.nested.value, 7);
} finally { await manager.dispose(); }
});
test('manager defaults to sixteen concurrent distinct definitions', async () => {
const ctx = context();
ctx.bot_instance.setMaxListeners(0);
const configs = Array.from({length: 17}, (_, index) => definition(`workflow_${index}`));
const manager = create_workflow_manager(ctx, configs);
try {
for (const config of configs.slice(0, 16)) assert.equal(manager.start(config.id).status, 'started');
assert.equal(manager.start(configs[16].id).status, 'queued');
assert.equal(manager.get_snapshots().length, 16);
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 16);
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().length === 17
&& ctx.bot_instance.listenerCount('physicsTick') === 1);
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().every(ended));
assert.ok(manager.get_snapshots().every(snapshot => snapshot.status === 'completed'));
} finally { await manager.dispose(); }
});
test('distinct concurrent workflows keep triggers, variables and outputs independent', async () => {
const ctx = context();
const releases = {one: deferred(), two: deferred()};
const calls = [];
ctx.info_manager = {get_info: name => ({value: {owner: name}})};
ctx.function_manager = {async run(_path, name, args, check, {signal}) {
calls.push({name, args, signal});
await releases[name].promise;
check();
return true;
}};
const configs = ['one', 'two'].map(id => definition(id, undefined, {
initial_vars: {owner: id},
nodes: [
{id: 'entry', type: 'query', config: {source: 'info', save_as: 'saved',
args: {name: {kind: 'ref', root: 'trigger', path: ['nested', 'owner']}}}},
{id: 'use', type: 'function', config: {path: 'bot', name: id, args: [
{kind: 'ref', root: 'trigger', path: ['nested']},
{kind: 'ref', root: 'vars', path: ['owner']},
{kind: 'ref', root: 'vars', path: ['saved']},
{kind: 'ref', root: 'outputs', path: ['entry', 'output']},
]}},
],
edges: [{id: 'use_result', source: 'entry', port: 'success', target: 'use'}],
}));
const manager = create_workflow_manager(ctx, configs);
try {
const trigger = {nested: {owner: 'one'}};
const one = manager.start('one', trigger);
trigger.nested.owner = 'two';
const two = manager.start('two', trigger);
trigger.nested.owner = 'mutated';
assert.equal(one.status, 'started');
assert.equal(two.status, 'started');
assert.notEqual(one.run_id, two.run_id);
await until(() => calls.length === 2);
for (const call of calls) {
assert.deepEqual(call.args, [{owner: call.name}, call.name, {owner: call.name}, {owner: call.name}]);
assert.equal(call.signal.aborted, false);
}
assert.notEqual(calls[0].signal, calls[1].signal);
releases.two.resolve();
await until(() => ended(manager.get_snapshot(two.run_id)));
assert.equal(ended(manager.get_snapshot(one.run_id)), false);
releases.one.resolve();
await until(() => ended(manager.get_snapshot(one.run_id)));
for (const run of [one, two]) {
const snapshot = manager.get_snapshot(run.run_id);
assert.equal(snapshot.status, 'completed');
const branch = snapshot.branches[0];
assert.deepEqual(branch.trigger, {nested: {owner: snapshot.workflow_id}});
assert.deepEqual(branch.vars, {owner: snapshot.workflow_id, saved: {owner: snapshot.workflow_id}});
assert.deepEqual(branch.outputs.entry.output, {owner: snapshot.workflow_id});
assert.equal(branch.outputs.use.output, true);
}
} finally {
Object.values(releases).forEach(release => release.resolve());
await manager.dispose();
}
});
test('definition concurrency two overlaps two runs and queues the next instance', async () => {
const ctx = context();
const config = definition();
config.policy.max_concurrent_runs = 2;
config.policy.max_queue = 1;
const manager = create_workflow_manager(ctx, [config]);
try {
const first = manager.start('one', {instance: 1});
const second = manager.start('one', {instance: 2});
assert.equal(first.status, 'started');
assert.equal(second.status, 'started');
assert.notEqual(first.run_id, second.run_id);
assert.equal(manager.start('one', {instance: 3}).status, 'queued');
assert.equal(manager.start('one', {instance: 4}).status, 'skipped');
assert.equal(manager.get_snapshots().length, 2);
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
await manager.stop(first.run_id);
await until(() => manager.get_snapshots().length === 3
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
assert.equal(ended(manager.get_snapshot(second.run_id)), false);
const queued = manager.get_snapshots().find(snapshot => ![first.run_id, second.run_id].includes(snapshot.run_id));
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().every(ended));
assert.equal(manager.get_snapshot(second.run_id).status, 'completed');
assert.equal(manager.get_snapshot(queued.run_id).status, 'completed');
assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.instance, 3);
} finally { await manager.dispose(); }
});
test('global concurrency two queues or skips distinct definitions at capacity', async () => {
const ctx = context();
const skip = definition('skip');
skip.policy.on_busy = 'skip';
const manager = create_workflow_manager(ctx, [definition('one'), definition('two'), definition('three'), skip],
{max_concurrent_runs: 2});
try {
const one = manager.start('one');
const two = manager.start('two');
assert.equal(one.status, 'started');
assert.equal(two.status, 'started');
assert.equal(manager.start('three').status, 'queued');
assert.equal(manager.start('skip').status, 'skipped');
assert.equal(manager.get_snapshots().length, 2);
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
await manager.stop(one.run_id);
await until(() => manager.get_snapshots().length === 3
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
assert.equal(ended(manager.get_snapshot(two.run_id)), false);
assert.ok(manager.get_snapshots().some(snapshot => snapshot.workflow_id === 'three'));
assert.ok(manager.get_snapshots().every(snapshot => snapshot.workflow_id !== 'skip'));
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().every(ended));
} finally { await manager.dispose(); }
});
test('queue pump bypasses a busy definition without losing its earlier queued request', async () => {
const ctx = context();
const manager = create_workflow_manager(ctx, [definition('one'), definition('occupied'), definition('two')],
{max_concurrent_runs: 2});
try {
const one = manager.start('one', {instance: 1});
const occupied = manager.start('occupied');
assert.equal(manager.start('one', {instance: 2}).status, 'queued');
assert.equal(manager.start('two').status, 'queued');
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
await manager.stop(occupied.run_id);
await until(() => manager.get_snapshots().length === 3
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
const two = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'two');
assert.ok(two);
assert.equal(manager.get_snapshots().filter(snapshot => snapshot.workflow_id === 'one').length, 1);
assert.equal(ended(manager.get_snapshot(one.run_id)), false);
await manager.stop(two.run_id);
await turn();
assert.equal(manager.get_snapshots().length, 3);
await manager.stop(one.run_id);
await until(() => manager.get_snapshots().length === 4
&& ctx.bot_instance.listenerCount('physicsTick') === 1);
const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'one'
&& snapshot.run_id !== one.run_id);
ctx.bot_instance.emit('physicsTick');
await until(() => ended(manager.get_snapshot(queued.run_id)));
assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.instance, 2);
} finally { await manager.dispose(); }
});
test('manager bounds global queue at 256 and disconnect clears queued work', async () => {
const ctx = context();
const config = definition();
config.policy.max_queue = 1000;
const manager = create_workflow_manager(ctx, [config]);
try {
const run = manager.start('one');
for (let index = 0; index < 256; index++) assert.equal(manager.start('one').status, 'queued');
assert.equal(manager.start('one').status, 'skipped');
ctx.bot_status = 'offline';
ctx.emit('status_update', 'offline');
await until(() => ended(manager.get_snapshot(run.run_id)));
ctx.bot_status = 'online';
ctx.emit('spawn');
await turn();
assert.equal(manager.get_snapshots().length, 1);
} finally { await manager.dispose(); }
});
test('manager validates bounded JSON triggers without invoking accessors', async () => {
const manager = create_workflow_manager(context(), [definition()]);
try {
let reads = 0;
const accessor = Object.defineProperty({}, 'value', {enumerable: true, get() { reads++; return 1; }});
const cycle = {}; cycle.self = cycle;
for (const trigger of [accessor, cycle, [], null, {text: 'x'.repeat(70_000)}, {value: Infinity}]) {
assert.throws(() => manager.start('one', trigger));
}
assert.equal(reads, 0);
assert.equal(manager.get_snapshots().length, 0);
} finally { await manager.dispose(); }
});
test('reentrant observer can launch another definition after the launching gate clears', async () => {
const ctx = context();
const manager = create_workflow_manager(ctx, [definition(), definition('two')]);
let result;
const off = manager.on_update(() => {
if (!result) result = manager.start('two', {queued: true});
throw new Error('broken observer');
});
ctx.on('workflow_update', () => { throw new Error('broken context observer'); });
try {
const run = manager.start('one');
assert.equal(result.status, 'queued');
assert.equal(manager.get_snapshots().length, 1);
await until(() => manager.get_snapshots().length === 2
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
assert.equal(ended(manager.get_snapshot(run.run_id)), false);
assert.ok(manager.get_snapshots().some(snapshot => snapshot.workflow_id === 'two'));
off();
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().every(ended));
const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'two');
assert.deepEqual(queued.branches[0].trigger, {queued: true});
} finally { await manager.dispose(); }
});
test('terminal snapshots are readable in the final update and retained snapshots are cloned', async () => {
const ctx = context();
const manager = create_workflow_manager(ctx, [definition('one', {id: 'entry', type: 'log',
config: {level: 'info', message: {kind: 'literal', value: 'done'}}})]);
let terminal;
manager.on_update(event => {
if (event.type === 'run_updated' && event.snapshot.ended_at !== null) {
terminal = manager.get_snapshots().find(snapshot => snapshot.run_id === event.snapshot.run_id);
}
});
try {
const run = manager.start('one', {value: 1});
await until(() => terminal);
assert.equal(terminal.status, 'completed');
terminal.branches[0].trigger.value = 2;
assert.equal(manager.get_snapshot(run.run_id).branches[0].trigger.value, 1);
await manager.stop(run.run_id);
await assert.rejects(manager.stop('unknown'), {code: 'WORKFLOW_RUN_NOT_FOUND'});
} finally { await manager.dispose(); }
});
test('function dependencies bind at actual queue start and pass scoped cancellation signal', async () => {
const ctx = context();
const calls = [];
const manager = create_workflow_manager(ctx, [definition(), definition('function', {id: 'entry', type: 'function',
config: {path: 'bot', name: 'chat', args: [{kind: 'literal', value: 'hello'}]}})], {max_concurrent_runs: 1});
try {
const first = manager.start('one');
manager.start('function');
ctx.function_manager = {async run(path, name, args, check, options) {
check(); calls.push({path, name, args, signal: options.signal}); return true;
}};
await manager.stop(first.run_id);
await until(() => calls.length === 1);
assert.equal(calls[0].path, 'bot');
assert.equal(calls[0].name, 'chat');
assert.deepEqual(calls[0].args, ['hello']);
assert.ok(calls[0].signal instanceof AbortSignal);
} finally { await manager.dispose(); }
});
test('function cancellation drains before the queued workflow starts', async () => {
const ctx = context();
let aborted = false;
let drained;
const drain = new Promise(resolve => { drained = resolve; });
ctx.function_manager = {async run(_path, _name, _args, _check, {signal}) {
await new Promise(resolve => signal.addEventListener('abort', () => { aborted = true; resolve(); }, {once: true}));
await drain;
signal.throwIfAborted();
}};
const manager = create_workflow_manager(ctx, [definition(), definition('function', {id: 'entry', type: 'function',
config: {path: 'bot', name: 'chat', args: []}})], {max_concurrent_runs: 1});
try {
const run = manager.start('function');
await turn();
manager.start('one');
let stopped = false;
const stop = manager.stop(run.run_id).then(() => { stopped = true; });
await until(() => aborted);
assert.equal(stopped, false);
assert.equal(manager.get_snapshots().length, 1);
drained();
await stop;
await until(() => manager.get_snapshots().length === 2);
} finally { drained(); await manager.dispose(); }
});
test('stopping one concurrent run preserves other signals and holds its slot until drain', async () => {
const ctx = context();
const releases = {one: deferred(), two: deferred()};
const calls = [];
ctx.function_manager = {async run(_path, name, _args, check, {signal}) {
calls.push({name, signal});
await releases[name].promise;
check();
return true;
}};
const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'),
definition('queued')], {max_concurrent_runs: 2});
try {
const one = manager.start('one');
const two = manager.start('two');
assert.equal(manager.start('queued').status, 'queued');
await until(() => calls.length === 2);
const first = calls.find(call => call.name === 'one');
const second = calls.find(call => call.name === 'two');
assert.notEqual(first.signal, second.signal);
let stopped = false;
const stop = manager.stop(one.run_id).then(() => { stopped = true; });
await until(() => first.signal.aborted);
await turn();
assert.equal(stopped, false);
assert.equal(second.signal.aborted, false);
assert.equal(ended(manager.get_snapshot(two.run_id)), false);
assert.equal(manager.get_snapshots().length, 2);
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
releases.one.resolve();
await stop;
await until(() => manager.get_snapshots().length === 3
&& ctx.bot_instance.listenerCount('physicsTick') === 1);
assert.equal(manager.get_snapshot(one.run_id).status, 'cancelled');
assert.equal(manager.get_snapshot(one.run_id).stop_reason, 'manual');
assert.equal(second.signal.aborted, false);
releases.two.resolve();
await until(() => ended(manager.get_snapshot(two.run_id)));
assert.equal(manager.get_snapshot(two.run_id).status, 'completed');
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().every(ended));
assert.equal(manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'queued').status, 'completed');
} finally {
Object.values(releases).forEach(release => release.resolve());
await manager.dispose();
}
});
test('dispose aborts all concurrent runs and awaits every deferred callback drain', async () => {
const ctx = context();
const releases = {one: deferred(), two: deferred()};
const calls = [];
ctx.function_manager = {async run(_path, name, _args, check, {signal}) {
calls.push({name, signal});
await releases[name].promise;
check();
return true;
}};
const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'),
definition('queued')], {max_concurrent_runs: 2});
try {
const one = manager.start('one');
const two = manager.start('two');
assert.equal(manager.start('queued').status, 'queued');
await until(() => calls.length === 2);
let disposed = false;
const dispose = manager.dispose();
assert.equal(manager.dispose(), dispose);
const done = dispose.then(() => { disposed = true; });
await until(() => calls.every(call => call.signal.aborted));
assert.equal(disposed, false);
assert.equal(ctx.events.eventNames().length, 0);
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_MANAGER_DISPOSED'});
releases.one.resolve();
await until(() => ended(manager.get_snapshot(one.run_id)));
assert.equal(disposed, false);
assert.equal(ended(manager.get_snapshot(two.run_id)), false);
releases.two.resolve();
await done;
assert.equal(disposed, true);
assert.equal(manager.get_snapshots().length, 2);
for (const run of [one, two]) {
assert.ok(ended(manager.get_snapshot(run.run_id)));
assert.equal(manager.get_snapshot(run.run_id).status, 'cancelled');
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disposed');
}
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
assert.equal(ctx.bot_instance.listenerCount('end'), 0);
} finally {
Object.values(releases).forEach(release => release.resolve());
await manager.dispose();
}
});
test('disconnect cancels every concurrent run and clears queued definitions across respawn', async () => {
const ctx = context();
const releases = {one: deferred(), two: deferred()};
const calls = [];
ctx.function_manager = {async run(_path, name, _args, check, {signal}) {
calls.push({name, signal});
await releases[name].promise;
check();
return true;
}};
const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'),
definition('queued')], {max_concurrent_runs: 2});
try {
const one = manager.start('one');
const two = manager.start('two');
assert.equal(manager.start('queued').status, 'queued');
assert.equal(manager.start('one').status, 'queued');
await until(() => calls.length === 2);
ctx.bot_status = 'offline';
ctx.emit('status_update', 'offline');
await until(() => calls.every(call => call.signal.aborted));
assert.equal(manager.get_snapshots().length, 2);
assert.ok(manager.get_snapshots().every(snapshot => !ended(snapshot)));
ctx.bot_status = 'online';
ctx.emit('status_update', 'online');
assert.throws(() => manager.start('queued'), {code: 'BOT_NOT_READY'});
ctx.emit('spawn');
releases.one.resolve();
releases.two.resolve();
await until(() => manager.get_snapshots().every(ended));
await turn();
assert.equal(manager.get_snapshots().length, 2);
assert.equal(calls.length, 2);
for (const run of [one, two]) {
assert.equal(manager.get_snapshot(run.run_id).status, 'cancelled');
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disconnect');
}
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
const fresh = manager.start('queued');
assert.equal(fresh.status, 'started');
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1);
ctx.bot_instance.emit('physicsTick');
await until(() => ended(manager.get_snapshot(fresh.run_id)));
assert.equal(manager.get_snapshot(fresh.run_id).status, 'completed');
} finally {
Object.values(releases).forEach(release => release.resolve());
await manager.dispose();
}
});
test('info query uses the info adapter and unknown sources fail predictably', async () => {
const ctx = context();
ctx.info_manager = {get_info: name => name === 'health' ? {value: {health: 20}} : undefined};
const manager = create_workflow_manager(ctx, ['info', 'missing'].map(source => definition(source,
{id: 'entry', type: 'query', config: {source, args: {name: {kind: 'literal', value: 'health'}}}})));
try {
const run = manager.start('info');
await until(() => ended(manager.get_snapshot(run.run_id)));
assert.deepEqual(manager.get_snapshot(run.run_id).branches[0].last_result.output, {health: 20});
const bad = manager.start('missing');
await until(() => ended(manager.get_snapshot(bad.run_id)));
assert.equal(manager.get_snapshot(bad.run_id).error.code, 'UNKNOWN_QUERY_SOURCE');
} finally { await manager.dispose(); }
});
test('info query metadata validation preserves the argument error code', async () => {
const ctx = context();
ctx.info_manager = {get_info: () => ({value: 20})};
const configs = [{}, {name: {kind: 'literal', value: ''}}, {name: {kind: 'literal', value: 1}}]
.map((args, index) => definition(`invalid_${index}`, {id: 'entry', type: 'query', config: {source: 'info', args}}));
const manager = create_workflow_manager(ctx, configs);
try {
const runs = configs.map(config => manager.start(config.id));
await until(() => runs.every(run => ended(manager.get_snapshot(run.run_id))));
for (const run of runs) assert.equal(manager.get_snapshot(run.run_id).error.code, 'INVALID_QUERY_ARGUMENTS');
} finally {await manager.dispose();}
});
test('common spawn, message conditions, arbitrary events and any recursion filter work', async () => {
const ctx = context(false);
const auto = definition('auto', undefined, {start: {type: 'common', auto_start: 'spawn'}});
const event = definition('message', undefined, {start: {type: 'event', event: 'message', condition: {
op: 'eq', left: {kind: 'ref', root: 'trigger', path: ['username']}, right: {kind: 'literal', value: 'Alice'},
}}});
const custom = definition('custom', undefined, {start: {type: 'event', event: 'someCustomEvent'}});
const any = definition('any', undefined, {start: {type: 'event', event: 'any'}});
const manager = create_workflow_manager(ctx, [auto, event, custom, any]);
try {
ctx.bot_instance.entity = {id: 1};
ctx.emit('spawn');
assert.equal(manager.get_snapshots()[0].workflow_id, 'auto');
ctx.emit('message', {text: '<Bob> no'}, 'chat');
ctx.emit('message', {text: '<Alice> hello'}, 'chat');
ctx.emit('someCustomEvent', 1, {safe: true}, ctx.bot_instance);
ctx.emit('any', 'workflow_update', {loop: true});
ctx.emit('any', 'chat', 'Alice', 'hello');
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 4);
assert.equal(manager.get_snapshots().length, 4);
assert.ok(manager.get_snapshots().every(snapshot => !ended(snapshot)));
ctx.bot_instance.emit('physicsTick');
await until(() => manager.get_snapshots().length === 4 && manager.get_snapshots().every(ended));
const message = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'message');
assert.equal(message.branches[0].trigger.message, 'hello');
const custom_trigger = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'custom').branches[0].trigger;
assert.deepEqual(custom_trigger.args, [1, {safe: true}, null]);
} finally { await manager.dispose(); }
});
test('message adapter reads bounded ChatMessage JSON and rejects malformed objects', () => {
class ChatMessage { constructor() { this.json = {text: '<Alice> hello'}; } }
assert.deepEqual(parse_message_trigger([new ChatMessage(), 'chat', 'uuid', true]), {
event: 'message', username: 'Alice', message: 'hello', plainText: '<Alice> hello',
position: 'chat', sender: 'uuid', verified: true,
});
assert.equal(parse_message_trigger([{text: 'x'.repeat(70_000)}]), undefined);
const cyclic = {}; cyclic.text = cyclic;
assert.equal(parse_message_trigger([cyclic]), undefined);
});
test('initially ready common start and timer lifetime are disposed', async t => {
t.mock.timers.enable({apis: ['setTimeout']});
const ctx = context();
const config = definition('auto', undefined, {start: {type: 'common', auto_start: 'spawn'}});
const manager = create_workflow_manager(ctx, [config,
definition('timer', undefined, {start: {type: 'timer', interval_ms: 10}})]);
await turn();
assert.equal(manager.get_snapshots()[0].workflow_id, 'auto');
t.mock.timers.tick(25);
await turn();
await manager.dispose();
const count = manager.get_snapshots().length;
t.mock.timers.tick(25);
assert.equal(manager.get_snapshots().length, count);
assert.equal(ctx.events.eventNames().length, 0);
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
assert.equal(ctx.bot_instance.listenerCount('end'), 0);
});
test('init assigns before subscriptions and rolls back all listeners on setup failure', async () => {
const ctx = context();
ctx.bot_config = {config_revision: 1};
const original_on = ctx.on;
let observed = false;
ctx.on = (event, callback) => {
assert.ok(ctx.workflow_manager);
observed = true;
original_on(event, callback);
if (event === 'someCustomEvent') throw new Error('listener install failed');
};
await assert.rejects(init_workflow(ctx, [definition('bad', undefined,
{start: {type: 'event', event: 'someCustomEvent'}})]), /listener install failed/);
assert.equal(observed, true);
assert.equal(ctx.workflow_manager, null);
assert.equal(ctx.events.eventNames().length, 0);
});
test('dispose during first-node logging awaits launched run cleanup', async () => {
const ctx = context();
let dispose;
let manager;
ctx.logger = {info: message => ({message})};
ctx.log = () => { dispose = manager.dispose(); };
manager = create_workflow_manager(ctx, [definition('one', {id: 'entry', type: 'log',
config: {level: 'info', message: {kind: 'literal', value: 'dispose'}}})]);
const run = manager.start('one');
await until(() => dispose);
await dispose;
assert.ok(ended(manager.get_snapshot(run.run_id)));
assert.equal(ctx.events.eventNames().length, 0);
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_MANAGER_DISPOSED'});
});
test('manager disables code by default and trusted host can explicitly enable it', async () => {
const config = definition('code', {id: 'entry', type: 'code', config: {source: 'return 42;'}});
const blocked = create_workflow_manager(context(), [config]);
const trusted = create_workflow_manager(context(), [config], {allow_code: true});
try {
const first = blocked.start('code');
const second = trusted.start('code');
await until(() => ended(blocked.get_snapshot(first.run_id)));
assert.equal(blocked.get_snapshot(first.run_id).error.code, 'CODE_EXECUTION_DISABLED');
await new Promise(resolve => trusted.on_update(event => {
if (event.type === 'run_updated' && event.snapshot.ended_at !== null) resolve();
}));
assert.equal(trusted.get_snapshot(second.run_id).branches[0].last_result.output, 42);
} finally { await blocked.dispose(); await trusted.dispose(); }
});
test('manager retains only the latest 20 completed runs without evicting active runs', async () => {
const manager = create_workflow_manager(context(), [definition('one', {id: 'entry', type: 'log',
config: {level: 'info', message: {kind: 'literal', value: 'done'}}}),
definition('active', {id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1000}})]);
const completed_ids = [];
try {
const active = manager.start('active');
for (let index = 0; index < 25; index++) {
const run = manager.start('one');
completed_ids.push(run.run_id);
await until(() => ended(manager.get_snapshot(run.run_id)));
await turn();
}
const snapshots = manager.get_snapshots();
assert.equal(snapshots.length, 21);
assert.deepEqual(snapshots.filter(ended).map(run => run.run_id), completed_ids.slice(-20));
for (const id of completed_ids.slice(0, 5)) assert.equal(manager.get_snapshot(id), undefined);
assert.equal(manager.get_snapshot(active.run_id).status, 'running');
await manager.stop(active.run_id);
assert.equal(manager.get_snapshots().length, 20);
assert.equal(manager.get_snapshot(completed_ids[5]), undefined);
assert.equal(manager.get_snapshot(active.run_id).status, 'cancelled');
} finally { await manager.dispose(); }
});
+295
View File
@@ -0,0 +1,295 @@
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 test from 'node:test';
import {build} from 'esbuild';
const root = fileURLToPath(new URL('../', import.meta.url));
const compiled = await build({
stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './bot/forward/manager.ts';
export * from './utils/forward_protocol.ts';`, resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
plugins: [{name: 'isolated-registry', setup(build) {
build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'mock'}));
build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({loader: 'js', contents:
'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'}));
}}],
});
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(createRequire(import.meta.url), module, module.exports);
const {BotStream, NodeForwardManager, validate_rpc} = module.exports;
const turn = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return {promise, resolve, reject};
};
const fails = (action, code = 'INVALID_REQUEST') => assert.throws(action, error => error.code === code);
const rejects = (promise, code) => assert.rejects(promise, error => error.code === code);
const request = (method, payload, scope = randomUUID(), deadline_at = Date.now() + 1000) => ({
request_id: randomUUID(), correlation_id: randomUUID(), method, payload, forward_session_id: scope, deadline_at,
});
function fixture(t) {
let now = 100;
const calls = [];
const run_id = randomUUID();
const snapshot = {run_id, workflow_id: 'gather', status: 'running', steps: 1, active_nodes: ['node'],
started_at: 1, ended_at: null, stop_reason: null, error: null,
trigger: {event: 'custom', args: [false, 0, null, {nested: [false, 0, null]}]}, trigger_available: true,
branches: [{trigger: {}, vars: {value: 1}, outputs: {}, inputs: {}, last_result: null, retry_counts: {}}]};
const manager = {
start(id, trigger) { calls.push(['start', id, trigger]); return {status: 'started', run_id}; },
async stop(id) { calls.push(['stop', id]); },
get_snapshot(id) { calls.push(['snapshot', id]); return id === run_id ? snapshot : undefined; },
get_snapshots() { calls.push(['snapshots']); return [snapshot]; },
};
const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat() {}}, workflow_manager: manager});
let current = context;
const observers = new Set();
const source = {get: () => current, observe(_id, listener) { observers.add(listener); return () => observers.delete(listener); }};
const stream = new BotStream(1, {source, now: () => now});
t.after(() => stream.dispose());
return {stream, source, context, manager, calls, snapshot, run_id,
set(context) { current = context; for (const listener of observers) listener(context); },
time(value) { now = value; },
};
}
test('workflow protocol accepts exact start, stop and optional snapshot payloads', () => {
for (const [method, payload] of [
['workflow.start', {workflow_id: 'gather-1_test'}],
['workflow.start', {workflow_id: 'x'.repeat(100), trigger: {nested: {items: [1, true, null, 'value']}}}],
['workflow.stop', {run_id: randomUUID()}],
['workflow.snapshot', {}],
['workflow.snapshot', {run_id: randomUUID()}],
]) {
const valid = request(method, payload);
assert.equal(validate_rpc(valid), valid);
fails(() => validate_rpc({...valid, payload: {...payload, extra: true}}));
}
for (const workflow_id of ['', '../x', 'a/b', 'a.b', 'a b', 'x'.repeat(101), '__proto__', 'prototype', 'constructor', 1])
fails(() => validate_rpc(request('workflow.start', {workflow_id})));
for (const method of ['workflow.stop', 'workflow.snapshot'])
for (const run_id of ['', 'not-a-uuid', null, undefined, 1]) fails(() => validate_rpc(request(method, {run_id})));
fails(() => validate_rpc(request('workflow.start', {})));
fails(() => validate_rpc(request('workflow.stop', {})));
fails(() => validate_rpc(request('workflow.delete', {})));
});
test('workflow triggers require bounded plain JSON without accessors, cycles or unsafe keys', () => {
let reads = 0;
const getter = Object.defineProperty({}, 'secret', {enumerable: true, get() { reads++; return 1; }});
const cycle = {}; cycle.self = cycle;
let deep = {};
for (let index = 0; index < 66; index++) deep = {next: deep};
const sparse = []; sparse.length = 2;
for (const trigger of [null, [], 'text', undefined, new Date(), Object.create({inherited: true}),
getter, cycle, deep, {sparse}, {value: NaN}, {value: Infinity}, {value: 1n}, {value() {}},
{value: undefined}, {huge: 'x'.repeat(1_048_576)}, {values: Array(100_001).fill(0)},
JSON.parse('{"__proto__":1}'), {nested: {constructor: 'unsafe'}}, {[Symbol('key')]: 1},
Object.defineProperty({}, 'hidden', {value: 1})]) {
fails(() => validate_rpc(request('workflow.start', {workflow_id: 'test', trigger})));
}
assert.equal(reads, 0);
const trigger = Object.assign(Object.create(null), {safe: {values: [null, 2]}});
assert.equal(validate_rpc(request('workflow.start', {workflow_id: 'test', trigger})).payload.trigger, trigger);
});
test('stream start preserves the manager result and copies its trigger input and result', t => {
const app = fixture(t);
const trigger = {data: {value: 1}};
const result = {status: 'queued', request_id: randomUUID()};
app.manager.start = (id, input) => { app.calls.push([id, input]); input.data.value = 2; return result; };
let checks = 0;
const actual = app.stream.workflow_start({workflow_id: 'gather', trigger}, 200, () => checks++);
assert.deepEqual(actual, result);
assert.notEqual(actual, result);
assert.equal(trigger.data.value, 1);
assert.equal(app.calls[0][0], 'gather');
assert.equal(app.calls[0][1].data.value, 2);
assert.ok(checks >= 2);
app.manager.start = () => ({status: 'skipped'});
assert.deepEqual(app.stream.workflow_start({workflow_id: 'gather'}, 200), {status: 'skipped'});
});
test('stream workflow guards reject invalid, expired, missing, offline and stale starts before manager calls', t => {
const app = fixture(t);
fails(() => app.stream.workflow_start({workflow_id: '../x'}, 200));
for (const deadline of [100, 99, NaN, Infinity, 1.5])
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, deadline), 'TIMEOUT');
app.context.bot_status = 'offline';
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'BOT_NOT_ONLINE');
app.context.bot_status = 'online';
app.context.bot_instance = null;
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'BOT_NOT_ONLINE');
app.context.bot_instance = {chat() {}};
app.context.workflow_manager = null;
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'INTERNAL_ERROR');
app.context.workflow_manager = app.manager;
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200, () => {
app.context.workflow_manager = {};
}), 'CANCELLED');
app.set(undefined);
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'NO_CONTEXT');
app.stream.dispose();
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'CANCELLED');
assert.deepEqual(app.calls, []);
});
test('workflow transport and post-start context checks prevent stale responses', t => {
const app = fixture(t);
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200, () => {
throw Object.assign(Error('stale'), {code: 'SESSION_STALE'});
}), 'SESSION_STALE');
assert.deepEqual(app.calls, []);
app.manager.start = () => { app.set(Object.assign(new EventEmitter(), app.context)); return {status: 'skipped'}; };
fails(() => app.stream.workflow_start({workflow_id: 'gather'}, 200), 'CANCELLED');
});
test('snapshots are deeply copied, available offline and return null for an unknown run', t => {
const app = fixture(t);
app.context.bot_status = 'offline';
app.context.bot_instance = null;
const all = app.stream.workflow_snapshot({}, 200);
const one = app.stream.workflow_snapshot({run_id: app.run_id}, 200);
const {branches, trigger, ...summary} = app.snapshot;
const expected_trigger = structuredClone(trigger);
assert.deepEqual(all, [summary]);
assert.equal(Object.hasOwn(all[0], 'trigger'), false);
assert.equal(Object.hasOwn(all[0], 'branches'), false);
assert.deepEqual(one, app.snapshot);
all[0].active_nodes.push('summary mutation');
one.branches[0].vars.value = 2;
one.trigger.args[3].nested.push('detail mutation');
one.active_nodes.push('extra');
assert.equal(app.snapshot.branches[0].vars.value, 1);
assert.deepEqual(app.snapshot.trigger, expected_trigger);
assert.deepEqual(app.snapshot.active_nodes, ['node']);
assert.equal(app.stream.workflow_snapshot({run_id: randomUUID()}, 200), null);
delete app.snapshot.trigger;
assert.equal(app.stream.workflow_snapshot({}, 200)[0].trigger_available, false);
app.manager.get_snapshots = () => { app.time(201); return []; };
fails(() => app.stream.workflow_snapshot({}, 200), 'TIMEOUT');
});
test('workflow stop waits for drain, works offline and rejects changed contexts or expired results', async t => {
for (const change of ['none', 'deadline', 'context', 'instance', 'manager', 'disposed', 'transport']) {
await t.test(change, async t => {
const app = fixture(t);
app.context.bot_status = 'offline';
const drain = deferred();
let settled = false;
let stale = false;
app.manager.stop = id => { app.calls.push(id); return drain.promise; };
const result = app.stream.workflow_stop({run_id: app.run_id}, 200, () => {
if (stale) throw Object.assign(Error('stale'), {code: 'SESSION_STALE'});
});
const expected = {deadline: 'TIMEOUT', context: 'CANCELLED', instance: 'CANCELLED', manager: 'CANCELLED',
disposed: 'CANCELLED', transport: 'SESSION_STALE'}[change];
const checked = expected ? rejects(result, expected) : result.then(value => assert.deepEqual(value, {stopped: true}));
void result.then(() => {settled = true;}, () => {settled = true;});
await turn();
assert.equal(settled, false);
assert.deepEqual(app.calls, [app.run_id]);
if (change === 'deadline') app.time(201);
if (change === 'context') app.set(undefined);
if (change === 'instance') app.context.bot_instance = {};
if (change === 'manager') app.context.workflow_manager = {};
if (change === 'disposed') app.stream.dispose();
if (change === 'transport') stale = true;
drain.resolve();
await checked;
});
}
});
async function relay(t, stream) {
const command = {forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'session', node_id: 1, bot_id: 1,
url: 'http://127.0.0.1:1234', path: '/socket.io', namespace: '/', token: 'opaque',
expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000};
const socket = new EventEmitter();
socket.auth = {};
socket.io = {engine: {writeBuffer: []}};
socket.volatile = {emit() {}};
socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); };
socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); };
socket.timeout = () => ({emit(event, _payload, ack) {
const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command;
const data = event === 'forward_node_ready' ? {node_ready: true}
: {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at, role: 'node', node_ready: false};
queueMicrotask(() => ack(null, {status: 'success', data}));
}});
const manager = new NodeForwardManager(1, 'session', {socket_factory: () => socket, stream_factory: () => stream});
t.after(() => manager.stop());
assert.equal((await manager.ensure(command)).status, 'success');
return {socket, manager, rpc: (method, payload, deadline = Date.now() + 1000) => new Promise(resolve =>
socket.emit('relay_request', request(method, payload, command.forward_session_id, deadline), resolve))};
}
test('Node dispatches workflow RPCs through stream guards and retains function RPC callbacks', async t => {
const app = fixture(t);
// Relay deadlines use the wall clock while direct stream tests use a fixed clock.
app.time(Date.now());
let before = 0;
app.context.function_manager = {async run(path, name, args, check) {
check(); before++; assert.deepEqual([path, name, args], ['bot', 'chat', ['hello']]); return true;
}};
const channel = await relay(t, app.stream);
assert.deepEqual(await channel.rpc('workflow.start', {workflow_id: 'gather'}),
{status: 'success', data: {status: 'started', run_id: app.run_id}});
assert.deepEqual(await channel.rpc('workflow.snapshot', {run_id: app.run_id}), {status: 'success', data: app.snapshot});
const {branches, trigger, ...summary} = app.snapshot;
assert.deepEqual(await channel.rpc('workflow.snapshot', {}), {status: 'success', data: [summary]});
assert.deepEqual(await channel.rpc('workflow.stop', {run_id: app.run_id}), {status: 'success', data: {stopped: true}});
assert.deepEqual(await channel.rpc('execute_function', {function: 'bot', name: 'chat', args: ['hello']}),
{status: 'success', data: true});
assert.equal(before, 1);
assert.equal((await channel.rpc('workflow.start', {workflow_id: '../bad'})).data.code, 'INVALID_REQUEST');
assert.equal((await channel.rpc('workflow.start', {workflow_id: 'gather'}, Date.now() - 1)).data.code, 'TIMEOUT');
});
test('Node returns stop TIMEOUT without waiting for drain and safely contains late rejection', async t => {
const app = fixture(t);
const drain = deferred();
app.manager.stop = () => drain.promise;
const channel = await relay(t, app.stream);
const response = await channel.rpc('workflow.stop', {run_id: app.run_id}, Date.now() + 30);
assert.equal(response.status, 'error');
assert.equal(response.data.code, 'TIMEOUT');
drain.reject(Error('SECRET late failure'));
await turn();
assert.equal((await channel.rpc('workflow.snapshot', {})).status, 'success');
});
test('Node rejects stopped transport results and sanitizes workflow manager failures', async t => {
const app = fixture(t);
const drain = deferred();
app.manager.stop = () => drain.promise;
const channel = await relay(t, app.stream);
app.manager.start = () => { throw Error('SECRET workflow error'); };
const failed = await channel.rpc('workflow.start', {workflow_id: 'gather'});
assert.equal(failed.data.code, 'INTERNAL_ERROR');
assert.equal(JSON.stringify(failed).includes('SECRET'), false);
const pending = channel.rpc('workflow.stop', {run_id: app.run_id});
channel.socket.disconnect();
drain.resolve();
assert.equal((await pending).data.code, 'SESSION_STALE');
});
test('workflow state updates retain reliable transport emission', async t => {
const app = fixture(t);
app.context.info_manager = {};
const channel = await relay(t, app.stream);
const reliable = [];
const volatile = [];
channel.socket.on('info_update', packet => reliable.push(packet));
channel.socket.volatile.emit = (event, packet) => volatile.push([event, packet]);
const update = {name: 'workflow_states', value: [{run_id: app.run_id, status: 'completed'}]};
app.context.emit('info_update', update);
assert.equal(reliable.length, 1);
assert.deepEqual(reliable[0].data, update);
assert.deepEqual(volatile, []);
});
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire, isBuiltin} from 'node:module';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
const root = fileURLToPath(new URL('../', import.meta.url));
const compiled = await build({
stdin: {contents: `export * from './bot/workflow/runtime/states_manager.ts';
export * from './bot/workflow/runtime/summary.ts'; export * from './bot/forward/stream.ts';`,
resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
plugins: [{name: 'isolated-registry', setup(build) {
build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'mock'}));
build.onLoad({filter: /.*/, namespace: 'mock'}, () => ({loader: 'js', contents:
'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'}));
}}],
});
const module = {exports: {}};
const require = createRequire(import.meta.url);
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
assert.ok(isBuiltin(specifier) || specifier === 'xstate', `Unexpected workflow history dependency: ${specifier}`);
return require(specifier);
}, module, module.exports);
const {create_workflow_manager, summarize_run, BotStream} = module.exports;
const turn = () => new Promise(resolve => setImmediate(resolve));
async function until(check) {
for (let index = 0; index < 100; index++) {
if (check()) return;
await turn();
}
assert.fail('Expected workflow history state was not reached');
}
const ended = snapshot => snapshot?.ended_at !== null && snapshot?.ended_at !== undefined;
const log = id => ({id, type: 'log', config: {level: 'info', message: {kind: 'literal', value: 'done'}}});
const sleep = id => ({id, type: 'sleep', config: {mode: 'ticks', value: 1}});
function definition(id = 'one', extra = {}) {
return {schema_version: 1, id, name: id, enabled: true,
start: {type: 'common', auto_start: 'never'}, entry: 'entry', nodes: [log('entry')], edges: [],
policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}, ...extra};
}
function fixture(t, configs = [definition()]) {
const bot = Object.assign(new EventEmitter(), {entity: {id: 1}, chat() {}});
const context = Object.assign(new EventEmitter(), {bot_instance: bot, bot_status: 'online',
info_manager: null, function_manager: {run: async () => true}, logger: null, log() {}});
const manager = create_workflow_manager(context, configs);
context.workflow_manager = manager;
const stream = new BotStream(1, {source: {get: () => context, observe: () => () => {}}});
t.after(async () => {stream.dispose(); await manager.dispose();});
const detail = run_id => stream.workflow_snapshot({run_id}, Date.now() + 5000);
return {bot, context, manager, stream, detail};
}
test('completed chat and message runs retain their actual normalized trigger values', async t => {
const app = fixture(t, ['chat', 'message', 'custom'].map(event => definition(event,
{start: {type: 'event', event}})));
const chat_args = ['Alice', 'hello', {nested: [false, 0, null, {value: 'original'}]}];
class ChatMessage {constructor() {this.json = {text: '<Bob> message body'};}}
app.context.emit('chat', ...chat_args);
app.context.emit('message', new ChatMessage(), 'chat', 'sender-id', false);
app.context.emit('custom', false, 0, null, {nested: [false, 0, null]});
await until(() => app.manager.get_snapshots().length === 3 && app.manager.get_snapshots().every(ended));
for (const snapshot of app.manager.get_snapshots()) {
const expected = {
chat: {event: 'chat', args: chat_args, username: 'Alice', message: 'hello'},
message: {event: 'message', username: 'Bob', message: 'message body', plainText: '<Bob> message body',
position: 'chat', sender: 'sender-id', verified: false},
custom: {event: 'custom', args: [false, 0, null, {nested: [false, 0, null]}]},
}[snapshot.workflow_id];
assert.equal(snapshot.status, 'completed');
const detail = app.detail(snapshot.run_id);
assert.deepEqual(detail.trigger, expected);
assert.deepEqual(detail.branches[0].trigger, expected);
assert.equal(detail.trigger_available, true);
assert.deepEqual(JSON.parse(JSON.stringify(detail.trigger)), expected);
}
const summaries = app.stream.workflow_snapshot({}, Date.now() + 5000);
assert.equal(summaries.length, 3);
for (const summary of summaries) {
assert.equal(summary.trigger_available, true);
assert.equal(Object.hasOwn(summary, 'trigger'), false);
assert.equal(Object.hasOwn(summary, 'branches'), false);
}
});
test('queued chat events keep their original argument objects after the emitter mutates them', async t => {
const app = fixture(t, [definition('chat', {start: {type: 'event', event: 'chat'}, nodes: [sleep('entry')]})]);
app.context.emit('chat', 'Alice', 'first', {nested: {value: 1}});
const first = app.manager.get_snapshots()[0];
const args = ['Bob', 'queued', {nested: {value: 0}, values: [false, null]}];
const original = structuredClone(args);
app.context.emit('chat', ...args);
assert.equal(app.manager.get_runtime_snapshot().workflows[0].queued_count, 1);
args[0] = 'mutated'; args[1] = 'changed'; args[2].nested.value = 99; args[2].values.push(true);
await app.manager.stop(first.run_id);
await until(() => app.manager.get_snapshots().length === 2 && app.bot.listenerCount('physicsTick') === 1);
const queued = app.manager.get_snapshots().find(snapshot => snapshot.run_id !== first.run_id);
const expected = {event: 'chat', args: original, username: 'Bob', message: 'queued'};
assert.deepEqual(app.detail(queued.run_id).trigger, expected);
app.bot.emit('physicsTick');
await until(() => ended(app.manager.get_snapshot(queued.run_id)));
assert.deepEqual(app.detail(queued.run_id).trigger, expected);
assert.deepEqual(app.detail(queued.run_id).branches[0].trigger, expected);
});
test('failed and cancelled runs retain triggers even with no terminal branches', async t => {
for (const status of ['failed', 'cancelled']) {
await t.test(status, async t => {
const app = fixture(t, [definition('one', {nodes: [sleep('entry'), log('next')],
edges: [{id: 'next', source: 'entry', port: 'success', target: 'next'}],
policy: {on_busy: 'queue', max_queue: 10, max_steps: 1, timeout_ms: 5000}})]);
const trigger = {event: 'custom', args: [false, 0, null, {nested: ['original']}]};
const run = app.manager.start('one', trigger);
const running = app.detail(run.run_id);
assert.deepEqual(running.branches, []);
assert.deepEqual(running.trigger, trigger);
assert.equal(running.trigger_available, true);
await until(() => app.bot.listenerCount('physicsTick') === 1);
if (status === 'cancelled') await app.manager.stop(run.run_id);
else app.bot.emit('physicsTick');
await until(() => ended(app.manager.get_snapshot(run.run_id)));
const detail = app.detail(run.run_id);
assert.equal(detail.status, status);
assert.deepEqual(detail.branches, []);
assert.deepEqual(detail.trigger, trigger);
assert.equal(detail.trigger_available, true);
if (status === 'failed') assert.equal(detail.error.code, 'STEP_LIMIT_EXCEEDED');
});
}
});
test('trigger inputs, observer events, run details and branch triggers never share mutations', async t => {
const app = fixture(t, [definition('one', {nodes: [sleep('entry')]})]);
const trigger = {nested: {values: [false, 0, null]}};
const original = structuredClone(trigger);
const observed = [];
app.manager.on_update(event => {
if (event.type === 'run_updated') event.snapshot.trigger.nested.values.push('observer mutation');
});
app.manager.on_update(event => {if (event.type === 'run_updated') observed.push(event.snapshot);});
const run = app.manager.start('one', trigger);
trigger.nested.values.push('input mutation');
const running = app.manager.get_snapshot(run.run_id);
running.trigger.nested.values.push('snapshot mutation');
assert.deepEqual(app.manager.get_snapshot(run.run_id).trigger, original);
await until(() => app.bot.listenerCount('physicsTick') === 1);
app.bot.emit('physicsTick');
await until(() => ended(app.manager.get_snapshot(run.run_id)));
await turn();
assert.ok(observed.length > 0);
assert.ok(observed.every(snapshot => JSON.stringify(snapshot.trigger) === JSON.stringify(original)));
const detail = app.detail(run.run_id);
detail.trigger.nested.values.push('detail mutation');
assert.deepEqual(detail.branches[0].trigger, original);
detail.branches[0].trigger.nested.values.push('branch mutation');
const list = app.manager.get_snapshots();
list[0].trigger.nested.values.push('list mutation');
assert.deepEqual(app.detail(run.run_id).trigger, original);
assert.deepEqual(app.detail(run.run_id).branches[0].trigger, original);
});
test('summaries mark actual trigger presence including empty input and legacy missing triggers', () => {
const legacy = {run_id: 'legacy', branches: [{trigger: {old: true}}], trigger_available: true};
assert.deepEqual(summarize_run(legacy), {run_id: 'legacy', trigger_available: false});
assert.deepEqual(summarize_run({...legacy, trigger: {}, trigger_available: false}),
{run_id: 'legacy', trigger_available: true});
});
test('history keeps the latest 20 completed details while evicted run ids return null', async t => {
const app = fixture(t, [definition(), definition('active', {nodes: [sleep('entry')]})]);
const active = app.manager.start('active', {active: true});
const ids = [];
for (let index = 0; index < 25; index++) {
const run = app.manager.start('one', {index, values: [false, 0, null]});
ids.push(run.run_id);
await until(() => ended(app.manager.get_snapshot(run.run_id)));
await turn();
}
const completed = app.manager.get_snapshots().filter(ended);
assert.deepEqual(completed.map(snapshot => snapshot.run_id), ids.slice(-20));
assert.equal(app.detail(active.run_id).status, 'running');
for (const id of ids.slice(0, 5)) assert.equal(app.detail(id), null);
for (let index = 5; index < ids.length; index++) assert.deepEqual(app.detail(ids[index]).trigger,
{index, values: [false, 0, null]});
await app.manager.stop(active.run_id);
assert.equal(app.manager.get_snapshots().length, 20);
assert.equal(app.detail(ids[5]), null);
assert.deepEqual(app.detail(active.run_id).trigger, {active: true});
});
test('the 5 MiB history budget evicts older details before reaching the run-count limit', async t => {
const app = fixture(t, [definition('one', {initial_vars: {payload: 'x'.repeat(350_000)}})]);
const ids = [];
for (let index = 0; index < 19; index++) {
const run = app.manager.start('one', {index});
ids.push(run.run_id);
await until(() => ended(app.manager.get_snapshot(run.run_id)));
await turn();
}
const retained = app.manager.get_snapshots();
assert.ok(retained.length > 0 && retained.length < 19);
assert.ok(retained.reduce((bytes, snapshot) => bytes + Buffer.byteLength(JSON.stringify(snapshot)), 0) <= 5 * 1024 * 1024);
assert.deepEqual(retained.map(snapshot => snapshot.run_id), ids.slice(-retained.length));
for (const id of ids.slice(0, -retained.length)) assert.equal(app.detail(id), null);
assert.deepEqual(app.detail(ids.at(-1)).trigger, {index: 18});
});
test('oversized terminal history strips branches but preserves its full trigger detail', async t => {
const leaves = Array.from({length: 6}, (_, index) => log(`leaf_${index}`));
const app = fixture(t, [definition('one', {initial_vars: {payload: 'x'.repeat(900_000)},
nodes: [log('entry'), ...leaves], edges: leaves.map(node =>
({id: node.id, source: 'entry', port: 'success', target: node.id}))})]);
const trigger = {event: 'custom', args: [false, 0, null, {nested: ['original']}]};
let terminal;
app.manager.on_update(event => {
if (event.type === 'run_updated' && ended(event.snapshot)) terminal = event.snapshot;
});
const run = app.manager.start('one', trigger);
await until(() => terminal);
await turn();
assert.equal(terminal.branches.length, 6);
assert.ok(Buffer.byteLength(JSON.stringify(terminal)) > 5 * 1024 * 1024);
const detail = app.detail(run.run_id);
assert.equal(detail.status, 'completed');
assert.deepEqual(detail.branches, []);
assert.deepEqual(detail.trigger, trigger);
assert.equal(detail.trigger_available, true);
});