306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
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();
|
|
}
|
|
});
|