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: ' 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)); }); });