chore: initial commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user