chore: initial commit
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHook} from 'node:async_hooks';
|
||||
import {isBuiltin, createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {runInThisContext} from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import {build} from 'esbuild';
|
||||
import {tsImport} from 'tsx/esm/api';
|
||||
|
||||
const {get_workflow_catalog, validate_workflow_catalog} = await tsImport('../bot/workflow/catalog.ts', import.meta.url);
|
||||
const {node_registry, start_registry, query_registry} = await tsImport('../bot/workflow/registry.ts', import.meta.url);
|
||||
const {event_registry, normalize_event} = await tsImport('../bot/workflow/node_action/event_node/registry.ts', import.meta.url);
|
||||
const {register_event_start} = await tsImport('../bot/workflow/start_node/event_node.ts', import.meta.url);
|
||||
const {resolve_fields, validate_fields, validate_output} = await tsImport('../bot/workflow/runtime/template.ts', import.meta.url);
|
||||
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
|
||||
const {execute_node} = await tsImport('../bot/workflow/runtime/execute.ts', import.meta.url);
|
||||
|
||||
const node_names = ['code', 'condition', 'error', 'function', 'goto', 'log', 'merge', 'query', 'sleep'];
|
||||
const start_names = ['common', 'event', 'timer'];
|
||||
const event_names = ['chat', 'custom', 'health', 'message', 'spawn', 'whisper'];
|
||||
const ports = {
|
||||
function: ['failure', 'success'], query: ['failure', 'success'], sleep: ['failure', 'success'],
|
||||
log: ['failure', 'success'], code: ['failure', 'success'], merge: ['failure', 'success'],
|
||||
condition: ['failure', 'false', 'true'], error: ['error', 'failure', 'normal', 'unmatched'],
|
||||
goto: ['exhausted', 'failure', 'retry'],
|
||||
};
|
||||
const names = items => items.map(item => item.name).sort();
|
||||
const literal = value => ({kind: 'literal', value});
|
||||
const translation = () => [{lang: 'en', name: 'Test field', description: 'A described test value.'}];
|
||||
const field = (name, type, extra = {}) => ({name, type, required: false, i18n: translation(), ...extra});
|
||||
const node_template = (catalog, name) => catalog.nodes.find(node => node.name === name);
|
||||
const config_field = (catalog, node, name) => node_template(catalog, node).config.find(item => item.name === name);
|
||||
function definition(type = 'sleep', config = {}, extra = {}) {
|
||||
return {
|
||||
schema_version: 1, id: 'catalog_test', name: 'Catalog test', enabled: true,
|
||||
start: {type: 'common'}, entry: 'entry', nodes: [{id: 'entry', type, config}], edges: [], policy: {},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
function described(i18n, path) {
|
||||
assert.ok(Array.isArray(i18n) && i18n.length > 0, `${path} translations`);
|
||||
assert.equal(new Set(i18n.map(item => item.lang)).size, i18n.length, `${path} unique languages`);
|
||||
for (const item of i18n) {
|
||||
for (const key of ['lang', 'name', 'description']) {
|
||||
assert.equal(typeof item[key], 'string', `${path}.${key}`);
|
||||
assert.ok(item[key].trim(), `${path}.${key} cannot be empty`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function described_fields(fields, path) {
|
||||
assert.equal(new Set(fields.map(item => item.name)).size, fields.length, `${path} unique fields`);
|
||||
for (const item of fields) {
|
||||
described(item.i18n, `${path}.${item.name}`);
|
||||
assert.equal(typeof item.required, 'boolean');
|
||||
for (const option of item.options ?? []) described(option.i18n, `${path}.${item.name}.${option.value}`);
|
||||
if (item.fields) described_fields(item.fields, `${path}.${item.name}`);
|
||||
if (item.item) described_fields([item.item], `${path}.${item.name}.item`);
|
||||
}
|
||||
}
|
||||
function described_output(output, path) {
|
||||
described(output.i18n, path);
|
||||
for (const [name, child] of Object.entries(output.properties ?? {})) described_output(child, `${path}.${name}`);
|
||||
if (output.items) described_output(output.items, `${path}.items`);
|
||||
if (output.additional_properties) described_output(output.additional_properties, `${path}.*`);
|
||||
}
|
||||
function matches_output(value, output, path = 'output') {
|
||||
if (value === null && (output.nullable || output.type === 'json' || output.type === 'null')) return;
|
||||
if (output.type === 'json') return;
|
||||
if (output.type === 'object') {
|
||||
assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), path);
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const descriptor = output.properties?.[key] ?? output.additional_properties;
|
||||
assert.ok(descriptor, `${path}.${key} must be described`);
|
||||
matches_output(child, descriptor, `${path}.${key}`);
|
||||
}
|
||||
} else if (output.type === 'array') {
|
||||
assert.ok(Array.isArray(value), path);
|
||||
assert.ok(output.items, `${path} needs item descriptions`);
|
||||
value.forEach((child, index) => matches_output(child, output.items, `${path}.${index}`));
|
||||
} else assert.equal(typeof value, output.type, path);
|
||||
}
|
||||
|
||||
test('runtime fixture ports and output values agree with all nine node declarations', async t => {
|
||||
const state = {trigger: {}, vars: {}, outputs: {}, last_result: null, inputs: {}, retry_counts: {}};
|
||||
const failed = {...state, last_result: {status: 'failure', output: false,
|
||||
error: {code: 'TEST', message: 'fixture failure', node_id: 'prior'}}};
|
||||
const deps = {
|
||||
run_function: async () => true, query: async () => ({value: false}), sleep: async () => {},
|
||||
log() {}, execute_code: async () => [0, false, null],
|
||||
};
|
||||
const fixtures = [
|
||||
['function', {path: 'bot', name: 'chat', args: []}, state, 'success', true],
|
||||
['query', {source: 'info', args: {}}, state, 'success', {value: false}],
|
||||
['sleep', {mode: 'milliseconds', value: 0}, state, 'success', null],
|
||||
['log', {level: 'info', message: literal(false)}, state, 'success', false],
|
||||
['code', {source: 'return [0, false, null];'}, state, 'success', [0, false, null]],
|
||||
['condition', {condition: {op: 'eq', left: literal(0), right: literal(0)}}, state, 'true', true],
|
||||
['condition', {condition: {op: 'eq', left: literal(0), right: literal(false)}}, state, 'false', false],
|
||||
['error', {}, state, 'normal', null], ['error', {}, failed, 'error', false],
|
||||
['error', {codes: []}, failed, 'unmatched', false],
|
||||
['goto', {max_retries: 1, delay_ms: 0}, state, 'retry', null],
|
||||
['goto', {max_retries: 0, delay_ms: 0}, state, 'exhausted', null],
|
||||
['merge', {mode: 'any'}, state, 'success', {}],
|
||||
];
|
||||
for (const [type, config, branch, port, output] of fixtures) await t.test(`${type}.${port}`, async () => {
|
||||
const returned = await node_registry[type].execute({id: 'fixture', type, config}, structuredClone(branch), new AbortController().signal, deps);
|
||||
assert.equal(returned.port, port);
|
||||
assert.deepEqual(returned.result.output, output);
|
||||
assert.ok(node_registry[type].config_template.ports.some(item => item.name === returned.port));
|
||||
matches_output(returned.result.output, node_registry[type].config_template.output, `${type}.output`);
|
||||
});
|
||||
for (const type of ['function', 'condition', 'merge']) await t.test(`${type}.failure null output`, async () => {
|
||||
const returned = await execute_node({id: 'fixture', type, config: {}, timeout_ms: 0}, structuredClone(state), new AbortController().signal, deps);
|
||||
assert.equal(returned.port, 'failure');
|
||||
assert.equal(returned.result.status, 'failure');
|
||||
assert.equal(returned.result.output, null);
|
||||
matches_output(returned.result.output, node_registry[type].config_template.output, `${type}.failure.output`);
|
||||
});
|
||||
});
|
||||
|
||||
test('info query output describes the actual value including null and falsy values', () => {
|
||||
const module = query_registry.info;
|
||||
for (const value of [null, false, 0, '', [], {nested: [false, 0, null]}]) {
|
||||
const result = module.query_info({info_manager: {get_info(name) {
|
||||
assert.equal(name, 'fixture');
|
||||
return {name: 'fixture', i18n: translation(), value};
|
||||
}}}, {name: 'fixture'});
|
||||
assert.deepEqual(result, value);
|
||||
matches_output(result, module.config_template.output);
|
||||
}
|
||||
for (const args of [{}, {name: ''}, {name: 0}]) {
|
||||
assert.throws(() => resolve_fields(module.config_template.config, args));
|
||||
assert.throws(() => module.query_info({info_manager: null}, args), {code: 'INVALID_QUERY_ARGUMENTS'});
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog contains every registered node, start, event and query with recursive descriptions', () => {
|
||||
const catalog = get_workflow_catalog();
|
||||
assert.equal(catalog.catalog_version, 1);
|
||||
assert.deepEqual(names(catalog.nodes), node_names);
|
||||
assert.deepEqual(names(catalog.start_nodes), start_names);
|
||||
assert.deepEqual(names(catalog.events), event_names);
|
||||
assert.deepEqual(names(catalog.queries), ['info']);
|
||||
for (const [registry, entries] of [
|
||||
[node_registry, catalog.nodes], [start_registry, catalog.start_nodes],
|
||||
[event_registry, catalog.events], [query_registry, catalog.queries],
|
||||
]) {
|
||||
assert.deepEqual(Object.keys(registry).sort(), names(entries));
|
||||
for (const [name, module] of Object.entries(registry)) {
|
||||
assert.equal(module.config_template.name, name);
|
||||
assert.deepEqual(entries.find(item => item.name === name).config, module.config_template.config);
|
||||
}
|
||||
}
|
||||
for (const item of [...catalog.nodes, ...catalog.start_nodes, ...catalog.events, ...catalog.queries]) {
|
||||
assert.match(item.version, /^\d+\.\d+\.\d+$/);
|
||||
described(item.i18n, item.name);
|
||||
described_fields(item.config, item.name);
|
||||
described_output(item.output, `${item.name}.output`);
|
||||
assert.doesNotThrow(() => validate_fields(item.config));
|
||||
assert.doesNotThrow(() => validate_output(item.output));
|
||||
}
|
||||
for (const node of catalog.nodes) {
|
||||
assert.equal(typeof node_registry[node.name].execute, 'function');
|
||||
assert.deepEqual(names(node.ports), ports[node.name]);
|
||||
node.ports.forEach(port => described(port.i18n, `${node.name}.${port.name}`));
|
||||
}
|
||||
catalog.events.forEach(event => assert.equal(event.readiness, 'spawned'));
|
||||
assert.equal(catalog.events.find(event => event.name === 'custom').custom, true);
|
||||
assert.equal(catalog.queries[0].provider, 'info');
|
||||
assert.equal(catalog.queries[0].config.find(item => item.name === 'name').provider, 'info');
|
||||
described_fields(catalog.common.node_fields, 'common.node_fields');
|
||||
described_fields(catalog.common.policy_fields, 'common.policy_fields');
|
||||
described_output(catalog.common.result, 'common.result');
|
||||
described_output(catalog.common.edge_input, 'common.edge_input');
|
||||
assert.deepEqual(catalog.common.references.map(item => item.root).sort(), ['inputs', 'last_result', 'outputs', 'trigger', 'vars']);
|
||||
catalog.common.references.forEach(item => described(item.i18n, item.root));
|
||||
assert.doesNotThrow(() => validate_workflow_catalog(catalog));
|
||||
});
|
||||
|
||||
test('catalog inspection imports and runs without creating Bot, timer or worker resources', async () => {
|
||||
const compiled = await build({
|
||||
entryPoints: [fileURLToPath(new URL('../bot/workflow/catalog.ts', import.meta.url))],
|
||||
bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true,
|
||||
});
|
||||
assert.ok(!Object.keys(compiled.metafile.inputs).some(path => /bot\/(instance|registry)|forward_server/.test(path)));
|
||||
const resources = [];
|
||||
const hook = createHook({init(_id, type) { resources.push(type); }});
|
||||
const require = createRequire(import.meta.url);
|
||||
const module = {exports: {}};
|
||||
const before = process.getActiveResourcesInfo().sort();
|
||||
hook.enable();
|
||||
try {
|
||||
const load = runInThisContext(`(function(module, exports, require, setTimeout, setInterval) {\n${compiled.outputFiles[0].text}\n})`);
|
||||
load(module, module.exports,
|
||||
name => { assert.ok(isBuiltin(name), `unexpected runtime dependency ${name}`); return require(name); },
|
||||
() => assert.fail('catalog started a timeout'),
|
||||
() => assert.fail('catalog started an interval'));
|
||||
for (let index = 0; index < 3; index++) module.exports.get_workflow_catalog({allow_code: index === 1});
|
||||
} finally { hook.disable(); }
|
||||
assert.deepEqual(process.getActiveResourcesInfo().sort(), before);
|
||||
assert.deepEqual(resources.filter(type => /^(Timeout|WORKER|MESSAGEPORT|TCPWRAP|TCPSERVERWRAP|UDPWRAP)$/.test(type)), []);
|
||||
});
|
||||
|
||||
test('catalog results are detached at every nested level and code is disabled unless explicitly enabled', () => {
|
||||
const baseline = get_workflow_catalog();
|
||||
const changed = get_workflow_catalog();
|
||||
node_template(changed, 'sleep').i18n[0].name = 'changed';
|
||||
config_field(changed, 'sleep', 'mode').options[0].i18n[0].description = 'changed';
|
||||
config_field(changed, 'function', 'args').default.push(literal(9));
|
||||
config_field(changed, 'query', 'args').default.changed = literal(false);
|
||||
node_template(changed, 'merge').output.additional_properties.properties.source.i18n[0].description = 'changed';
|
||||
changed.events[0].output.properties.event.i18n[0].description = 'changed';
|
||||
changed.start_nodes[0].config.length = 0;
|
||||
changed.queries[0].i18n.length = 0;
|
||||
changed.common.node_fields[0].default = 2;
|
||||
changed.common.result.properties.status.i18n[0].description = 'changed';
|
||||
changed.common.references[0].root = 'changed';
|
||||
node_template(changed, 'code').availability.available = true;
|
||||
assert.deepEqual(get_workflow_catalog(), baseline);
|
||||
assert.deepEqual(node_template(baseline, 'code').availability, {available: false, reason: 'CODE_EXECUTION_DISABLED'});
|
||||
const enabled = get_workflow_catalog({allow_code: true});
|
||||
assert.deepEqual(node_template(enabled, 'code').availability, {available: true});
|
||||
enabled.nodes = enabled.nodes.filter(node => node.name !== 'code');
|
||||
assert.deepEqual(get_workflow_catalog({allow_code: false}), baseline);
|
||||
assert.equal(node_registry.code.config_template.availability.available, false);
|
||||
});
|
||||
|
||||
test('compile applies documented defaults without mutating caller input or registry templates', () => {
|
||||
const input = definition();
|
||||
const original = structuredClone(input);
|
||||
const catalog = get_workflow_catalog();
|
||||
const graph = compile_workflow(input);
|
||||
assert.deepEqual(graph.definition.start, {type: 'common', auto_start: 'never'});
|
||||
assert.deepEqual(graph.nodes.get('entry').config, {mode: 'milliseconds', value: 1000});
|
||||
assert.equal(graph.nodes.get('entry').timeout_ms, 30000);
|
||||
assert.deepEqual(graph.definition.policy, {
|
||||
max_concurrent_runs: 1, on_busy: 'skip', max_queue: 10, max_steps: 1000, timeout_ms: 60000,
|
||||
});
|
||||
assert.deepEqual(input, original);
|
||||
graph.definition.start.auto_start = 'spawn';
|
||||
graph.nodes.get('entry').config.value = 2;
|
||||
graph.definition.policy.max_queue = 999;
|
||||
assert.deepEqual(input, original);
|
||||
assert.deepEqual(get_workflow_catalog(), catalog);
|
||||
assert.equal(compile_workflow(input).nodes.get('entry').config.value, 1000);
|
||||
const without_policy = definition();
|
||||
delete without_policy.policy;
|
||||
assert.deepEqual(compile_workflow(without_policy).definition.policy, resolve_fields(catalog.common.policy_fields, {}));
|
||||
});
|
||||
|
||||
test('compile preserves explicit zero, false and complete valid configs', () => {
|
||||
const input = definition('sleep', {mode: 'ticks', value: 0}, {
|
||||
enabled: false, start: {type: 'common', auto_start: 'spawn'}, initial_vars: {zero: 0, flag: false, empty: ''},
|
||||
policy: {max_concurrent_runs: 2, on_busy: 'queue', max_queue: 0, max_steps: 1, timeout_ms: 1},
|
||||
});
|
||||
input.nodes[0].timeout_ms = 1;
|
||||
const original = structuredClone(input);
|
||||
assert.deepEqual(compile_workflow(input).definition, original);
|
||||
assert.deepEqual(input, original);
|
||||
const fn = compile_workflow(definition('function', {path: 'bot', name: 'chat'}));
|
||||
assert.deepEqual(fn.nodes.get('entry').config, {path: 'bot', name: 'chat', args: []});
|
||||
const query = compile_workflow(definition('query', {source: 'info'}));
|
||||
assert.deepEqual(query.nodes.get('entry').config, {source: 'info', args: {}});
|
||||
const log = compile_workflow(definition('log', {message: literal(false)}));
|
||||
assert.deepEqual(log.nodes.get('entry').config, {level: 'info', message: literal(false)});
|
||||
});
|
||||
|
||||
test('compile preserves omitted error filters as match-all and explicit empty filters as match-none', async () => {
|
||||
const state = {last_result: {status: 'failure', output: false, error: {code: 'TEST', message: 'test', node_id: 'prior'}}};
|
||||
const signal = new AbortController().signal;
|
||||
const omitted = compile_workflow(definition('error')).nodes.get('entry');
|
||||
assert.equal(Object.hasOwn(omitted.config, 'codes'), false);
|
||||
assert.equal((await node_registry.error.execute(omitted, state, signal, {})).port, 'error');
|
||||
const empty = compile_workflow(definition('error', {codes: []})).nodes.get('entry');
|
||||
assert.equal((await node_registry.error.execute(empty, state, signal, {})).port, 'unmatched');
|
||||
});
|
||||
|
||||
test('compile rejects explicit invalid values rather than replacing them with defaults', async t => {
|
||||
const cases = [
|
||||
['sleep null', value => { value.nodes[0].config.value = null; }],
|
||||
['sleep false', value => { value.nodes[0].config.value = false; }],
|
||||
['sleep string', value => { value.nodes[0].config.value = '1000'; }],
|
||||
['sleep negative', value => { value.nodes[0].config.value = -1; }],
|
||||
['sleep fraction', value => { value.nodes[0].config.value = 0.5; }],
|
||||
['sleep overflow', value => { value.nodes[0].config.value = 2147483648; }],
|
||||
['invalid select', value => { value.nodes[0].config.mode = 'seconds'; }],
|
||||
['null config', value => { value.nodes[0].config = null; }],
|
||||
['array config', value => { value.nodes[0].config = []; }],
|
||||
['node timeout zero', value => { value.nodes[0].timeout_ms = 0; }],
|
||||
['node timeout null', value => { value.nodes[0].timeout_ms = null; }],
|
||||
['invalid start choice', value => { value.start.auto_start = false; }],
|
||||
['null policy', value => { value.policy = null; }],
|
||||
['invalid busy choice', value => { value.policy.on_busy = 'replace'; }],
|
||||
['negative queue', value => { value.policy.max_queue = -1; }],
|
||||
['queue overflow', value => { value.policy.max_queue = 1001; }],
|
||||
['zero steps', value => { value.policy.max_steps = 0; }],
|
||||
['zero run timeout', value => { value.policy.timeout_ms = 0; }],
|
||||
['zero concurrency', value => { value.policy.max_concurrent_runs = 0; }],
|
||||
['concurrency overflow', value => { value.policy.max_concurrent_runs = 257; }],
|
||||
];
|
||||
for (const [name, mutate] of cases) await t.test(name, () => {
|
||||
const input = definition(); mutate(input);
|
||||
const original = structuredClone(input);
|
||||
assert.throws(() => compile_workflow(input));
|
||||
assert.deepEqual(input, original);
|
||||
});
|
||||
const invalid_nodes = [
|
||||
['function', {}], ['function', {path: 'bot'}], ['function', {name: 'chat'}],
|
||||
['function', {path: 'bot', name: 'chat', args: null}],
|
||||
['function', {path: 'bot', name: 'chat', args: Array(101).fill(literal(0))}],
|
||||
['query', {}], ['query', {source: 'info', args: []}],
|
||||
['condition', {}], ['condition', {condition: {op: 'unknown'}}],
|
||||
['log', {}], ['log', {level: 'debug', message: literal('test')}],
|
||||
['code', {}], ['code', {source: false}], ['code', {source: 'x'.repeat(100001)}],
|
||||
];
|
||||
for (const [index, [type, config]] of invalid_nodes.entries()) await t.test(`${type} required fields and constraints ${index}`, () => {
|
||||
assert.throws(() => compile_workflow(definition(type, config)));
|
||||
});
|
||||
assert.doesNotThrow(() => compile_workflow(definition('code', {source: 'return false;'})));
|
||||
});
|
||||
|
||||
test('resolve_fields fills nested absent values and returns independent defaults', () => {
|
||||
const child_fields = [field('count', 'number', {default: 7, integer: true, min: 0, max: 10}), field('flag', 'boolean', {default: true})];
|
||||
const child = field('entry', 'object', {fields: child_fields});
|
||||
const fields = [
|
||||
field('count', 'number', {default: 5}), field('flag', 'boolean', {default: true}),
|
||||
field('label', 'string', {default: 'fallback'}), field('settings', 'object', {fields: child_fields, default: {}}),
|
||||
field('rows', 'array', {item: child, default: [{}]}), field('lookup', 'map', {item: child, default: {first: {}}}),
|
||||
field('optional', 'string'),
|
||||
];
|
||||
const template_before = structuredClone(fields);
|
||||
const input = {count: 0, flag: false, label: '', settings: {count: 0, flag: false}, rows: [{}], lookup: {first: {flag: false}}, extra: {ok: false}};
|
||||
const before = structuredClone(input);
|
||||
const result = resolve_fields(fields, input);
|
||||
assert.deepEqual(result, {
|
||||
count: 0, flag: false, label: '', settings: {count: 0, flag: false}, rows: [{count: 7, flag: true}],
|
||||
lookup: {first: {count: 7, flag: false}}, extra: {ok: false},
|
||||
});
|
||||
assert.equal(Object.hasOwn(result, 'optional'), false);
|
||||
result.rows[0].count = 9; result.extra.ok = true;
|
||||
const defaults = resolve_fields(fields, {});
|
||||
defaults.lookup.first.flag = false;
|
||||
defaults.rows.push({count: 1, flag: false});
|
||||
assert.deepEqual(resolve_fields(fields, {}).rows, [{count: 7, flag: true}]);
|
||||
assert.deepEqual(input, before);
|
||||
assert.deepEqual(fields, template_before);
|
||||
for (const value of [null, '0', false, -1, 11, 0.5]) {
|
||||
assert.throws(() => resolve_fields(child_fields, {count: value}), `invalid explicit count ${value}`);
|
||||
}
|
||||
let getters = 0;
|
||||
const accessor = {get count() { getters++; return 1; }};
|
||||
assert.throws(() => resolve_fields(child_fields, accessor));
|
||||
assert.equal(getters, 0);
|
||||
});
|
||||
|
||||
test('normalizers return exactly the keys and types described by event templates', async t => {
|
||||
const fixtures = [
|
||||
['message', [{json: {translate: 'chat.type.text', with: ['Alex', 'hello']}}, 'chat', 'sender-id', false],
|
||||
{event: 'message', username: 'Alex', message: 'hello', plainText: '<Alex> hello', position: 'chat', sender: 'sender-id', verified: false}],
|
||||
['message', [{text: 'system notice'}],
|
||||
{event: 'message', username: '', message: 'system notice', plainText: 'system notice', position: '', sender: null, verified: null}],
|
||||
['chat', ['Alex', 'hello', new Date()], {event: 'chat', args: ['Alex', 'hello', null], username: 'Alex', message: 'hello'}],
|
||||
['whisper', ['Alex', 'secret'], {event: 'whisper', args: ['Alex', 'secret'], username: 'Alex', message: 'secret'}],
|
||||
['spawn', [], {event: 'spawn', args: []}], ['health', [], {event: 'health', args: []}],
|
||||
['custom', [0, false, null, {nested: ['ok']}], {event: 'custom', args: [0, false, null, {nested: ['ok']}]}],
|
||||
];
|
||||
for (const [index, [event, args, expected]] of fixtures.entries()) await t.test(`${event} ${index}`, () => {
|
||||
const value = event_registry[event].normalize(args);
|
||||
assert.deepEqual(value, expected);
|
||||
assert.deepEqual(Object.keys(value).sort(), Object.keys(event_registry[event].config_template.output.properties).sort());
|
||||
matches_output(value, event_registry[event].config_template.output);
|
||||
assert.deepEqual(normalize_event(event, args), expected);
|
||||
});
|
||||
for (const event of ['chat', 'whisper']) {
|
||||
assert.equal(normalize_event(event, [null, 'message']), undefined);
|
||||
assert.equal(normalize_event(event, ['Alex', 0]), undefined);
|
||||
}
|
||||
assert.equal(normalize_event('message', [{text: 'x'.repeat(65537)}]), undefined);
|
||||
assert.equal(normalize_event('custom', Array(33).fill(0)), undefined);
|
||||
});
|
||||
|
||||
test('unknown and custom events sanitize unsafe arguments to null and detach JSON arguments', () => {
|
||||
const cycle = {}; cycle.self = cycle;
|
||||
let getters = 0;
|
||||
const accessor = {get value() { getters++; return 1; }};
|
||||
const nested = {values: [0, false, null]};
|
||||
const args = [nested, undefined, () => {}, new Date(), NaN, Infinity, cycle, accessor, Symbol('test'), 1n];
|
||||
const expected = {event: 'unknown_event', args: [nested, ...Array(9).fill(null)]};
|
||||
const result = normalize_event('unknown_event', args);
|
||||
assert.deepEqual(result, expected);
|
||||
matches_output(result, event_registry.custom.config_template.output);
|
||||
result.args[0].values[0] = 7;
|
||||
assert.deepEqual(nested, {values: [0, false, null]});
|
||||
assert.equal(getters, 0);
|
||||
assert.deepEqual(normalize_event('custom', [null]), {event: 'custom', args: [null]});
|
||||
assert.deepEqual(normalize_event('constructor', [false]), {event: 'constructor', args: [false]});
|
||||
assert.deepEqual(normalize_event('any', ['chat', 'Alex', 'hi']), {event: 'any', args: ['chat', 'Alex', 'hi']});
|
||||
assert.equal(normalize_event('unknown_event', ['x'.repeat(65537)]), undefined);
|
||||
assert.equal(normalize_event('unknown_event', Array(33).fill(0)), undefined);
|
||||
});
|
||||
|
||||
test('event start ignores workflow_update recursion directly and through any', () => {
|
||||
const listeners = new Map();
|
||||
const context = {
|
||||
on(event, callback) { listeners.set(event, callback); },
|
||||
off(event, callback) { if (listeners.get(event) === callback) listeners.delete(event); },
|
||||
};
|
||||
const received = [];
|
||||
const config = definition('sleep', {}, {start: {type: 'event', event: 'any'}});
|
||||
const dispose = register_event_start(context, config, trigger => received.push(trigger));
|
||||
try {
|
||||
listeners.get('any')('workflow_update', {run_id: 'test'});
|
||||
assert.deepEqual(received, []);
|
||||
listeners.get('any')('custom', {value: false});
|
||||
assert.deepEqual(received, [{event: 'any', args: ['custom', {value: false}]}]);
|
||||
assert.throws(() => register_event_start(context,
|
||||
definition('sleep', {}, {start: {type: 'event', event: 'workflow_update'}}), () => {}));
|
||||
} finally { dispose(); dispose(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('catalog validator rejects duplicate entries, invalid translations, ports and recursive templates', async t => {
|
||||
const cases = [
|
||||
['version', value => { value.catalog_version = 2; }],
|
||||
...['nodes', 'start_nodes', 'events', 'queries'].map(group => [`duplicate ${group}`, value => { value[group].push(structuredClone(value[group][0])); }]),
|
||||
['entry version', value => { value.nodes[0].version = 'latest'; }],
|
||||
['missing translation', value => { value.nodes[0].i18n = []; }],
|
||||
['duplicate translation', value => { value.nodes[0].i18n.push({...value.nodes[0].i18n[0]}); }],
|
||||
['blank description', value => { value.nodes[0].i18n[0].description = ' '; }],
|
||||
['duplicate port', value => { value.nodes[0].ports.push(structuredClone(value.nodes[0].ports[0])); }],
|
||||
['invalid port name', value => { value.nodes[0].ports[0].name = 'bad port'; }],
|
||||
['missing failure port', value => { value.nodes[0].ports = value.nodes[0].ports.filter(port => port.name !== 'failure'); }],
|
||||
['port description', value => { value.nodes[0].ports[0].i18n = []; }],
|
||||
['duplicate field', value => { value.nodes[0].config.push(structuredClone(value.nodes[0].config[0])); }],
|
||||
['field description', value => { config_field(value, 'sleep', 'value').i18n[0].description = ''; }],
|
||||
['numeric default type', value => { config_field(value, 'sleep', 'value').default = false; }],
|
||||
['numeric default range', value => { config_field(value, 'sleep', 'value').default = -1; }],
|
||||
['numeric default integer', value => { config_field(value, 'sleep', 'value').default = 0.5; }],
|
||||
['inverted range', value => { config_field(value, 'sleep', 'value').min = 2147483648; }],
|
||||
['select default', value => { config_field(value, 'sleep', 'mode').default = 'seconds'; }],
|
||||
['select choices missing', value => { config_field(value, 'sleep', 'mode').options = []; }],
|
||||
['select choices duplicate', value => { const options = config_field(value, 'sleep', 'mode').options; options.push(structuredClone(options[0])); }],
|
||||
['choice description', value => { config_field(value, 'sleep', 'mode').options[0].i18n[0].description = ''; }],
|
||||
['array item missing', value => { delete config_field(value, 'function', 'args').item; }],
|
||||
['array item description', value => { config_field(value, 'function', 'args').item.i18n = []; }],
|
||||
['map item missing', value => { delete config_field(value, 'query', 'args').item; }],
|
||||
['output description', value => { value.events[0].output.properties.event.i18n = []; }],
|
||||
['array output item', value => { value.events.find(event => event.name === 'custom').output.properties.args.items.type = 'unknown'; }],
|
||||
['wrong output items owner', value => { node_template(value, 'sleep').output.items = {type: 'json', i18n: translation()}; }],
|
||||
['common field default', value => { value.common.node_fields[0].default = 0; }],
|
||||
['common reference translation', value => { value.common.references[0].i18n = []; }],
|
||||
];
|
||||
for (const [name, mutate] of cases) await t.test(name, () => {
|
||||
const catalog = get_workflow_catalog(); mutate(catalog);
|
||||
assert.throws(() => validate_workflow_catalog(catalog));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user