246 lines
12 KiB
JavaScript
246 lines
12 KiB
JavaScript
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);
|
|
});
|