chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+457
View File
@@ -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));
});
});