chore: initial commit
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
const compiled = await build({
|
||||
entryPoints: [new URL('../utils/bot_config_apply_handler.ts', import.meta.url).pathname],
|
||||
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false,
|
||||
plugins: [{name: 'isolation', setup(build) {
|
||||
build.onResolve({filter: /bot\/instance\/index\.ts$/}, () => ({path: 'context', namespace: 'mock'}));
|
||||
build.onResolve({filter: /console_print\.ts$/}, () => ({path: 'logger', namespace: 'mock'}));
|
||||
build.onResolve({filter: /runtime\/compile\.ts$/}, () => ({path: 'compile', namespace: 'mock'}));
|
||||
build.onLoad({filter: /.*/, namespace: 'mock'}, ({path}) => ({contents: path === 'context'
|
||||
? 'export const get_bot_context = id => globalThis.lookup(id);'
|
||||
: path === 'compile' ? 'export const compile_workflow = value => globalThis.compile(value);'
|
||||
: 'export const console_print={log:value=>globalThis.logged(value),success:value=>value,error:value=>value};'}));
|
||||
}}],
|
||||
});
|
||||
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
|
||||
const flush = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
|
||||
const dto = (revision = 1, extras = {}) => ({command_id: randomUUID(), payload_version: 1, node_session_id: 'session',
|
||||
bot_id: 1, config_revision: revision, plugins: [], workflows: [], ...extras});
|
||||
const success = (request, state) => ({status: 'success', data: {command_id: request.command_id, bot_id: request.bot_id,
|
||||
config_revision: request.config_revision, state}});
|
||||
|
||||
function setup() {
|
||||
const calls = [], logs = [];
|
||||
const contexts = new Map();
|
||||
const options = {};
|
||||
const module = {exports: {}};
|
||||
vm.runInNewContext(compiled.outputFiles[0].text, {module, exports: module.exports, require: createRequire(import.meta.url),
|
||||
Buffer, structuredClone, lookup: id => contexts.get(id), logged: value => logs.push(value),
|
||||
compile: value => {
|
||||
calls.push(['compile', structuredClone(value)]);
|
||||
if (options.compile_error || !value?.id) throw new Error('secret workflow code');
|
||||
return {definition: value};
|
||||
}});
|
||||
const context = {bot_config: {config_revision: 0, plugins: [], workflow: []}, bot_status: 'offline', is_disposed: false,
|
||||
plugin_manager: {async apply_desired(plugins) {
|
||||
calls.push(['plugins', structuredClone(plugins)]);
|
||||
if (options.plugin_gate) await options.plugin_gate;
|
||||
if (options.plugin_error) throw options.plugin_error;
|
||||
}}, workflow_manager: {async apply(workflows, revision) {
|
||||
calls.push(['workflows', structuredClone(workflows), revision]);
|
||||
if (options.workflow_gate) await options.workflow_gate;
|
||||
if (options.workflow_error) throw options.workflow_error;
|
||||
}}, logger: {info: value => value, error: value => value}, log: value => logs.push(value)};
|
||||
contexts.set(1, context);
|
||||
const handler = module.exports.create_bot_config_apply_handler();
|
||||
const socket = {connected: true, id: 'session'};
|
||||
return {calls, logs, contexts, options, context, handler, socket, async run(request, ack) {
|
||||
const replies = [];
|
||||
await handler(socket, request, value => { replies.push(structuredClone(value)); ack?.(); });
|
||||
assert.equal(replies.length, 1);
|
||||
return replies[0];
|
||||
}};
|
||||
}
|
||||
|
||||
test('processing ACK is immediate, duplicate jobs install once and completed ACK has exact fields', async () => {
|
||||
const s = setup(), request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {value: 1}}]});
|
||||
const gate = deferred(); s.options.plugin_gate = gate.promise;
|
||||
assert.deepEqual(await s.run(request), success(request, 'processing'));
|
||||
for (let i = 0; i < 3; i++) assert.deepEqual(await s.run(request), success(request, 'processing'));
|
||||
assert.deepEqual(s.calls.map(call => call[0]), ['plugins']);
|
||||
gate.resolve(); await flush();
|
||||
assert.deepEqual(await s.run(request), success(request, 'completed'));
|
||||
assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']);
|
||||
assert.equal(s.context.bot_config.config_revision, 1);
|
||||
assert.deepEqual(s.context.bot_config.plugins, request.plugins);
|
||||
assert.equal(s.logs.length, 2);
|
||||
});
|
||||
|
||||
test('strict bounded JSON validation rejects malformed input before lookup or mutation', async () => {
|
||||
const s = setup(), request = dto();
|
||||
const unsafe = Object.defineProperty({...request}, 'plugins', {enumerable: true, get() { throw new Error('getter secret'); }});
|
||||
const cyclic = dto(); cyclic.plugins.push(cyclic);
|
||||
const bad = [null, [], {}, {...request, extra: true}, {...request, payload_version: 2}, {...request, command_id: 'bad'},
|
||||
{...request, bot_id: 0}, {...request, config_revision: 0}, {...request, workflows: {}}, unsafe, cyclic,
|
||||
new Proxy(request, {}), {...request, plugins: [{path: '../evil', name: 'a', config: {}}]},
|
||||
{...request, plugins: [{path: 'runtime', name: 'a', config: {bad: NaN}}]},
|
||||
{...request, plugins: [{path: 'runtime', name: 'a', config: {}}, {path: 'other', name: 'a', config: {}}]},
|
||||
{...request, plugins: [{path: 'runtime', name: 'a', config: {huge: 'x'.repeat(256 * 1024)}}]}];
|
||||
for (const data of bad) assert.equal((await s.run(data)).data.code, 'INVALID_PAYLOAD');
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
|
||||
test('exact 256KiB serialized limit accepts boundary and rejects next byte', async () => {
|
||||
const s = setup();
|
||||
const request = dto(1, {plugins: [{path: 'runtime', name: 'a', config: {text: ''}}]});
|
||||
request.plugins[0].config.text = 'x'.repeat(256 * 1024 - Buffer.byteLength(JSON.stringify(request)));
|
||||
assert.equal(Buffer.byteLength(JSON.stringify(request)), 256 * 1024);
|
||||
assert.equal((await s.run(request)).status, 'success');
|
||||
const excess = structuredClone(request); excess.command_id = randomUUID(); excess.plugins[0].config.text += 'x';
|
||||
assert.equal((await s.run(excess)).data.code, 'INVALID_PAYLOAD');
|
||||
await flush();
|
||||
});
|
||||
|
||||
test('all workflows compile before plugins and duplicate graph IDs reject without mutation', async () => {
|
||||
const s = setup();
|
||||
for (const workflows of [[{id: 'valid'}, {}], [{id: 'same'}, {id: 'same'}]]) {
|
||||
assert.equal((await s.run(dto(1, {workflows}))).data.code, 'INVALID_PAYLOAD');
|
||||
assert.ok(s.calls.every(call => call[0] === 'compile'));
|
||||
}
|
||||
s.calls.length = 0;
|
||||
const request = dto(1, {workflows: [{id: 'a'}, {id: 'b'}]});
|
||||
await s.run(request); await flush();
|
||||
assert.deepEqual(s.calls.map(call => call[0]), ['compile', 'compile', 'plugins', 'workflows']);
|
||||
});
|
||||
|
||||
test('missing context is explicit and never creates managers, ACK throws do not repeat work', async () => {
|
||||
const s = setup(), request = dto();
|
||||
s.contexts.clear();
|
||||
assert.deepEqual(await s.run(request), success(request, 'no_context'));
|
||||
s.contexts.set(1, s.context);
|
||||
assert.deepEqual(await s.run(request), success(request, 'no_context'));
|
||||
await s.run(dto(), () => { throw new Error('ACK failed'); }); await flush();
|
||||
assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows']);
|
||||
await s.handler(s.socket, new Proxy({}, {get() { throw new Error('unread'); }}));
|
||||
});
|
||||
|
||||
test('socket session and context disposal checked before and after each await', async () => {
|
||||
const s = setup();
|
||||
assert.equal((await s.run(dto(1, {node_session_id: 'old'}))).data.code, 'SESSION_MISMATCH');
|
||||
s.socket.connected = false;
|
||||
assert.equal((await s.run(dto())).data.code, 'SESSION_MISMATCH');
|
||||
s.socket.connected = true;
|
||||
const request = dto(), gate = deferred(); s.options.plugin_gate = gate.promise;
|
||||
await s.run(request);
|
||||
s.context.is_disposed = true; gate.resolve(); await flush();
|
||||
assert.equal((await s.run(request)).data.code, 'CONTEXT_CHANGED');
|
||||
assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 0);
|
||||
assert.equal(s.context.bot_config.config_revision, 0);
|
||||
});
|
||||
|
||||
test('replacement context and reconnect never receive stale async completion', async () => {
|
||||
for (const change of ['replace', 'session', 'disconnect']) {
|
||||
const s = setup(), request = dto(), gate = deferred(); s.options.workflow_gate = gate.promise;
|
||||
await s.run(request); await flush();
|
||||
if (change === 'replace') s.contexts.set(1, {...s.context});
|
||||
if (change === 'session') s.socket.id = 'new';
|
||||
if (change === 'disconnect') s.socket.connected = false;
|
||||
gate.resolve(); await flush();
|
||||
if (change !== 'replace') { s.socket.id = 'session'; s.socket.connected = true; }
|
||||
assert.equal((await s.run(request)).data.code, change === 'replace' ? 'CONTEXT_CHANGED' : 'SESSION_MISMATCH');
|
||||
assert.equal(s.context.bot_config.config_revision, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('per-context queue serializes revisions and rejects stale, conflicting revision and command payloads', async () => {
|
||||
const s = setup(), first = dto(2), gate = deferred(); s.options.plugin_gate = gate.promise;
|
||||
await s.run(first);
|
||||
const second = dto(3, {plugins: [{path: 'runtime', name: 'a', config: {}}]});
|
||||
await s.run(second);
|
||||
assert.equal((await s.run(dto(1))).data.code, 'STALE_REVISION');
|
||||
assert.equal((await s.run({...second, command_id: randomUUID(), plugins: []})).data.code, 'REVISION_CONFLICT');
|
||||
assert.equal((await s.run({...first, config_revision: 4})).data.code, 'COMMAND_CONFLICT');
|
||||
assert.deepEqual(s.calls.map(call => call[0]), ['plugins']);
|
||||
gate.resolve(); await flush();
|
||||
assert.deepEqual(s.calls.map(call => call[0]), ['plugins', 'workflows', 'plugins', 'workflows']);
|
||||
assert.equal(s.context.bot_config.config_revision, 3);
|
||||
assert.deepEqual(await s.run(second), success(second, 'completed'));
|
||||
});
|
||||
|
||||
test('initial context revision fences old commands and identical applied configs preserve managers', async () => {
|
||||
const s = setup();
|
||||
s.context.bot_config.config_revision = 9;
|
||||
assert.equal((await s.run(dto(8))).data.code, 'STALE_REVISION');
|
||||
const request = dto(9);
|
||||
await s.run(request); await flush();
|
||||
assert.deepEqual(await s.run(request), success(request, 'completed'));
|
||||
assert.deepEqual(s.calls, []);
|
||||
assert.equal((await s.run(dto(9, {plugins: [{path: 'runtime', name: 'a', config: {}}]}))).data.code, 'REVISION_CONFLICT');
|
||||
});
|
||||
|
||||
test('workflow partial failure is reported safely and same command recovers on next attempt', async () => {
|
||||
const s = setup(), request = dto();
|
||||
s.options.workflow_error = {status: 'error', data: {code: 'LEAK', message: 'password secret'}};
|
||||
await s.run(request); await flush();
|
||||
const reply = await s.run(request);
|
||||
assert.equal(reply.data.code, 'PARTIAL_APPLY_FAILED');
|
||||
assert.equal(JSON.stringify([reply, s.logs]).includes('secret'), false);
|
||||
assert.equal(s.context.bot_config.config_revision, 0);
|
||||
s.options.workflow_error = undefined;
|
||||
assert.deepEqual(await s.run(request), success(request, 'processing'));
|
||||
await flush();
|
||||
assert.deepEqual(await s.run(request), success(request, 'completed'));
|
||||
assert.equal(s.calls.filter(call => call[0] === 'workflows').length, 2);
|
||||
});
|
||||
|
||||
test('pending jobs capped at 32 without evicting inflight work', async () => {
|
||||
const s = setup(), gate = deferred(); s.options.plugin_gate = gate.promise;
|
||||
const requests = Array.from({length: 32}, (_, i) => dto(i + 1));
|
||||
for (const request of requests) assert.equal((await s.run(request)).data.state, 'processing');
|
||||
assert.equal((await s.run(dto(33))).data.code, 'APPLY_BUSY');
|
||||
assert.deepEqual(await s.run(requests[0]), success(requests[0], 'processing'));
|
||||
gate.resolve(); await new Promise(setImmediate);
|
||||
assert.deepEqual(await s.run(requests.at(-1)), success(requests.at(-1), 'completed'));
|
||||
assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 2);
|
||||
assert.equal((await s.run(requests[1])).data.code, 'STALE_REVISION');
|
||||
});
|
||||
|
||||
test('completed job cache retains 100 entries and evicted commands cannot roll revisions back', async () => {
|
||||
const s = setup(), first = dto(1);
|
||||
await s.run(first); await flush();
|
||||
for (let revision = 2; revision <= 101; revision++) { await s.run(dto(revision)); await flush(); }
|
||||
assert.equal((await s.run(first)).data.code, 'STALE_REVISION');
|
||||
assert.equal(s.context.bot_config.config_revision, 101);
|
||||
assert.equal(s.calls.filter(call => call[0] === 'plugins').length, 101);
|
||||
});
|
||||
Reference in New Issue
Block a user