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); });