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