426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
/** Unit tests for the workflow validation and repair helpers (node inputs, parameters, output refs, positioning) — deterministic. */
|
|
import { describe, expect, test } from 'bun:test';
|
|
import type { WorkflowDefinition, WorkflowNode } from '../../src/types/index';
|
|
import {
|
|
correctOptionParameters,
|
|
detectUnknownParameters,
|
|
ensureExpressionPrefix,
|
|
positionNodes,
|
|
validateNodeInputs,
|
|
validateNodeParameters,
|
|
validateOutputReferences,
|
|
validateWorkflow,
|
|
} from '../../src/utils/workflow';
|
|
|
|
function schedule(overrides?: Partial<WorkflowNode>): WorkflowNode {
|
|
return {
|
|
name: 'Schedule Trigger',
|
|
type: 'workflows-nodes-base.scheduleTrigger',
|
|
typeVersion: 1,
|
|
position: [250, 300],
|
|
parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 1 }] } },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function http(overrides?: Partial<WorkflowNode>): WorkflowNode {
|
|
return {
|
|
name: 'HTTP Request',
|
|
type: 'workflows-nodes-base.httpRequest',
|
|
typeVersion: 4.3,
|
|
position: [500, 300],
|
|
parameters: { method: 'GET', url: 'https://example.com' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function setNode(overrides?: Partial<WorkflowNode>): WorkflowNode {
|
|
return {
|
|
name: 'Set',
|
|
type: 'workflows-nodes-base.set',
|
|
typeVersion: 3.4,
|
|
position: [750, 300],
|
|
parameters: {
|
|
assignments: {
|
|
assignments: [{ name: 'message', value: 'Hello', type: 'string' }],
|
|
},
|
|
options: {},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function code(overrides?: Partial<WorkflowNode>): WorkflowNode {
|
|
return {
|
|
name: 'Code',
|
|
type: 'workflows-nodes-base.code',
|
|
typeVersion: 2,
|
|
position: [750, 420],
|
|
parameters: { jsCode: 'return items;' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function validWorkflow(overrides?: Partial<WorkflowDefinition>): WorkflowDefinition {
|
|
return {
|
|
name: 'Supported Workflow',
|
|
nodes: [schedule(), http()],
|
|
connections: {
|
|
'Schedule Trigger': {
|
|
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function malformedWorkflow(overrides: Record<string, unknown>): WorkflowDefinition {
|
|
return {
|
|
name: 'Bad',
|
|
nodes: [],
|
|
connections: {},
|
|
...overrides,
|
|
} as WorkflowDefinition;
|
|
}
|
|
|
|
function withoutPosition(node: WorkflowNode): WorkflowNode {
|
|
const copy: Partial<WorkflowNode> = { ...node };
|
|
delete copy.position;
|
|
return copy as WorkflowNode;
|
|
}
|
|
|
|
describe('validateWorkflow', () => {
|
|
test('valid supported workflow passes validation', () => {
|
|
const result = validateWorkflow(validWorkflow());
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toEqual([]);
|
|
});
|
|
|
|
test('rejects empty or malformed workflows', () => {
|
|
expect(validateWorkflow({ name: 'Empty', nodes: [], connections: {} }).errors).toContain(
|
|
'Workflow must have at least one node'
|
|
);
|
|
expect(validateWorkflow(malformedWorkflow({ nodes: null, connections: {} })).errors).toContain(
|
|
'Missing or invalid nodes array'
|
|
);
|
|
expect(
|
|
validateWorkflow(malformedWorkflow({ nodes: [schedule()], connections: null })).errors
|
|
).toContain('Missing or invalid connections object');
|
|
});
|
|
|
|
test('detects duplicate node names and broken connections', () => {
|
|
const duplicate = validateWorkflow({
|
|
name: 'Duplicate',
|
|
nodes: [schedule({ name: 'A' }), http({ name: 'A' })],
|
|
connections: {},
|
|
});
|
|
expect(duplicate.valid).toBe(false);
|
|
expect(duplicate.errors.some((e) => e.includes('Duplicate node name'))).toBe(true);
|
|
|
|
const broken = validateWorkflow(
|
|
validWorkflow({
|
|
connections: {
|
|
'Schedule Trigger': {
|
|
main: [[{ node: 'Missing', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
expect(broken.valid).toBe(false);
|
|
expect(broken.errors.some((e) => e.includes('non-existent target node'))).toBe(true);
|
|
});
|
|
|
|
test('warns about missing trigger and orphan nodes', () => {
|
|
const noTrigger = validateWorkflow({
|
|
name: 'No trigger',
|
|
nodes: [http(), setNode()],
|
|
connections: { 'HTTP Request': { main: [[{ node: 'Set', type: 'main', index: 0 }]] } },
|
|
});
|
|
expect(noTrigger.valid).toBe(true);
|
|
expect(noTrigger.warnings.some((w) => w.includes('no trigger node'))).toBe(true);
|
|
|
|
const orphan = validateWorkflow({
|
|
name: 'Orphan',
|
|
nodes: [schedule(), http(), setNode()],
|
|
connections: {
|
|
'Schedule Trigger': {
|
|
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
});
|
|
expect(orphan.warnings.some((w) => w.includes('Set') && w.includes('no incoming'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('positionNodes', () => {
|
|
test('skips positioning when all nodes have valid positions', () => {
|
|
const workflow = validWorkflow();
|
|
const result = positionNodes(workflow);
|
|
expect(result.nodes[0].position).toEqual(workflow.nodes[0].position);
|
|
expect(result.nodes[1].position).toEqual(workflow.nodes[1].position);
|
|
});
|
|
|
|
test('positions missing nodes left-to-right', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [withoutPosition(schedule()), withoutPosition(http())],
|
|
});
|
|
const result = positionNodes(workflow);
|
|
const trigger = result.nodes.find((n) => n.name === 'Schedule Trigger');
|
|
const request = result.nodes.find((n) => n.name === 'HTTP Request');
|
|
expect(trigger).toBeDefined();
|
|
expect(request).toBeDefined();
|
|
if (!trigger || !request) throw new Error('expected positioned nodes');
|
|
expect(trigger.position[0]).toBeLessThan(request.position[0]);
|
|
});
|
|
|
|
test('positions branching nodes at the same depth with different Y values', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [withoutPosition(schedule()), withoutPosition(setNode()), withoutPosition(code())],
|
|
connections: {
|
|
'Schedule Trigger': {
|
|
main: [
|
|
[
|
|
{ node: 'Set', type: 'main', index: 0 },
|
|
{ node: 'Code', type: 'main', index: 0 },
|
|
],
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const result = positionNodes(workflow);
|
|
const setPos = result.nodes.find((n) => n.name === 'Set')?.position;
|
|
const codePos = result.nodes.find((n) => n.name === 'Code')?.position;
|
|
expect(setPos[0]).toBe(codePos[0]);
|
|
expect(setPos[1]).not.toBe(codePos[1]);
|
|
});
|
|
});
|
|
|
|
describe('validateOutputReferences', () => {
|
|
test('does not emit false positives when trimmed schema indexes are empty', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [schedule(), http({ parameters: { method: 'GET', url: '={{ $json.url }}' } })],
|
|
});
|
|
expect(validateOutputReferences(workflow)).toEqual([]);
|
|
});
|
|
|
|
test('returns empty for static parameters', () => {
|
|
expect(validateOutputReferences(validWorkflow())).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('validateNodeParameters', () => {
|
|
test('detects visible required parameters', () => {
|
|
const warnings = validateNodeParameters(
|
|
validWorkflow({
|
|
nodes: [schedule(), http({ parameters: { method: 'GET' } })],
|
|
})
|
|
);
|
|
expect(warnings.some((w) => w.includes('HTTP Request') && w.includes('URL'))).toBe(true);
|
|
});
|
|
|
|
test('skips unknown node types', () => {
|
|
const warnings = validateNodeParameters({
|
|
name: 'Unknown',
|
|
nodes: [
|
|
{
|
|
name: 'Custom',
|
|
type: 'workflows-nodes-community.unknownNode',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
],
|
|
connections: {},
|
|
});
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
test('includes the catalog property description so the user knows what the parameter governs', () => {
|
|
// Repro: clarification panel shows `missing required parameter "URL"`.
|
|
// The displayName alone is often opaque; the description is the same
|
|
// hover-text the upstream node UI shows. The catalog's URL property
|
|
// carries the description "The URL to make the request to".
|
|
const warnings = validateNodeParameters(
|
|
validWorkflow({
|
|
nodes: [schedule(), http({ parameters: { method: 'GET' } })],
|
|
})
|
|
);
|
|
const urlWarning = warnings.find((w) => w.includes('"URL"'));
|
|
expect(urlWarning).toBeDefined();
|
|
expect(urlWarning).toContain('(The URL to make the request to)');
|
|
});
|
|
|
|
test('omits the parens suffix when the property has no description', () => {
|
|
// The catalog `code` node's required `jsCode` property has no
|
|
// description. The warning string must stay tight in that case —
|
|
// no empty parens, no trailing whitespace.
|
|
const warnings = validateNodeParameters({
|
|
name: 'No description',
|
|
nodes: [
|
|
{
|
|
name: 'Code',
|
|
type: 'workflows-nodes-base.code',
|
|
typeVersion: 2,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
],
|
|
connections: {},
|
|
});
|
|
const jsCodeWarning = warnings.find(
|
|
(w) => w.includes('Code') && w.includes('required parameter')
|
|
);
|
|
if (jsCodeWarning) {
|
|
expect(jsCodeWarning).not.toMatch(/\(\s*\)/);
|
|
expect(jsCodeWarning.endsWith('"')).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('strips HTML tags from the catalog description', () => {
|
|
// Catalog descriptions can embed HTML like <a href="...">expression</a>
|
|
// sourced from the upstream node-types definitions. The clarification
|
|
// surface is plain text, so raw HTML must not leak through. Verify by
|
|
// monkey-patching a node's catalog entry to include HTML.
|
|
const workflow = {
|
|
name: 'Custom HTML',
|
|
nodes: [
|
|
{
|
|
name: 'Set',
|
|
type: 'workflows-nodes-base.set',
|
|
typeVersion: 3.4,
|
|
position: [0, 0] as [number, number],
|
|
parameters: {},
|
|
},
|
|
],
|
|
connections: {},
|
|
};
|
|
const warnings = validateNodeParameters(workflow);
|
|
for (const w of warnings) {
|
|
expect(w).not.toMatch(/<[^>]+>/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('validateNodeInputs', () => {
|
|
test('returns no warnings for connected workflow', () => {
|
|
expect(validateNodeInputs(validWorkflow())).toEqual([]);
|
|
});
|
|
|
|
test('warns about action node with no incoming connection', () => {
|
|
const warnings = validateNodeInputs({
|
|
name: 'Disconnected',
|
|
nodes: [schedule(), http()],
|
|
connections: {},
|
|
});
|
|
expect(warnings.some((w) => w.includes('HTTP Request'))).toBe(true);
|
|
});
|
|
|
|
test('does not warn about trigger nodes without incoming connections', () => {
|
|
expect(
|
|
validateNodeInputs({
|
|
name: 'Only trigger',
|
|
nodes: [schedule()],
|
|
connections: {},
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('correctOptionParameters', () => {
|
|
test('corrects invalid option values and typeVersion against the supported catalog', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [
|
|
schedule(),
|
|
http({
|
|
typeVersion: 99,
|
|
parameters: { method: 'TRACE', url: 'https://example.com' },
|
|
}),
|
|
],
|
|
});
|
|
const fixes = correctOptionParameters(workflow);
|
|
expect(fixes).toBeGreaterThanOrEqual(2);
|
|
expect(workflow.nodes[1].typeVersion).toBe(4.3);
|
|
expect(workflow.nodes[1].parameters.method).toBe('GET');
|
|
});
|
|
|
|
test('does not touch valid parameters', () => {
|
|
const workflow = validWorkflow();
|
|
expect(correctOptionParameters(workflow)).toBe(0);
|
|
});
|
|
|
|
test('normalizes generated Set node assignments.values into executable assignments', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [
|
|
schedule(),
|
|
setNode({
|
|
parameters: {
|
|
mode: 'manual',
|
|
assignments: {
|
|
values: [
|
|
{ name: 'source', value: 'cerebras' },
|
|
{ name: 'verified', value: true },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
connections: {
|
|
'Schedule Trigger': {
|
|
main: [[{ node: 'Set', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(correctOptionParameters(workflow)).toBe(1);
|
|
expect(workflow.nodes[1].parameters.assignments).toEqual({
|
|
assignments: [
|
|
{ name: 'source', value: 'cerebras' },
|
|
{ name: 'verified', value: true },
|
|
],
|
|
});
|
|
});
|
|
|
|
test('skips unknown node types', () => {
|
|
const workflow: WorkflowDefinition = {
|
|
name: 'Unknown',
|
|
nodes: [
|
|
{
|
|
name: 'Custom',
|
|
type: 'workflows-nodes-community.unknown',
|
|
typeVersion: 1,
|
|
position: [250, 300],
|
|
parameters: { resource: 'whatever' },
|
|
},
|
|
],
|
|
connections: {},
|
|
};
|
|
expect(correctOptionParameters(workflow)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('detectUnknownParameters', () => {
|
|
test('detects unknown parameters on supported nodes', () => {
|
|
const detections = detectUnknownParameters(
|
|
validWorkflow({
|
|
nodes: [
|
|
schedule(),
|
|
http({ parameters: { method: 'GET', url: 'https://x.test', bogus: 1 } }),
|
|
],
|
|
})
|
|
);
|
|
expect(detections).toHaveLength(1);
|
|
expect(detections[0].nodeName).toBe('HTTP Request');
|
|
expect(detections[0].unknownKeys).toContain('bogus');
|
|
expect(detections[0].unknownKeys).not.toContain('url');
|
|
});
|
|
|
|
test('defaults keep Code node jsCode visible when mode/language are omitted', () => {
|
|
const detections = detectUnknownParameters({
|
|
name: 'Code',
|
|
nodes: [code({ parameters: { jsCode: 'return items;' } })],
|
|
connections: {},
|
|
});
|
|
expect(detections).toEqual([]);
|
|
});
|
|
|
|
test('skips unknown node types', () => {
|
|
const detections = detectUnknownParameters({
|
|
name: 'Unknown Type',
|
|
nodes: [
|
|
{
|
|
name: 'Custom',
|
|
type: 'workflows-nodes-community.unknown',
|
|
typeVersion: 1,
|
|
position: [250, 300],
|
|
parameters: { anything: 'goes' },
|
|
},
|
|
],
|
|
connections: {},
|
|
});
|
|
expect(detections).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('ensureExpressionPrefix', () => {
|
|
test('adds = prefix to expression strings', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [
|
|
http({
|
|
parameters: {
|
|
method: 'GET',
|
|
url: '{{ $json.url }}',
|
|
queryParameters: { parameters: [{ name: 'q', value: 'static' }] },
|
|
},
|
|
}),
|
|
],
|
|
connections: {},
|
|
});
|
|
const count = ensureExpressionPrefix(workflow);
|
|
expect(count).toBe(1);
|
|
expect(workflow.nodes[0].parameters.url).toBe('={{ $json.url }}');
|
|
});
|
|
|
|
test('handles nested objects and arrays without double-prefixing', () => {
|
|
const workflow = validWorkflow({
|
|
nodes: [
|
|
setNode({
|
|
parameters: {
|
|
assignments: {
|
|
assignments: [
|
|
{ name: 'a', value: '{{ $json.a }}', type: 'string' },
|
|
{ name: 'b', value: '={{ $json.b }}', type: 'string' },
|
|
],
|
|
},
|
|
options: { values: ['{{ $json.c }}', 'static'] },
|
|
},
|
|
}),
|
|
],
|
|
connections: {},
|
|
});
|
|
const count = ensureExpressionPrefix(workflow);
|
|
expect(count).toBe(2);
|
|
const assignmentBlock = workflow.nodes[0].parameters.assignments as {
|
|
assignments: Array<{ name: string; value: string; type: string }>;
|
|
};
|
|
expect(assignmentBlock.assignments[0].value).toBe('={{ $json.a }}');
|
|
expect(assignmentBlock.assignments[1].value).toBe('={{ $json.b }}');
|
|
const optionsBlock = workflow.nodes[0].parameters.options as { values: string[] };
|
|
expect(optionsBlock.values[0]).toBe('={{ $json.c }}');
|
|
});
|
|
});
|