first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,477 @@
import { mock } from 'jest-mock-extended';
import type { SimpleWorkflow } from '@/types';
import { evaluateAgentPrompt } from './agent-prompt';
describe('evaluateAgentPrompt', () => {
it('should return no violations for empty workflow', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should return no violations for workflow without agent nodes', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Chat Trigger',
type: 'n8n-nodes-base.chatTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Code Node',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [100, 0],
parameters: {},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should return violation for agent node without expression in prompt', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: 'This is a static prompt without expressions',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(1);
expect(result.violations[0]).toEqual({
type: 'major',
name: expect.any(String),
description:
'Agent node "AI Agent" has no expression in its prompt field. This likely means it failed to use chatInput or dynamic context',
pointsDeducted: 20,
});
});
it('should return no violations for agent node with expression in prompt', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: '=Process this request: {{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should handle different expression formats', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent 1',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: '=Process: {{ $json.input }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '2',
name: 'Agent 2',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [100, 0],
parameters: {
text: "=Process: {{$('Chat Trigger'.params.chatInput)}}",
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '3',
name: 'Agent 3',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [200, 0],
parameters: {
text: '={{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should not check agent nodes with promptType set to auto', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'auto',
text: 'This would normally trigger a violation',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should not check agent nodes with promptType set to guardrails', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'guardrails',
text: 'This would normally trigger a violation',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should not check agent nodes with promptType set to guardrails', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'guardrails',
text: 'This would normally trigger a violation',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(0);
});
it('should check agent nodes with promptType set to define', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
promptType: 'define',
text: 'Static text without expressions',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].pointsDeducted).toBe(20);
});
it('should handle missing parameters gracefully', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have violations for: no expression + no systemMessage
expect(result.violations.length).toBeGreaterThanOrEqual(1);
expect(result.violations.some((v) => v.type === 'major')).toBe(true);
});
it('should detect multiple agents with issues', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent 1',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [0, 0],
parameters: {
text: 'No expression here',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '2',
name: 'Agent 2',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [100, 0],
parameters: {
text: 'Also no expression',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
id: '3',
name: 'Agent 3',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 2,
position: [200, 0],
parameters: {
text: '=Has expression: {{ $json.input }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(2);
expect(result.violations[0].description).toContain('Agent 1');
expect(result.violations[1].description).toContain('Agent 2');
});
describe('System Message Separation', () => {
it('should flag agent with no systemMessage as major violation', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Orchestrator Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=You are an orchestrator agent that coordinates specialized agents. Your task is to: 1) Call Research Agent 2) Call Fact-Check Agent. The research topic is: {{ $json.researchTopic }}',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have major violation for missing systemMessage
expect(result.violations.length).toBeGreaterThan(0);
const systemMessageViolation = result.violations.find((v) =>
v.description.includes('no system message'),
);
expect(systemMessageViolation).toBeDefined();
expect(systemMessageViolation?.type).toBe('major');
expect(systemMessageViolation?.pointsDeducted).toBe(25);
});
it('should not flag agent when it has proper systemMessage', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=You are an agent. Your task is to process: {{ $json.data }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should not have any system message violations
const systemMessageViolations = result.violations.filter((v) =>
v.description.includes('system message'),
);
expect(systemMessageViolations).toHaveLength(0);
});
it('should pass for properly separated agent configuration', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Orchestrator Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=The research topic is: {{ $json.researchTopic }}',
options: {
systemMessage:
'You are an orchestrator agent that coordinates specialized agents.\n\nYour task is to:\n1. Call the Research Agent Tool\n2. Call the Fact-Check Agent Tool\n3. Generate a report',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have no violations
expect(result.violations).toHaveLength(0);
});
it('should handle agents with expressions in text and proper systemMessage', () => {
const testCases = [
{ text: "=You're analyzing: {{ $json.input }}" }, // Contains "you're" but has systemMessage
{ text: '=Process this data: {{ $json.data }}' },
{ text: '=User question: {{ $json.chatInput }}' },
{ text: '=Analyze for topic: {{ $json.researchTopic }}' },
];
testCases.forEach(({ text }, index) => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: `test-${index}`,
name: `Test Agent ${index}`,
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text,
options: {
systemMessage: 'You are a helpful assistant.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have no violations since systemMessage is present
expect(result.violations).toHaveLength(0);
});
});
it('should flag major violation when agent has no systemMessage', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=Process: {{ $json.input }}',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
const noSystemMessageViolation = result.violations.find((v) =>
v.description.includes('no system message'),
);
expect(noSystemMessageViolation).toBeDefined();
expect(noSystemMessageViolation?.type).toBe('major');
expect(noSystemMessageViolation?.pointsDeducted).toBe(25);
});
});
});
@@ -0,0 +1,11 @@
import type { SimpleWorkflow } from '@/types';
import { validateAgentPrompt } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateAgentPrompt(workflow: SimpleWorkflow): SingleEvaluatorResult {
const violations = validateAgentPrompt(workflow);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateConnections } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateConnections(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateConnections(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,242 @@
import { mock } from 'jest-mock-extended';
import type { INodeParameters } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateCredentials } from '@/validation/checks/credentials';
// Helper types
interface HeaderParam {
name: string;
value: string;
}
interface QueryParam {
name: string;
value: string;
}
interface Assignment {
name: string;
value: string;
type?: string;
}
interface HttpRequestNodeOptions {
name?: string;
id?: string;
headers?: HeaderParam[];
queryParams?: QueryParam[];
extraParams?: Record<string, unknown>;
}
interface SetNodeOptions {
name?: string;
id?: string;
assignments?: Assignment[];
}
// Helper functions
function createHttpRequestNode(options: HttpRequestNodeOptions = {}): SimpleWorkflow['nodes'][0] {
const { name = 'HTTP Request', id = '1', headers, queryParams, extraParams = {} } = options;
const parameters: Record<string, unknown> = {
method: 'GET',
url: 'https://api.example.com/data',
...extraParams,
};
if (headers) {
parameters.sendHeaders = true;
parameters.headerParameters = { parameters: headers };
}
if (queryParams) {
parameters.sendQuery = true;
parameters.queryParameters = { parameters: queryParams };
}
return {
id,
name,
type: 'n8n-nodes-base.httpRequest',
parameters: parameters as INodeParameters,
typeVersion: 4,
position: [0, 0],
};
}
function createSetNode(options: SetNodeOptions = {}): SimpleWorkflow['nodes'][0] {
const { name = 'Set', id = '1', assignments = [] } = options;
const parameters: Record<string, unknown> =
assignments.length > 0
? {
assignments: {
assignments: assignments.map((a) => ({ ...a, type: a.type ?? 'string' })),
},
}
: {};
return {
id,
name,
type: 'n8n-nodes-base.set',
parameters: parameters as INodeParameters,
typeVersion: 3,
position: [0, 0],
};
}
function createWorkflow(nodes: SimpleWorkflow['nodes']): SimpleWorkflow {
return mock<SimpleWorkflow>({
name: 'Test Workflow',
nodes,
connections: {},
});
}
describe('validateCredentials', () => {
describe('HTTP Request node validation', () => {
it('should flag hardcoded Authorization header', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [{ name: 'Authorization', value: 'Bearer sk_test_1234567890abcdef' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'http-request-hardcoded-credentials',
type: 'minor',
}),
);
expect(violations[0].description).toContain('Authorization');
});
it('should flag hardcoded X-API-Key header', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [{ name: 'X-API-Key', value: 'my-secret-api-key-12345' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'http-request-hardcoded-credentials',
type: 'minor',
}),
);
expect(violations[0].description).toContain('X-API-Key');
});
it('should allow Authorization header with expression', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [{ name: 'Authorization', value: '={{ $json.token }}' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
it('should allow non-sensitive headers with hardcoded values', () => {
const workflow = createWorkflow([
createHttpRequestNode({
headers: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Accept', value: 'application/json' },
],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
it('should flag credential-like query parameters with hardcoded values', () => {
const workflow = createWorkflow([
createHttpRequestNode({
queryParams: [{ name: 'api_key', value: 'my-secret-key-12345' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'http-request-hardcoded-credentials',
type: 'minor',
}),
);
expect(violations[0].description).toContain('api_key');
});
it('should allow query parameters with expressions', () => {
const workflow = createWorkflow([
createHttpRequestNode({
queryParams: [{ name: 'api_key', value: '={{ $json.apiKey }}' }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
it('should handle HTTP Request node without parameters', () => {
const workflow = createWorkflow([createHttpRequestNode()]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
});
describe('Set node validation', () => {
it.each([
['api_key', 'sk_test_12345'],
['access_token', 'ya29.a0AfB_byC...'],
['password', 'my-secret-password'],
['secret', 'top-secret-value'],
])('should flag field named "%s"', (fieldName, fieldValue) => {
const workflow = createWorkflow([
createSetNode({
assignments: [{ name: fieldName, value: fieldValue }],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'set-node-credential-field',
type: 'minor',
}),
);
expect(violations[0].description).toContain(fieldName);
});
it('should allow normal field names', () => {
const workflow = createWorkflow([
createSetNode({
assignments: [
{ name: 'user_id', value: '12345' },
{ name: 'status', value: 'active' },
{ name: 'email', value: 'user@example.com' },
],
}),
]);
const violations = validateCredentials(workflow);
expect(violations).toEqual([]);
});
});
});
@@ -0,0 +1,10 @@
import type { SimpleWorkflow } from '@/types';
import { validateCredentials } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateCredentials(workflow: SimpleWorkflow): SingleEvaluatorResult {
const violations = validateCredentials(workflow);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateFromAi } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateFromAi(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateFromAi(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,114 @@
/**
* Graph validation evaluator - validates the internal graph structure
* of generated TypeScript SDK code using workflow-sdk's validate() method.
*/
import { parseWorkflowCodeToBuilder } from '@n8n/workflow-sdk';
import { stripImportStatements } from '@/code-builder/utils/extract-code';
import type {
ProgrammaticViolation,
ProgrammaticViolationName,
SingleEvaluatorResult,
} from '@/validation/types';
/**
* Convert SDK validation code (SCREAMING_SNAKE_CASE) to violation name (graph-kebab-case).
* E.g., "NO_NODES" -> "graph-no-nodes", "DISCONNECTED_NODE" -> "graph-disconnected-node"
*/
function codeToViolationName(code: string): ProgrammaticViolationName {
const kebab = code.toLowerCase().replace(/_/g, '-');
return `graph-${kebab}` as ProgrammaticViolationName;
}
/**
* Get violation type from a validation issue.
* Uses the violationLevel from the issue if present, otherwise defaults to 'minor'.
*/
function getViolationType(issue: {
violationLevel?: 'critical' | 'major' | 'minor';
}): 'critical' | 'major' | 'minor' {
return issue.violationLevel ?? 'minor';
}
/**
* Get points deducted for a violation based on its type
*/
function getPointsDeducted(type: 'critical' | 'major' | 'minor'): number {
switch (type) {
case 'critical':
return 25;
case 'major':
return 15;
case 'minor':
return 5;
}
}
/**
* Evaluate generated TypeScript SDK code using graph validation.
* Returns violations for any graph structure issues found.
*
* @param generatedCode - The TypeScript SDK code to validate
* @returns Evaluation result with score and violations
*/
export function evaluateGraphValidation(generatedCode: string | undefined): SingleEvaluatorResult {
// If no code provided, skip evaluation
if (!generatedCode) {
return {
score: 1, // Neutral - not applicable
violations: [],
};
}
const violations: ProgrammaticViolation[] = [];
try {
// Strip import statements before parsing (Acorn uses sourceType: 'script')
const cleanCode = stripImportStatements(generatedCode);
// Parse code to WorkflowBuilder
const builder = parseWorkflowCodeToBuilder(cleanCode);
// Run graph validation
const validation = builder.validate();
// Convert errors to violations
for (const error of validation.errors) {
const violationType = getViolationType(error);
violations.push({
name: codeToViolationName(error.code),
type: violationType,
description: error.message,
pointsDeducted: getPointsDeducted(violationType),
});
}
// Convert warnings to violations
for (const warning of validation.warnings) {
const violationType = getViolationType(warning);
violations.push({
name: codeToViolationName(warning.code),
type: violationType,
description: warning.message,
pointsDeducted: getPointsDeducted(violationType),
});
}
} catch (error) {
// Code parsing failed - this is a critical violation
violations.push({
name: 'graph-parse-error',
type: 'critical',
description: `Failed to parse code for graph validation: ${error instanceof Error ? error.message : String(error)}`,
pointsDeducted: 25,
});
}
// Calculate score (start at 100, deduct points)
const totalDeducted = violations.reduce((sum, v) => sum + v.pointsDeducted, 0);
const score = Math.max(0, 100 - totalDeducted) / 100;
return {
score,
violations,
};
}
@@ -0,0 +1,11 @@
export * from './agent-prompt';
export * from './connections';
export * from './credentials';
export * from './from-ai';
export * from './graph-validation';
export * from './nodes';
export * from './parameters';
export * from './tools';
export * from './trigger';
export * from './node-usage';
export * from './workflow-similarity';
@@ -0,0 +1,10 @@
import type { SimpleWorkflow } from '@/types';
import { validateWebhookResponse } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateNodeUsage(workflow: SimpleWorkflow): SingleEvaluatorResult {
const violations = validateWebhookResponse(workflow);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,290 @@
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { evaluateNodes } from './nodes';
describe('evaluateNodes', () => {
const mockNodeTypes: INodeTypeDescription[] = [
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.manualTrigger',
displayName: 'Manual Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.executeWorkflowTrigger',
displayName: 'Execute Workflow Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
maxNodes: 1,
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.code',
displayName: 'Code',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
}),
];
describe('basic validation', () => {
it('should detect workflow with no nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Empty Workflow',
nodes: [],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toContainEqual(
expect.objectContaining({ description: 'Workflow has no nodes' }),
);
});
it('should accept workflow with nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Valid Workflow',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
});
describe('maxNodes validation', () => {
it('should accept workflow with nodes within maxNodes limit', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Single Execute Workflow Trigger',
nodes: [
{
id: '1',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should detect workflow exceeding maxNodes limit', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Execute Workflow Triggers',
nodes: [
{
id: '1',
name: 'Execute Workflow Trigger 1',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Execute Workflow Trigger 2',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 200],
},
{
id: '3',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toContainEqual(
expect.objectContaining({
name: 'workflow-exceeds-max-nodes-limit',
type: 'critical',
}),
);
});
it('should include count and limit in violation description', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Execute Workflow Triggers',
nodes: [
{
id: '1',
name: 'First Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Second Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 200],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
const violation = result.violations.find(
(v) => v.name === 'workflow-exceeds-max-nodes-limit',
);
expect(violation?.description).toContain('1'); // maxNodes limit
expect(violation?.description).toContain('2'); // actual count
expect(violation?.description).toContain('Execute Workflow Trigger');
});
it('should allow multiple nodes when maxNodes is not set', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Code Nodes',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Code 1',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: '3',
name: 'Code 2',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
{
id: '4',
name: 'Code 3',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should validate maxNodes for different node types independently', () => {
const nodeTypesWithMaxNodes: INodeTypeDescription[] = [
...mockNodeTypes,
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.limitedNode',
displayName: 'Limited Node',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
maxNodes: 2,
}),
];
const workflow = mock<SimpleWorkflow>({
name: 'Multiple Limited Nodes',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Limited 1',
type: 'n8n-nodes-base.limitedNode',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: '3',
name: 'Limited 2',
type: 'n8n-nodes-base.limitedNode',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
],
connections: {},
});
const result = evaluateNodes(workflow, nodeTypesWithMaxNodes);
expect(result.violations).toEqual([]);
// Now add a third limited node (exceeding maxNodes: 2)
const workflowExceeding = mock<SimpleWorkflow>({
name: 'Too Many Limited Nodes',
nodes: [
...workflow.nodes,
{
id: '4',
name: 'Limited 3',
type: 'n8n-nodes-base.limitedNode',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {},
});
const resultExceeding = evaluateNodes(workflowExceeding, nodeTypesWithMaxNodes);
expect(resultExceeding.violations).toContainEqual(
expect.objectContaining({
name: 'workflow-exceeds-max-nodes-limit',
description: expect.stringContaining('2'),
}),
);
});
});
});
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateNodes } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateNodes(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateNodes(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,690 @@
import type { INodeParameters, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateParameters } from '@/validation/checks/parameters';
function createNodeType(
type: string,
properties: INodeTypeDescription['properties'] = [],
version: number | number[] = 1,
): INodeTypeDescription {
return {
name: type,
displayName: type.split('.').pop() ?? type,
group: ['transform'],
version,
description: 'Test node',
defaults: { name: type.split('.').pop() ?? type },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties,
} as INodeTypeDescription;
}
function createNode(
type: string,
parameters: Record<string, unknown> = {},
options: { name?: string; id?: string; typeVersion?: number } = {},
): SimpleWorkflow['nodes'][0] {
const { name = 'Test Node', id = '1', typeVersion = 1 } = options;
return {
id,
name,
type,
parameters: parameters as INodeParameters,
typeVersion,
position: [0, 0],
};
}
function createWorkflow(nodes: SimpleWorkflow['nodes']): SimpleWorkflow {
return { name: 'Test Workflow', nodes, connections: {} };
}
describe('validateParameters', () => {
describe('node-missing-required-parameter', () => {
it.each([
['empty string default', ''],
['undefined default', undefined],
])('should flag missing required parameter with %s', (_, defaultValue) => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
default: defaultValue as string,
required: true,
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', {})]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
type: 'critical',
pointsDeducted: 50,
metadata: expect.objectContaining({
nodeName: 'Test Node',
nodeType: 'n8n-nodes-base.test',
parameterName: 'apiKey',
}),
}),
);
});
it('should NOT flag required parameter with meaningful default or when value is provided', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Method',
name: 'method',
type: 'options',
default: 'GET',
required: true,
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
default: '',
required: true,
},
]);
const workflow = createWorkflow([
createNode('n8n-nodes-base.test', { apiKey: 'my-key' }), // method uses default, apiKey provided
]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toHaveLength(0);
});
it('should respect displayOptions for resource/operation', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
default: 'user',
options: [
{ name: 'User', value: 'user' },
{ name: 'Post', value: 'post' },
],
},
{
displayName: 'Post ID',
name: 'postId',
type: 'string',
default: '',
required: true,
displayOptions: { show: { resource: ['post'] } },
},
]);
// Resource is 'user', so postId should not be required
const workflowUser = createWorkflow([
createNode('n8n-nodes-base.test', { resource: 'user' }),
]);
expect(validateParameters(workflowUser, [nodeType])).toHaveLength(0);
// Resource is 'post', so postId IS required
const workflowPost = createWorkflow([
createNode('n8n-nodes-base.test', { resource: 'post' }),
]);
expect(validateParameters(workflowPost, [nodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({ parameterName: 'postId' }),
}),
);
});
it.each(['collection', 'fixedCollection', 'credentialsSelect'] as const)(
'should skip %s type parameters',
(type) => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Options',
name: 'options',
type,
default: {},
required: true,
options: [],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', {})]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
},
);
});
describe('node-invalid-options-value', () => {
it('should flag invalid options value with metadata', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Method',
name: 'method',
type: 'options',
default: 'GET',
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', { method: 'INVALID' })]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'node-invalid-options-value',
type: 'critical',
pointsDeducted: 50,
metadata: expect.objectContaining({
parameterName: 'method',
invalidValue: 'INVALID',
}),
}),
);
});
it('should NOT flag valid options value', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Method',
name: 'method',
type: 'options',
default: 'GET',
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', { method: 'POST' })]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
});
it.each([
['dynamic loadOptionsMethod', { channel: 'any-value' }, { loadOptionsMethod: 'getChannels' }],
['expression values', { method: '={{ $json.method }}' }, undefined],
['undefined values', {}, undefined],
])('should skip validation for %s', (_, params, typeOptions) => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Field',
name: Object.keys(params)[0] ?? 'method',
type: 'options',
default: 'GET',
typeOptions,
options: [
{ name: 'GET', value: 'GET' },
{ name: 'POST', value: 'POST' },
],
},
]);
const workflow = createWorkflow([createNode('n8n-nodes-base.test', params)]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
});
it('should handle non-string option values (numeric, boolean)', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'Priority',
name: 'priority',
type: 'options',
default: 1,
options: [
{ name: 'Low', value: 1 },
{ name: 'High', value: 3 },
],
},
{
displayName: 'Enabled',
name: 'enabled',
type: 'options',
default: true,
options: [
{ name: 'Yes', value: true },
{ name: 'No', value: false },
],
},
]);
// Invalid numeric value
const workflowNumeric = createWorkflow([
createNode('n8n-nodes-base.test', { priority: 5 }, { id: '1', name: 'Node 1' }),
]);
expect(validateParameters(workflowNumeric, [nodeType])).toContainEqual(
expect.objectContaining({ name: 'node-invalid-options-value' }),
);
// Invalid boolean value (string instead of boolean)
const workflowBoolean = createWorkflow([
createNode('n8n-nodes-base.test', { enabled: 'yes' }, { id: '2', name: 'Node 2' }),
]);
expect(validateParameters(workflowBoolean, [nodeType])).toContainEqual(
expect.objectContaining({ name: 'node-invalid-options-value' }),
);
// Valid boolean value should pass
const workflowValidBoolean = createWorkflow([
createNode('n8n-nodes-base.test', { enabled: false }, { id: '3', name: 'Node 3' }),
]);
expect(validateParameters(workflowValidBoolean, [nodeType])).toHaveLength(0);
});
});
describe('edge cases', () => {
it('should validate multiple nodes independently', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{ displayName: 'Field', name: 'field', type: 'string', default: '', required: true },
]);
const workflow = createWorkflow([
createNode('n8n-nodes-base.test', { field: 'valid' }, { id: '1', name: 'Node 1' }),
createNode('n8n-nodes-base.test', {}, { id: '2', name: 'Node 2' }),
createNode('n8n-nodes-base.test', { field: 'valid' }, { id: '3', name: 'Node 3' }),
]);
const violations = validateParameters(workflow, [nodeType]);
expect(violations).toHaveLength(1);
expect(violations[0].metadata?.nodeName).toBe('Node 2');
});
it('should respect displayOptions for boolean parameters (e.g., sshTunnel)', () => {
const nodeType = createNodeType('n8n-nodes-base.test', [
{
displayName: 'SSH Tunnel',
name: 'sshTunnel',
type: 'boolean',
default: false,
},
{
displayName: 'SSH Host',
name: 'sshHost',
type: 'string',
default: '',
required: true,
displayOptions: { show: { sshTunnel: [true] } },
},
{
displayName: 'Regular Field',
name: 'regularField',
type: 'string',
default: '',
required: true,
displayOptions: { hide: { sshTunnel: [true] } },
},
]);
// sshTunnel is false, so sshHost should be hidden (not required)
// but regularField is shown (required)
const workflowTunnelOff = createWorkflow([
createNode('n8n-nodes-base.test', { sshTunnel: false, regularField: 'value' }),
]);
expect(validateParameters(workflowTunnelOff, [nodeType])).toHaveLength(0);
// sshTunnel is true, so sshHost is shown (required) and regularField is hidden
// Missing sshHost should trigger violation
const workflowTunnelOn = createWorkflow([
createNode('n8n-nodes-base.test', { sshTunnel: true }),
]);
const violations = validateParameters(workflowTunnelOn, [nodeType]);
expect(violations).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({ parameterName: 'sshHost' }),
}),
);
// regularField should NOT be flagged since it's hidden when sshTunnel is true
expect(violations).not.toContainEqual(
expect.objectContaining({
metadata: expect.objectContaining({ parameterName: 'regularField' }),
}),
);
// sshTunnel is true and sshHost is provided - should pass
const workflowTunnelOnValid = createWorkflow([
createNode('n8n-nodes-base.test', { sshTunnel: true, sshHost: 'localhost' }),
]);
expect(validateParameters(workflowTunnelOnValid, [nodeType])).toHaveLength(0);
});
it('should handle node with different version via @version displayOptions', () => {
const nodeType = createNodeType(
'n8n-nodes-base.test',
[
{
displayName: 'V1 Field',
name: 'v1Field',
type: 'string',
default: '',
required: true,
displayOptions: { show: { '@version': [1] } },
},
],
[1, 2],
);
// Node is version 2, so v1Field should be hidden
const workflow = createWorkflow([createNode('n8n-nodes-base.test', {}, { typeVersion: 2 })]);
expect(validateParameters(workflow, [nodeType])).toHaveLength(0);
});
it('should respect displayOptions for mode parameter (e.g., Vector Store nodes)', () => {
const nodeType = createNodeType('n8n-nodes-langchain.vectorStore', [
{
displayName: 'Mode',
name: 'mode',
type: 'options',
default: 'retrieve',
options: [
{ name: 'Retrieve', value: 'retrieve' },
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve as Tool', value: 'retrieve-as-tool' },
],
},
{
displayName: 'Description',
name: 'toolDescription',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
]);
// Mode is 'retrieve', so toolDescription should NOT be required
const workflowRetrieve = createWorkflow([
createNode('n8n-nodes-langchain.vectorStore', { mode: 'retrieve' }),
]);
expect(validateParameters(workflowRetrieve, [nodeType])).toHaveLength(0);
// Mode is 'retrieve-as-tool', so toolDescription IS required
const workflowTool = createWorkflow([
createNode('n8n-nodes-langchain.vectorStore', { mode: 'retrieve-as-tool' }),
]);
expect(validateParameters(workflowTool, [nodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({ parameterName: 'toolDescription' }),
}),
);
});
it('should respect displayOptions for authentication parameter', () => {
const nodeType = createNodeType('n8n-nodes-base.discord', [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
default: 'botToken',
options: [
{ name: 'Bot Token', value: 'botToken' },
{ name: 'Webhook', value: 'webhook' },
],
},
// Bot token operation (includes send, getAll)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'send',
displayOptions: { show: { authentication: ['botToken'] } },
options: [
{ name: 'Send', value: 'send' },
{ name: 'Get All', value: 'getAll' },
],
},
// Webhook operation (only sendLegacy)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'sendLegacy',
displayOptions: { show: { authentication: ['webhook'] } },
options: [{ name: 'Send', value: 'sendLegacy' }],
},
]);
// Using botToken auth with 'send' operation - should be valid
const workflowBotSend = createWorkflow([
createNode('n8n-nodes-base.discord', { authentication: 'botToken', operation: 'send' }),
]);
expect(validateParameters(workflowBotSend, [nodeType])).toHaveLength(0);
// Using botToken auth with 'getAll' operation - should be valid
const workflowBotGetAll = createWorkflow([
createNode('n8n-nodes-base.discord', { authentication: 'botToken', operation: 'getAll' }),
]);
expect(validateParameters(workflowBotGetAll, [nodeType])).toHaveLength(0);
// Using webhook auth with 'sendLegacy' operation - should be valid
const workflowWebhook = createWorkflow([
createNode('n8n-nodes-base.discord', {
authentication: 'webhook',
operation: 'sendLegacy',
}),
]);
expect(validateParameters(workflowWebhook, [nodeType])).toHaveLength(0);
// Using webhook auth with 'send' operation - should be invalid
// (send is only valid for botToken, not webhook)
const workflowWebhookInvalid = createWorkflow([
createNode('n8n-nodes-base.discord', { authentication: 'webhook', operation: 'send' }),
]);
expect(validateParameters(workflowWebhookInvalid, [nodeType])).toContainEqual(
expect.objectContaining({
name: 'node-invalid-options-value',
metadata: expect.objectContaining({ parameterName: 'operation', invalidValue: 'send' }),
}),
);
});
});
describe('real-world workflow scenarios', () => {
it('should validate Discord nodes from Eco chatbot workflow (botToken + send/getAll)', () => {
// Simulates the Discord node structure with multiple operation properties
// for different authentication methods
const discordNodeType = createNodeType(
'n8n-nodes-base.discord',
[
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
default: 'botToken',
options: [
{ name: 'Bot Token', value: 'botToken' },
{ name: 'OAuth2', value: 'oAuth2' },
{ name: 'Webhook', value: 'webhook' },
],
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
default: 'message',
displayOptions: { show: { authentication: ['botToken', 'oAuth2'] } },
options: [
{ name: 'Message', value: 'message' },
{ name: 'Channel', value: 'channel' },
],
},
// Message operations for botToken/oAuth2
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'send',
displayOptions: {
show: { resource: ['message'], authentication: ['botToken', 'oAuth2'] },
},
options: [
{ name: 'Send', value: 'send' },
{ name: 'Get All', value: 'getAll' },
{ name: 'Delete', value: 'deleteMessage' },
],
},
// Webhook operation (different options)
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'sendLegacy',
displayOptions: { show: { authentication: ['webhook'] } },
options: [{ name: 'Send', value: 'sendLegacy' }],
},
],
[1, 2], // Include version 2 to match node's typeVersion
);
const getMessagesWorkflow = createWorkflow([
createNode(
'n8n-nodes-base.discord',
{ authentication: 'botToken', resource: 'message', operation: 'getAll' },
{ name: 'Get Discord Messages', typeVersion: 2 },
),
]);
expect(validateParameters(getMessagesWorkflow, [discordNodeType])).toHaveLength(0);
// "Send Discord Response" node from workflow
const sendResponseWorkflow = createWorkflow([
createNode(
'n8n-nodes-base.discord',
{ authentication: 'botToken', resource: 'message', operation: 'send' },
{ name: 'Send Discord Response', typeVersion: 2 },
),
]);
expect(validateParameters(sendResponseWorkflow, [discordNodeType])).toHaveLength(0);
});
it('should validate Vector Store nodes from Eco chatbot workflow', () => {
// Simulates the Vector Store In Memory node structure
const vectorStoreNodeType = createNodeType(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
[
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
default: 'retrieve',
options: [
{ name: 'Get Many', value: 'load' },
{ name: 'Insert Documents', value: 'insert' },
{ name: 'Retrieve Documents (As Vector Store)', value: 'retrieve' },
{ name: 'Retrieve Documents (As Tool)', value: 'retrieve-as-tool' },
{ name: 'Update Documents', value: 'update' },
],
},
{
displayName: 'Description',
name: 'toolDescription',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['load'] } },
},
{
displayName: 'ID',
name: 'id',
type: 'string',
default: '',
required: true,
displayOptions: { show: { mode: ['update'] } },
},
],
[1, 1.1, 1.2, 1.3], // Include version 1.3 to match node's typeVersion
);
// "Vector Store - Load Rules" node (mode: insert) - no toolDescription required
const loadRulesWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'insert', clearStore: false },
{ name: 'Vector Store - Load Rules', typeVersion: 1.3 },
),
]);
expect(validateParameters(loadRulesWorkflow, [vectorStoreNodeType])).toHaveLength(0);
// "Vector Store - Query Tool" node (mode: retrieve-as-tool) with toolDescription provided
const queryToolWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{
mode: 'retrieve-as-tool',
toolDescription:
'Search the Eco community ruleset documents to find relevant rules and regulations.',
topK: 5,
},
{ name: 'Vector Store - Query Tool', typeVersion: 1.3 },
),
]);
expect(validateParameters(queryToolWorkflow, [vectorStoreNodeType])).toHaveLength(0);
// Vector Store in 'retrieve-as-tool' mode WITHOUT toolDescription - should fail
const missingDescriptionWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'retrieve-as-tool', topK: 5 },
{ name: 'Vector Store Missing Description', typeVersion: 1.3 },
),
]);
expect(validateParameters(missingDescriptionWorkflow, [vectorStoreNodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({
nodeName: 'Vector Store Missing Description',
parameterName: 'toolDescription',
}),
}),
);
// Vector Store in 'load' mode WITHOUT prompt - should fail
const missingPromptWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'load' },
{ name: 'Vector Store Missing Prompt', typeVersion: 1.3 },
),
]);
expect(validateParameters(missingPromptWorkflow, [vectorStoreNodeType])).toContainEqual(
expect.objectContaining({
name: 'node-missing-required-parameter',
metadata: expect.objectContaining({
nodeName: 'Vector Store Missing Prompt',
parameterName: 'prompt',
}),
}),
);
// Vector Store in 'retrieve' mode - no extra required params
const retrieveWorkflow = createWorkflow([
createNode(
'@n8n/n8n-nodes-langchain.vectorStoreInMemory',
{ mode: 'retrieve' },
{ name: 'Vector Store Retrieve', typeVersion: 1.3 },
),
]);
expect(validateParameters(retrieveWorkflow, [vectorStoreNodeType])).toHaveLength(0);
});
});
});
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateParameters } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateParameters(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateParameters(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,16 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateTools } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateTools(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateTools(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,219 @@
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { evaluateTrigger } from './trigger';
describe('evaluateTrigger', () => {
const mockNodeTypes: INodeTypeDescription[] = [
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.manualTrigger',
displayName: 'Manual Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.webhookTrigger',
displayName: 'Webhook Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.scheduleTrigger',
displayName: 'Schedule Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.executeWorkflowTrigger',
displayName: 'Execute Workflow Trigger',
group: ['trigger'],
inputs: [],
outputs: ['main'],
maxNodes: 1,
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.code',
displayName: 'Code',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.httpRequest',
displayName: 'HTTP Request',
group: ['transform'],
inputs: ['main'],
outputs: ['main'],
}),
mock<INodeTypeDescription>({
name: 'n8n-nodes-base.set',
displayName: 'Set',
group: ['input'],
inputs: ['main'],
outputs: ['main'],
}),
];
describe('basic trigger validation', () => {
it('should return no violations for workflow with no nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Empty Workflow',
nodes: [],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should detect workflow with no trigger nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'No Trigger Workflow',
nodes: [
{
id: '1',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toContainEqual(
expect.objectContaining({
description: 'Workflow must have at least one trigger node to start execution',
}),
);
});
it('should accept workflow with one trigger node', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Valid Workflow',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
});
describe('edge cases', () => {
it('should handle unknown node types gracefully', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Unknown Node Workflow',
nodes: [
{
id: '1',
name: 'Unknown Trigger',
type: 'n8n-nodes-base.unknownTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
it('should handle mixed trigger and non-trigger nodes', () => {
const workflow = mock<SimpleWorkflow>({
name: 'Mixed Workflow',
nodes: [
{
id: '1',
name: 'Set Data',
type: 'n8n-nodes-base.set',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Webhook',
type: 'n8n-nodes-base.webhookTrigger',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: '3',
name: 'Process',
type: 'n8n-nodes-base.code',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
{
id: '4',
name: 'Manual',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
typeVersion: 1,
position: [0, 200],
},
{
id: '5',
name: 'HTTP Call',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [600, 0],
},
],
connections: {},
});
const result = evaluateTrigger(workflow, mockNodeTypes);
expect(result.violations).toEqual([]);
});
});
});
@@ -0,0 +1,15 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import { validateTrigger } from '@/validation/checks';
import type { SingleEvaluatorResult } from '@/validation/types';
import { calcSingleEvaluatorScore } from '../score';
export function evaluateTrigger(
workflow: SimpleWorkflow,
nodeTypes: INodeTypeDescription[],
): SingleEvaluatorResult {
const violations = validateTrigger(workflow, nodeTypes);
return { violations, score: calcSingleEvaluatorScore({ violations }) };
}
@@ -0,0 +1,312 @@
import { mock } from 'jest-mock-extended';
import type { SimpleWorkflow } from '@/types';
import {
evaluateWorkflowSimilarity,
evaluateWorkflowSimilarityMultiple,
} from './workflow-similarity';
// Mock node modules before any imports
jest.mock('node:child_process', () => ({
execFile: jest.fn(),
}));
// Create the mock inside the factory - must use var for proper hoisting with jest.mock
// eslint-disable-next-line no-var
var mockExecFileAsync: jest.Mock;
jest.mock('node:util', () => {
const mockFn = jest.fn();
// Store reference so tests can access it
mockExecFileAsync = mockFn;
return {
promisify: jest.fn(() => mockFn),
};
});
jest.mock('node:fs/promises');
describe('evaluateWorkflowSimilarity', () => {
const generatedWorkflow = mock<SimpleWorkflow>({
name: 'Generated',
nodes: [
{
id: '1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
const groundTruthWorkflow = mock<SimpleWorkflow>({
name: 'Ground Truth',
nodes: [
{
id: '1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [200, 0],
parameters: {},
},
],
connections: {},
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('successful evaluation', () => {
it('should parse Python output and map violations correctly', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.75,
edit_cost: 25,
max_possible_cost: 100,
top_edits: [
{
type: 'node_insert',
description: 'Missing node: Code',
cost: 15,
priority: 'major',
node_name: 'Code',
},
{
type: 'edge_delete',
description: 'Extra connection from Trigger to Code',
cost: 10,
priority: 'minor',
},
],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.score).toBe(0.75);
expect(result.violations).toHaveLength(2);
expect(result.violations[0]).toEqual({
name: 'workflow-similarity-node-insert',
type: 'major',
description: 'Missing node: Code',
pointsDeducted: 15,
});
expect(result.violations[1]).toEqual({
name: 'workflow-similarity-edge-delete',
type: 'minor',
description: 'Extra connection from Trigger to Code',
pointsDeducted: 10,
});
});
it('should handle all edit types correctly', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.5,
edit_cost: 50,
max_possible_cost: 100,
top_edits: [
{ type: 'node_insert', description: 'Insert', cost: 10, priority: 'major' },
{ type: 'node_delete', description: 'Delete', cost: 10, priority: 'major' },
{ type: 'node_substitute', description: 'Substitute', cost: 10, priority: 'major' },
{ type: 'edge_insert', description: 'Edge insert', cost: 5, priority: 'minor' },
{ type: 'edge_delete', description: 'Edge delete', cost: 5, priority: 'minor' },
{ type: 'edge_substitute', description: 'Edge substitute', cost: 10, priority: 'major' },
],
metadata: {
generated_nodes: 2,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.violations).toHaveLength(6);
expect(result.violations[0].name).toBe('workflow-similarity-node-insert');
expect(result.violations[1].name).toBe('workflow-similarity-node-delete');
expect(result.violations[2].name).toBe('workflow-similarity-node-substitute');
expect(result.violations[3].name).toBe('workflow-similarity-edge-insert');
expect(result.violations[4].name).toBe('workflow-similarity-edge-delete');
expect(result.violations[5].name).toBe('workflow-similarity-edge-substitute');
});
it('should round cost values to integers', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.85,
edit_cost: 15.7,
max_possible_cost: 100,
top_edits: [
{
type: 'node_insert',
description: 'Missing node',
cost: 15.7,
priority: 'major',
},
],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.violations[0].pointsDeducted).toBe(16);
});
it('should pass custom preset to Python script', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.9,
edit_cost: 10,
max_possible_cost: 100,
top_edits: [],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 1,
config_name: 'lenient',
},
});
mockExecFileAsync.mockResolvedValue({ stdout: mockPythonOutput, stderr: '' });
await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow, 'lenient');
// Verify the preset was passed to the Python script
expect(mockExecFileAsync).toHaveBeenCalledWith(
'uvx',
expect.arrayContaining(['--preset', 'lenient']),
expect.any(Object),
);
});
});
describe('error handling', () => {
it('should handle uvx command not found error', async () => {
const error = Object.assign(new Error('Command not found'), { code: 'ENOENT' });
mockExecFileAsync.mockRejectedValue(error);
await expect(
evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow),
).rejects.toThrow('uvx command not found');
});
it('should handle timeout error', async () => {
const error = Object.assign(new Error('Timeout'), { killed: true });
mockExecFileAsync.mockRejectedValue(error);
await expect(
evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow),
).rejects.toThrow('Workflow comparison timed out');
});
it('should handle Python script errors with empty output', async () => {
const error = Object.assign(new Error('Python error'), {
stdout: '',
stderr: 'Something went wrong',
code: 1,
});
mockExecFileAsync.mockRejectedValue(error);
await expect(
evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow),
).rejects.toThrow('Workflow similarity evaluation failed');
});
it('should accept non-zero exit code if Python outputs valid JSON', async () => {
const mockPythonOutput = JSON.stringify({
similarity_score: 0.3,
edit_cost: 70,
max_possible_cost: 100,
top_edits: [
{
type: 'node_delete',
description: 'Major difference',
cost: 70,
priority: 'critical',
},
],
metadata: {
generated_nodes: 1,
ground_truth_nodes: 2,
config_name: 'standard',
},
});
const error = Object.assign(new Error('Non-zero exit'), {
stdout: mockPythonOutput,
stderr: 'Warning: similarity below threshold',
code: 1,
});
mockExecFileAsync.mockRejectedValue(error);
const result = await evaluateWorkflowSimilarity(generatedWorkflow, groundTruthWorkflow);
expect(result.score).toBe(0.3);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].name).toBe('workflow-similarity-node-delete');
});
});
describe('evaluateWorkflowSimilarityMultiple', () => {
it('should return result with highest similarity score', async () => {
const referenceWorkflows = [
mock<SimpleWorkflow>({ name: 'Ref1', nodes: [], connections: {} }),
mock<SimpleWorkflow>({ name: 'Ref2', nodes: [], connections: {} }),
mock<SimpleWorkflow>({ name: 'Ref3', nodes: [], connections: {} }),
];
let callCount = 0;
mockExecFileAsync.mockImplementation(async () => {
callCount++;
const score = callCount === 2 ? 0.9 : 0.5; // Second call has highest score
const mockOutput = JSON.stringify({
similarity_score: score,
edit_cost: 10,
max_possible_cost: 100,
top_edits: [],
metadata: { generated_nodes: 1, ground_truth_nodes: 1, config_name: 'standard' },
});
return { stdout: mockOutput, stderr: '' };
});
const result = await evaluateWorkflowSimilarityMultiple(
generatedWorkflow,
referenceWorkflows,
);
expect(result.score).toBe(0.9);
expect(mockExecFileAsync).toHaveBeenCalledTimes(3);
});
it('should throw error when no reference workflows provided', async () => {
await expect(evaluateWorkflowSimilarityMultiple(generatedWorkflow, [])).rejects.toThrow(
'At least one reference workflow is required',
);
});
});
});
@@ -0,0 +1,254 @@
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { writeFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import type { SimpleWorkflow } from '@/types';
import type { ProgrammaticViolationName, SingleEvaluatorResult } from '@/validation/types';
const execFileAsync = promisify(execFile);
interface ExecError extends Error {
code?: string;
killed?: boolean;
stdout?: string;
stderr?: string;
}
function isExecError(error: unknown): error is ExecError {
return error instanceof Error;
}
/**
* Map Python edit types to violation names
*/
function mapEditTypeToViolationName(editType: string): ProgrammaticViolationName {
const mapping: Record<string, ProgrammaticViolationName> = {
node_insert: 'workflow-similarity-node-insert',
node_delete: 'workflow-similarity-node-delete',
node_substitute: 'workflow-similarity-node-substitute',
edge_insert: 'workflow-similarity-edge-insert',
edge_delete: 'workflow-similarity-edge-delete',
edge_substitute: 'workflow-similarity-edge-substitute',
};
return mapping[editType] ?? 'workflow-similarity-node-substitute';
}
interface WorkflowSimilarityResult {
similarity_score: number; // 0-1
edit_cost: number;
max_possible_cost: number;
top_edits: Array<{
type:
| 'node_insert'
| 'node_delete'
| 'node_substitute'
| 'edge_insert'
| 'edge_delete'
| 'edge_substitute';
description: string;
cost: number;
priority: 'critical' | 'major' | 'minor';
node_name?: string;
}>;
metadata: {
generated_nodes: number;
ground_truth_nodes: number;
generated_nodes_after_filter?: number;
ground_truth_nodes_after_filter?: number;
config_name: string;
config_description?: string;
};
}
function isWorkflowSimilarityResult(value: unknown): value is WorkflowSimilarityResult {
if (typeof value !== 'object' || value === null) {
return false;
}
const obj = value as Record<string, unknown>;
return (
typeof obj.similarity_score === 'number' &&
typeof obj.edit_cost === 'number' &&
typeof obj.max_possible_cost === 'number' &&
Array.isArray(obj.top_edits) &&
typeof obj.metadata === 'object' &&
obj.metadata !== null
);
}
/**
* Evaluate workflow similarity using Python graph edit distance algorithm.
* Compares against a single reference workflow.
*
* @param generatedWorkflow - Workflow generated by AI
* @param groundTruthWorkflow - Reference workflow to compare against
* @param configPreset - Built-in preset to use ('strict' | 'standard' | 'lenient')
* @param customConfigPath - Optional path to custom configuration file
* @returns SingleEvaluatorResult with violations and score
*/
export async function evaluateWorkflowSimilarity(
generatedWorkflow: SimpleWorkflow,
groundTruthWorkflow: SimpleWorkflow,
configPreset: 'strict' | 'standard' | 'lenient' = 'standard',
customConfigPath?: string,
): Promise<SingleEvaluatorResult> {
const tmpDir = tmpdir();
const uniqueId = randomUUID();
const generatedPath = join(tmpDir, `n8n-workflow-generated-${uniqueId}.json`);
const groundTruthPath = join(tmpDir, `n8n-workflow-groundtruth-${uniqueId}.json`);
let stdout = '';
let stderr = '';
try {
// Write workflows to temp files
await Promise.all([
writeFile(generatedPath, JSON.stringify(generatedWorkflow)),
writeFile(groundTruthPath, JSON.stringify(groundTruthWorkflow)),
]);
// Build command arguments
const pythonScriptDir = join(__dirname, '..', 'python');
const args = [
'--from',
pythonScriptDir,
'python',
'-m',
'src.compare_workflows',
generatedPath,
groundTruthPath,
'--output-format',
'json',
];
// Add config argument
if (customConfigPath) {
args.push('--config', customConfigPath);
} else {
args.push('--preset', configPreset);
}
// Run Python script using uvx
try {
const result = await execFileAsync('uvx', args, {
cwd: pythonScriptDir, // Set working directory to Python project
timeout: 30000, // 30 second timeout
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
});
stdout = result.stdout;
stderr = result.stderr;
} catch (execError) {
// Python script may exit with non-zero code if similarity is below threshold
// But it still outputs valid JSON, so we should use it
if (isExecError(execError)) {
stdout = execError.stdout ?? '';
stderr = execError.stderr ?? '';
}
// Only throw if we don't have valid output
if (!stdout || stdout.trim() === '') {
throw execError;
}
}
// Check if stdout is empty
if (!stdout || stdout.trim() === '') {
throw new Error(
`Python script produced no output. stderr: ${stderr || 'none'}. Command: uvx ${args.join(' ')}`,
);
}
// Parse result
const parsed: unknown = JSON.parse(stdout);
if (!isWorkflowSimilarityResult(parsed)) {
throw new Error(
`Invalid response from Python script. Expected WorkflowSimilarityResult shape but got: ${stdout.slice(0, 200)}`,
);
}
// Convert Python result to SingleEvaluatorResult format
const violations = parsed.top_edits.map((edit) => ({
name: mapEditTypeToViolationName(edit.type),
type: edit.priority,
description: edit.description,
pointsDeducted: Math.round(edit.cost),
}));
return {
violations,
score: parsed.similarity_score,
};
} catch (error) {
// Handle specific error cases
if (isExecError(error)) {
if (error.killed) {
// Timeout error
throw new Error(
'Workflow comparison timed out (graphs too complex). Consider using a simpler comparison or increasing timeout.',
);
}
if (error.code === 'ENOENT') {
throw new Error(
'uvx command not found. Please install uv: https://docs.astral.sh/uv/getting-started/installation/',
);
}
// Re-throw with more context
throw new Error(`Workflow similarity evaluation failed: ${error.message}`);
}
// Handle non-Error thrown values
throw new Error(`Workflow similarity evaluation failed: ${String(error)}`);
} finally {
// Cleanup temp files (don't throw on cleanup errors)
await Promise.allSettled([unlink(generatedPath), unlink(groundTruthPath)]);
}
}
/**
* Evaluate workflow similarity against multiple reference workflows.
* Returns the result with the highest similarity score.
*
* @param generatedWorkflow - Workflow generated by AI
* @param referenceWorkflows - Array of reference workflows to compare against
* @param configPreset - Built-in preset to use ('strict' | 'standard' | 'lenient')
* @param customConfigPath - Optional path to custom configuration file
* @returns SingleEvaluatorResult with violations and score from the best match
*/
export async function evaluateWorkflowSimilarityMultiple(
generatedWorkflow: SimpleWorkflow,
referenceWorkflows: SimpleWorkflow[],
configPreset: 'strict' | 'standard' | 'lenient' = 'standard',
customConfigPath?: string,
): Promise<SingleEvaluatorResult> {
if (referenceWorkflows.length === 0) {
throw new Error('At least one reference workflow is required');
}
// Compare against all reference workflows in parallel
const results = await Promise.all(
referenceWorkflows.map(
async (refWorkflow) =>
await evaluateWorkflowSimilarity(
generatedWorkflow,
refWorkflow,
configPreset,
customConfigPath,
),
),
);
// Find the result with the highest similarity score
const bestResult = results.reduce((best, current) =>
current.score > best.score ? current : best,
);
return bestResult;
}