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
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:
+477
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+11
@@ -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 }) };
|
||||
}
|
||||
+1145
File diff suppressed because it is too large
Load Diff
+15
@@ -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 }) };
|
||||
}
|
||||
+242
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
@@ -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 }) };
|
||||
}
|
||||
+114
@@ -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';
|
||||
+10
@@ -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 }) };
|
||||
}
|
||||
+290
@@ -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 }) };
|
||||
}
|
||||
+690
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
@@ -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 }) };
|
||||
}
|
||||
+219
@@ -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 }) };
|
||||
}
|
||||
+312
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+254
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user