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:
@@ -0,0 +1,46 @@
|
||||
// ============================================================================
|
||||
// Agent Types
|
||||
// ============================================================================
|
||||
|
||||
export const AGENT_TYPES = {
|
||||
CODE_BUILDER: 'code-builder',
|
||||
MULTI_AGENT: 'multi-agent',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Evaluation Type Identifiers
|
||||
// ============================================================================
|
||||
|
||||
export const EVAL_TYPES = {
|
||||
PAIRWISE_LOCAL: 'pairwise-local',
|
||||
PAIRWISE_LANGSMITH: 'pairwise-langsmith',
|
||||
LANGSMITH: 'langsmith-evals',
|
||||
} as const;
|
||||
|
||||
export const EVAL_USERS = {
|
||||
PAIRWISE_LOCAL: 'pairwise-local-user',
|
||||
LANGSMITH: 'langsmith-eval-user',
|
||||
} as const;
|
||||
|
||||
export const TRACEABLE_NAMES = {
|
||||
PAIRWISE_EVALUATION: 'pairwise_evaluation',
|
||||
WORKFLOW_GENERATION: 'workflow_generation',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULTS = {
|
||||
NUM_JUDGES: 3,
|
||||
EXPERIMENT_NAME: 'pairwise-evals',
|
||||
LLM_JUDGE_EXPERIMENT_NAME: 'workflow-builder-evaluation',
|
||||
CONCURRENCY: 5,
|
||||
REPETITIONS: 1,
|
||||
/** Per-operation timeout (generation / evaluator) */
|
||||
TIMEOUT_MS: 20 * 60 * 1000,
|
||||
DATASET_NAME: 'notion-pairwise-workflows',
|
||||
FEATURE_FLAGS: {
|
||||
templateExamples: false,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
import fs from 'fs';
|
||||
import { Client } from 'langsmith/client';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import path from 'path';
|
||||
|
||||
import { DEFAULT_MODEL, getApiKeyEnvVar, MODEL_FACTORIES, type ModelId } from '@/llm-config';
|
||||
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
import { WorkflowBuilderAgent } from '@/workflow-builder-agent';
|
||||
|
||||
import { loadNodesFromFile } from './load-nodes';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import {
|
||||
createTraceFilters,
|
||||
isMinimalTracingEnabled,
|
||||
type TraceFilters,
|
||||
} from '../langsmith/trace-filters';
|
||||
|
||||
/** Maximum memory for trace queue (3GB) */
|
||||
const MAX_INGEST_MEMORY_BYTES = 3 * 1024 * 1024 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// Stage Models Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for per-stage model selection.
|
||||
* All fields except 'default' are optional - unspecified stages use the default model.
|
||||
*/
|
||||
export interface StageModels {
|
||||
/** Default model for all stages */
|
||||
default: ModelId;
|
||||
/** Model for supervisor stage (routing decisions) */
|
||||
supervisor?: ModelId;
|
||||
/** Model for responder stage (final user responses) */
|
||||
responder?: ModelId;
|
||||
/** Model for discovery stage (node discovery) */
|
||||
discovery?: ModelId;
|
||||
/** Model for builder stage (workflow structure and configuration) */
|
||||
builder?: ModelId;
|
||||
/** Model for parameter updater (within builder) */
|
||||
parameterUpdater?: ModelId;
|
||||
/** Model for planner stage (plan mode) */
|
||||
planner?: ModelId;
|
||||
/** Model for LLM judge evaluation */
|
||||
judge?: ModelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved LLM instances for each stage.
|
||||
* All fields are populated (using default model as fallback).
|
||||
*/
|
||||
export interface ResolvedStageLLMs {
|
||||
default: BaseChatModel;
|
||||
supervisor: BaseChatModel;
|
||||
responder: BaseChatModel;
|
||||
discovery: BaseChatModel;
|
||||
builder: BaseChatModel;
|
||||
parameterUpdater: BaseChatModel;
|
||||
planner: BaseChatModel;
|
||||
judge: BaseChatModel;
|
||||
}
|
||||
|
||||
export interface TestEnvironment {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
/** Resolved LLM instances for each stage */
|
||||
llms: ResolvedStageLLMs;
|
||||
tracer?: LangChainTracer;
|
||||
lsClient?: Client;
|
||||
/** Trace filtering utilities (only present when minimal tracing is enabled) */
|
||||
traceFilters?: TraceFilters;
|
||||
/** Directories containing generated node definition files */
|
||||
nodeDefinitionDirs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an LLM with proper configuration
|
||||
* @param modelId - Model identifier (defaults to DEFAULT_MODEL)
|
||||
* @returns Configured LLM instance
|
||||
* @throws Error if the required API key environment variable is not set
|
||||
*/
|
||||
export async function setupLLM(modelId: ModelId = DEFAULT_MODEL): Promise<BaseChatModel> {
|
||||
const envVar = getApiKeyEnvVar(modelId);
|
||||
const apiKey = process.env[envVar];
|
||||
if (!apiKey) {
|
||||
throw new Error(`${envVar} environment variable is required for model ${modelId}`);
|
||||
}
|
||||
const factory = MODEL_FACTORIES[modelId];
|
||||
return await factory({ apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all stage models to LLM instances.
|
||||
* Unspecified stages fall back to the default model.
|
||||
* @param stageModels - Per-stage model configuration
|
||||
* @returns Resolved LLM instances for each stage
|
||||
*/
|
||||
export async function resolveStageModels(stageModels: StageModels): Promise<ResolvedStageLLMs> {
|
||||
const defaultLLM = await setupLLM(stageModels.default);
|
||||
|
||||
// For stages without specific model, use default
|
||||
// For parameter updater, fall back to builder if not specified
|
||||
const builderLLM = stageModels.builder ? await setupLLM(stageModels.builder) : defaultLLM;
|
||||
|
||||
return {
|
||||
default: defaultLLM,
|
||||
supervisor: stageModels.supervisor ? await setupLLM(stageModels.supervisor) : defaultLLM,
|
||||
responder: stageModels.responder ? await setupLLM(stageModels.responder) : defaultLLM,
|
||||
discovery: stageModels.discovery ? await setupLLM(stageModels.discovery) : defaultLLM,
|
||||
builder: builderLLM,
|
||||
parameterUpdater: stageModels.parameterUpdater
|
||||
? await setupLLM(stageModels.parameterUpdater)
|
||||
: builderLLM,
|
||||
planner: stageModels.planner ? await setupLLM(stageModels.planner) : defaultLLM,
|
||||
judge: stageModels.judge ? await setupLLM(stageModels.judge) : defaultLLM,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a LangChain tracer for monitoring agent execution
|
||||
* @param projectName - Name of the LangSmith project
|
||||
* @returns LangChainTracer instance or undefined if API key not provided
|
||||
*/
|
||||
export function createTracer(client: Client, projectName: string): LangChainTracer | undefined {
|
||||
return new LangChainTracer({
|
||||
client,
|
||||
projectName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a LangSmith client with optional filtering.
|
||||
*/
|
||||
export interface LangsmithClientResult {
|
||||
client: Client;
|
||||
/** Trace filters (only present when minimal tracing is enabled) */
|
||||
traceFilters?: TraceFilters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Langsmith client if API key is available.
|
||||
* By default, minimal tracing is enabled to reduce payload sizes and avoid 403 errors.
|
||||
* Set LANGSMITH_MINIMAL_TRACING=false to disable filtering and get full traces.
|
||||
* @param logger - Optional logger for trace filter output
|
||||
* @returns LangSmith client with optional trace filters, or undefined if no API key
|
||||
*/
|
||||
export function createLangsmithClient(logger?: EvalLogger): LangsmithClientResult | undefined {
|
||||
const apiKey = process.env.LANGSMITH_API_KEY;
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const minimalTracing = isMinimalTracingEnabled();
|
||||
|
||||
if (!minimalTracing) {
|
||||
return { client: new Client({ apiKey }) };
|
||||
}
|
||||
|
||||
// Create closure-scoped filters for this client instance
|
||||
const traceFilters = createTraceFilters(logger);
|
||||
|
||||
const client = new Client({
|
||||
apiKey,
|
||||
// Filter large fields from traces to avoid 403 payload errors
|
||||
hideInputs: traceFilters.filterInputs,
|
||||
hideOutputs: traceFilters.filterOutputs,
|
||||
// Increase queue memory limit for high-concurrency evals
|
||||
maxIngestMemoryBytes: MAX_INGEST_MEMORY_BYTES,
|
||||
});
|
||||
|
||||
return { client, traceFilters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve built-in node definition directories from installed node packages.
|
||||
* Mirrors `WorkflowBuilderService.resolveBuiltinNodeDefinitionDirs()` for use
|
||||
* in the eval harness where the DI container is not available.
|
||||
*/
|
||||
export function resolveBuiltinNodeDefinitionDirs(): string[] {
|
||||
// In a pnpm monorepo, n8n-nodes-base and n8n-nodes-langchain are not direct
|
||||
// dependencies of ai-workflow-builder.ee, so bare require.resolve() fails.
|
||||
// Resolve from packages/cli which has them as dependencies.
|
||||
const repoRoot = findRepoRoot(__dirname);
|
||||
const resolvePaths = repoRoot ? [path.join(repoRoot, 'packages', 'cli')] : undefined;
|
||||
|
||||
const dirs: string[] = [];
|
||||
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
|
||||
try {
|
||||
const packageJsonPath = require.resolve(`${packageId}/package.json`, {
|
||||
paths: resolvePaths,
|
||||
});
|
||||
const distDir = path.dirname(packageJsonPath);
|
||||
const nodeDefsDir = path.join(distDir, 'dist', 'node-definitions');
|
||||
if (fs.existsSync(nodeDefsDir)) {
|
||||
dirs.push(nodeDefsDir);
|
||||
}
|
||||
} catch {
|
||||
// Package not installed, skip
|
||||
}
|
||||
}
|
||||
if (dirs.length === 0) {
|
||||
console.error('[NODE-DEFS] No node definition dirs resolved — get_node_types will fail');
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** Walk up from startDir to find the monorepo root (contains pnpm-workspace.yaml). */
|
||||
export function findRepoRoot(startDir: string): string | undefined {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the path to packages/nodes-base/nodes/ for __schema__ resolution.
|
||||
* Returns undefined if the path doesn't exist (e.g. running outside the monorepo).
|
||||
*/
|
||||
export function resolveNodesBasePath(): string | undefined {
|
||||
const repoRoot = findRepoRoot(__dirname);
|
||||
if (!repoRoot) return undefined;
|
||||
const p = path.join(repoRoot, 'packages', 'nodes-base', 'nodes');
|
||||
return fs.existsSync(p) ? p : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the test environment with LLM, nodes, and tracing
|
||||
* @param stageModels - Per-stage model configuration (optional, uses default model if not provided)
|
||||
* @param logger - Optional logger for trace filter output
|
||||
* @returns Test environment configuration
|
||||
*/
|
||||
export async function setupTestEnvironment(
|
||||
stageModels?: StageModels,
|
||||
logger?: EvalLogger,
|
||||
): Promise<TestEnvironment> {
|
||||
const parsedNodeTypes = loadNodesFromFile();
|
||||
|
||||
// Use provided stage models or default configuration
|
||||
const models: StageModels = stageModels ?? { default: DEFAULT_MODEL };
|
||||
const llms = await resolveStageModels(models);
|
||||
|
||||
const lsClientResult = createLangsmithClient(logger);
|
||||
|
||||
const lsClient = lsClientResult?.client;
|
||||
const traceFilters = lsClientResult?.traceFilters;
|
||||
const tracer = lsClient ? createTracer(lsClient, 'workflow-builder-evaluation') : undefined;
|
||||
|
||||
return {
|
||||
parsedNodeTypes,
|
||||
llms,
|
||||
tracer,
|
||||
lsClient,
|
||||
traceFilters,
|
||||
nodeDefinitionDirs: resolveBuiltinNodeDefinitionDirs(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
/** Per-stage LLMs resolved from model configuration */
|
||||
llms: ResolvedStageLLMs;
|
||||
tracer?: LangChainTracer;
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
experimentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WorkflowBuilderAgent instance
|
||||
* @param options - Agent configuration options
|
||||
* @returns Configured WorkflowBuilderAgent
|
||||
*/
|
||||
export function createAgent(options: CreateAgentOptions): WorkflowBuilderAgent {
|
||||
const { parsedNodeTypes, llms, tracer, featureFlags, experimentName } = options;
|
||||
|
||||
return new WorkflowBuilderAgent({
|
||||
parsedNodeTypes,
|
||||
stageLLMs: {
|
||||
supervisor: llms.supervisor,
|
||||
responder: llms.responder,
|
||||
discovery: llms.discovery,
|
||||
builder: llms.builder,
|
||||
parameterUpdater: llms.parameterUpdater,
|
||||
planner: llms.planner,
|
||||
},
|
||||
checkpointer: new MemorySaver(),
|
||||
tracer,
|
||||
featureFlags,
|
||||
runMetadata: {
|
||||
featureFlags: featureFlags ?? {},
|
||||
experimentName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get concurrency limit from environment
|
||||
* @returns Concurrency limit (defaults to 5)
|
||||
*/
|
||||
export function getConcurrencyLimit(): number {
|
||||
const envConcurrency = process.env.EVALUATION_CONCURRENCY;
|
||||
if (envConcurrency) {
|
||||
const parsed = parseInt(envConcurrency, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if test cases should be generated
|
||||
* @returns True if test cases should be generated
|
||||
*/
|
||||
export function shouldGenerateTestCases(): boolean {
|
||||
return process.env.GENERATE_TEST_CASES === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* How many test cases to generate based on environment variable
|
||||
* @returns Number of test cases to generate (defaults to 10)
|
||||
*/
|
||||
export function howManyTestCasesToGenerate(): number {
|
||||
const envCount = process.env.GENERATE_TEST_CASES_COUNT;
|
||||
if (envCount) {
|
||||
const parsed = parseInt(envCount, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 10; // Default to 10 if not specified
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
// We need to mock the fs module before importing the module under test
|
||||
jest.mock('fs', () => ({
|
||||
readFileSync: jest.fn(),
|
||||
existsSync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedReadFileSync = readFileSync as jest.MockedFunction<typeof readFileSync>;
|
||||
const mockedExistsSync = existsSync as jest.MockedFunction<typeof existsSync>;
|
||||
|
||||
// Import after mocking
|
||||
import { loadNodesFromFile } from './load-nodes';
|
||||
|
||||
describe('loadNodesFromFile', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Default: legacy path exists
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
return String(path).endsWith('nodes.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version handling', () => {
|
||||
it('should keep all version entries for a node type so older versions can be validated', () => {
|
||||
// This is the real-world scenario: removeDuplicates has two entries
|
||||
// - Entry 1: version [1, 1.1], defaultVersion 2
|
||||
// - Entry 2: version [2], defaultVersion 2
|
||||
// A workflow using typeVersion 1.1 should still be able to validate
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.removeDuplicates',
|
||||
displayName: 'Remove Duplicates',
|
||||
version: [1, 1.1],
|
||||
defaultVersion: 2,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.removeDuplicates',
|
||||
displayName: 'Remove Duplicates',
|
||||
version: [2],
|
||||
defaultVersion: 2,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
|
||||
// The result should contain entries that cover ALL versions (1, 1.1, and 2)
|
||||
// so that workflows using any of these versions can be validated
|
||||
const removeDuplicatesEntries = result.filter(
|
||||
(n) => n.name === 'n8n-nodes-base.removeDuplicates',
|
||||
);
|
||||
|
||||
// We need at least the versions to be available for lookup
|
||||
// Either keep both entries, or merge the versions
|
||||
const allVersions = removeDuplicatesEntries.flatMap((entry) =>
|
||||
Array.isArray(entry.version) ? entry.version : [entry.version],
|
||||
);
|
||||
|
||||
expect(allVersions).toContain(1);
|
||||
expect(allVersions).toContain(1.1);
|
||||
expect(allVersions).toContain(2);
|
||||
});
|
||||
|
||||
it('should handle single-version nodes correctly', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
const codeNode = result.find((n) => n.name === 'n8n-nodes-base.code');
|
||||
|
||||
expect(codeNode).toBeDefined();
|
||||
expect(codeNode?.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle nodes with version array but no defaultVersion', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
version: [1, 2, 3],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
const httpNode = result.find((n) => n.name === 'n8n-nodes-base.httpRequest');
|
||||
|
||||
expect(httpNode).toBeDefined();
|
||||
// All versions should be available
|
||||
const versions = Array.isArray(httpNode?.version) ? httpNode.version : [httpNode?.version];
|
||||
expect(versions).toContain(1);
|
||||
expect(versions).toContain(2);
|
||||
expect(versions).toContain(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
it('should filter out ignored node types', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
displayName: 'Vector Store Tool',
|
||||
version: 1,
|
||||
inputs: [],
|
||||
outputs: ['ai_tool'],
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
|
||||
expect(
|
||||
result.find((n) => n.name === '@n8n/n8n-nodes-langchain.toolVectorStore'),
|
||||
).toBeUndefined();
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-base.code')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter out hidden nodes except dataTable', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.hiddenNode',
|
||||
displayName: 'Hidden Node',
|
||||
version: 1,
|
||||
hidden: true,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.dataTable',
|
||||
displayName: 'Data Table',
|
||||
version: 1,
|
||||
hidden: true,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-base.hiddenNode')).toBeUndefined();
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-base.dataTable')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { jsonParse, type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { join } from 'path';
|
||||
|
||||
interface NodeWithVersion extends INodeTypeDescription {
|
||||
version: number | number[];
|
||||
defaultVersion?: number;
|
||||
}
|
||||
|
||||
// These types are ignored because they tend to cause issues when generating workflows
|
||||
// Same as in ai-workflow-builder-agent.service.ts
|
||||
const IGNORED_TYPES = new Set([
|
||||
'@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
'@n8n/n8n-nodes-langchain.documentGithubLoader',
|
||||
'@n8n/n8n-nodes-langchain.code',
|
||||
]);
|
||||
|
||||
// Parse disabled nodes from environment variable (comma-separated)
|
||||
function getDisabledNodes(): Set<string> {
|
||||
const disabledNodesEnv = process.env.N8N_EVALS_DISABLED_NODES ?? '';
|
||||
return new Set(
|
||||
disabledNodesEnv
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter node types similar to production service:
|
||||
* - Remove ignored types (hardcoded)
|
||||
* - Remove disabled types (from env var)
|
||||
* - Remove hidden nodes (except DataTable)
|
||||
* - Merge tool nodes with their non-tool counterparts
|
||||
*/
|
||||
function filterNodeTypes(
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
disabledNodes: Set<string>,
|
||||
): INodeTypeDescription[] {
|
||||
const visibleNodeTypes = nodeTypes.filter(
|
||||
(nodeType) =>
|
||||
!IGNORED_TYPES.has(nodeType.name) &&
|
||||
!disabledNodes.has(nodeType.name) &&
|
||||
// Filter out hidden nodes, except for the Data Table node which has custom hiding logic
|
||||
(nodeType.hidden !== true || nodeType.name === 'n8n-nodes-base.dataTable'),
|
||||
);
|
||||
|
||||
return visibleNodeTypes.map((nodeType) => {
|
||||
// If the node type is a tool, merge it with the corresponding non-tool node type
|
||||
const isTool = nodeType.name.endsWith('Tool');
|
||||
if (!isTool) return nodeType;
|
||||
|
||||
const nonToolNode = nodeTypes.find((nt) => nt.name === nodeType.name.replace('Tool', ''));
|
||||
if (!nonToolNode) return nodeType;
|
||||
|
||||
return { ...nonToolNode, ...nodeType };
|
||||
});
|
||||
}
|
||||
|
||||
export function loadNodesFromFile(): INodeTypeDescription[] {
|
||||
const preferredPath = join(__dirname, '..', '.data', 'nodes.json');
|
||||
const legacyPath = join(__dirname, '..', 'nodes.json');
|
||||
const nodesPath = existsSync(preferredPath) ? preferredPath : legacyPath;
|
||||
|
||||
if (!existsSync(nodesPath)) {
|
||||
throw new Error(
|
||||
`nodes.json not found at ${nodesPath}. ` +
|
||||
'Run n8n and export node definitions to evaluations/.data/nodes.json',
|
||||
);
|
||||
}
|
||||
|
||||
const nodesData = readFileSync(nodesPath, 'utf-8');
|
||||
const allNodes = jsonParse<NodeWithVersion[]>(nodesData);
|
||||
|
||||
// Keep all version entries instead of selecting only the latest version.
|
||||
// Workflows may use older node versions (e.g., removeDuplicates v1.1), and
|
||||
// discarding those entries causes "Node type not found" validation errors.
|
||||
const disabledNodes = getDisabledNodes();
|
||||
return filterNodeTypes(allNodes, disabledNodes);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import type { INode, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
identifyPinDataNodes,
|
||||
buildSchemaContexts,
|
||||
workflowToMermaid,
|
||||
generateEvalPinData,
|
||||
} from './pin-data-generator';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeNode(overrides: Partial<INode> & { name: string; type: string }): INode {
|
||||
return {
|
||||
name: overrides.name,
|
||||
type: overrides.type,
|
||||
typeVersion: overrides.typeVersion ?? 1,
|
||||
position: overrides.position ?? [0, 0],
|
||||
parameters: overrides.parameters ?? {},
|
||||
id: overrides.id ?? overrides.name,
|
||||
...(overrides.disabled !== undefined ? { disabled: overrides.disabled } : {}),
|
||||
} as INode;
|
||||
}
|
||||
|
||||
function makeNodeType(
|
||||
name: string,
|
||||
opts?: { credentials?: Array<{ name: string }> },
|
||||
): INodeTypeDescription {
|
||||
return {
|
||||
displayName: name,
|
||||
name,
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: '',
|
||||
defaults: { name },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
...(opts?.credentials ? { credentials: opts.credentials } : {}),
|
||||
} as unknown as INodeTypeDescription;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// identifyPinDataNodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('identifyPinDataNodes', () => {
|
||||
const nodeTypes: INodeTypeDescription[] = [
|
||||
makeNodeType('n8n-nodes-base.slack', { credentials: [{ name: 'slackApi' }] }),
|
||||
makeNodeType('n8n-nodes-base.gmail', { credentials: [{ name: 'gmailOAuth2' }] }),
|
||||
makeNodeType('n8n-nodes-base.set'),
|
||||
makeNodeType('n8n-nodes-base.if'),
|
||||
makeNodeType('n8n-nodes-base.httpRequest'),
|
||||
makeNodeType('n8n-nodes-base.webhook'),
|
||||
];
|
||||
|
||||
it('should include service nodes with credentials', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Slack');
|
||||
});
|
||||
|
||||
it('should exclude utility nodes that can run without infrastructure', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }),
|
||||
makeNode({ name: 'If', type: 'n8n-nodes-base.if' }),
|
||||
makeNode({ name: 'Merge', type: 'n8n-nodes-base.merge' }),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include Code and ExecuteCommand nodes (require task runner)', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
makeNode({ name: 'Exec', type: 'n8n-nodes-base.executeCommand' }),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((n) => n.name)).toEqual(['Code', 'Exec']);
|
||||
});
|
||||
|
||||
it('should include DataTable nodes', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Table', type: 'n8n-nodes-base.dataTable' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Table');
|
||||
});
|
||||
|
||||
it('should include root AI nodes targeted by ai_* connections', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Chat Trigger', type: '@n8n/n8n-nodes-langchain.chatTrigger' }),
|
||||
makeNode({ name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
|
||||
makeNode({ name: 'OpenAI', type: '@n8n/n8n-nodes-langchain.lmOpenAi' }),
|
||||
makeNode({ name: 'Tool', type: '@n8n/n8n-nodes-langchain.toolCalculator' }),
|
||||
],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
main: [[{ node: 'Agent', type: 'main', index: 0 }]],
|
||||
},
|
||||
OpenAI: {
|
||||
ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]],
|
||||
},
|
||||
Tool: {
|
||||
ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
const names = result.map((n) => n.name);
|
||||
// Agent is a root AI node (target of ai_* connections) → pinned
|
||||
expect(names).toContain('Agent');
|
||||
// Sub-nodes (OpenAI, Tool) are not pinned — they won't execute
|
||||
// because the root is pinned
|
||||
expect(names).not.toContain('OpenAI');
|
||||
expect(names).not.toContain('Tool');
|
||||
});
|
||||
|
||||
it('should not treat nodes targeted only by main connections as AI roots', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }),
|
||||
makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }),
|
||||
],
|
||||
connections: {
|
||||
Trigger: {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include HTTP Request and Webhook nodes even without credentials', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'HTTP', type: 'n8n-nodes-base.httpRequest' }),
|
||||
makeNode({ name: 'Hook', type: 'n8n-nodes-base.webhook' }),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should skip disabled nodes', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', disabled: true })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle mixed utility and service nodes', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }),
|
||||
makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' }),
|
||||
makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }),
|
||||
makeNode({ name: 'Gmail', type: 'n8n-nodes-base.gmail' }),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = identifyPinDataNodes(workflow, nodeTypes);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((n) => n.name)).toEqual(['Slack', 'Gmail']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSchemaContexts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildSchemaContexts', () => {
|
||||
it('should extract resource and operation from node parameters', () => {
|
||||
const nodes: INode[] = [
|
||||
makeNode({
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
parameters: { resource: 'message', operation: 'send' },
|
||||
}),
|
||||
];
|
||||
|
||||
const contexts = buildSchemaContexts(nodes);
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(contexts[0]).toMatchObject({
|
||||
nodeName: 'Slack',
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nodes without resource/operation', () => {
|
||||
const nodes: INode[] = [
|
||||
makeNode({
|
||||
name: 'HTTP',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
parameters: { url: 'https://example.com' },
|
||||
}),
|
||||
];
|
||||
|
||||
const contexts = buildSchemaContexts(nodes);
|
||||
expect(contexts[0].resource).toBeUndefined();
|
||||
expect(contexts[0].operation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include schema when nodesBasePath is not provided', () => {
|
||||
const nodes: INode[] = [
|
||||
makeNode({
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
parameters: { resource: 'message', operation: 'send' },
|
||||
}),
|
||||
];
|
||||
|
||||
const contexts = buildSchemaContexts(nodes);
|
||||
expect(contexts[0].schema).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// workflowToMermaid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('workflowToMermaid', () => {
|
||||
it('should generate mermaid flowchart for a simple workflow', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }),
|
||||
makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack', typeVersion: 2 }),
|
||||
],
|
||||
connections: {
|
||||
Trigger: {
|
||||
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mermaid = workflowToMermaid(workflow);
|
||||
expect(mermaid).toContain('flowchart LR');
|
||||
expect(mermaid).toContain('Trigger');
|
||||
expect(mermaid).toContain('Slack');
|
||||
expect(mermaid).toContain('-->');
|
||||
});
|
||||
|
||||
it('should include resource and operation in labels', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
parameters: { resource: 'message', operation: 'send' },
|
||||
}),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mermaid = workflowToMermaid(workflow);
|
||||
expect(mermaid).toContain('resource:message');
|
||||
expect(mermaid).toContain('op:send');
|
||||
});
|
||||
|
||||
it('should handle empty connections', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mermaid = workflowToMermaid(workflow);
|
||||
expect(mermaid).toContain('flowchart LR');
|
||||
expect(mermaid).not.toContain('-->');
|
||||
});
|
||||
|
||||
it('should handle multi-output connections', () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'If', type: 'n8n-nodes-base.if' }),
|
||||
makeNode({ name: 'True', type: 'n8n-nodes-base.set' }),
|
||||
makeNode({ name: 'False', type: 'n8n-nodes-base.set' }),
|
||||
],
|
||||
connections: {
|
||||
If: {
|
||||
main: [
|
||||
[{ node: 'True', type: 'main', index: 0 }],
|
||||
[{ node: 'False', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mermaid = workflowToMermaid(workflow);
|
||||
// Should have two connection lines
|
||||
const arrowCount = (mermaid.match(/-->/g) ?? []).length;
|
||||
expect(arrowCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateEvalPinData (with mocked LLM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateEvalPinData', () => {
|
||||
const nodeTypes: INodeTypeDescription[] = [
|
||||
makeNodeType('n8n-nodes-base.slack', { credentials: [{ name: 'slackApi' }] }),
|
||||
makeNodeType('n8n-nodes-base.linear', { credentials: [{ name: 'linearApi' }] }),
|
||||
makeNodeType('n8n-nodes-base.set'),
|
||||
];
|
||||
|
||||
function createMockLLM(responseContent: string) {
|
||||
return {
|
||||
invoke: jest.fn().mockResolvedValue({ content: responseContent }),
|
||||
} as never;
|
||||
}
|
||||
|
||||
it('should return pin data for service nodes', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Linear', type: 'n8n-nodes-base.linear' }),
|
||||
makeNode({ name: 'Set', type: 'n8n-nodes-base.set' }),
|
||||
makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' }),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const llmResponse = JSON.stringify({
|
||||
Linear: [{ json: { id: 'issue-123', title: 'Test Issue' } }],
|
||||
Slack: [{ json: { ok: true, channel: 'C123' } }],
|
||||
});
|
||||
|
||||
const result = await generateEvalPinData(workflow, {
|
||||
llm: createMockLLM(llmResponse),
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
expect(Object.keys(result)).toEqual(['Linear', 'Slack']);
|
||||
expect(result.Linear).toHaveLength(1);
|
||||
expect(result.Slack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty object when no service nodes exist', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Set', type: 'n8n-nodes-base.set' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const llm = createMockLLM('{}');
|
||||
|
||||
const result = await generateEvalPinData(workflow, { llm, nodeTypes });
|
||||
|
||||
expect(result).toEqual({});
|
||||
// LLM should not be called
|
||||
expect((llm as unknown as { invoke: jest.Mock }).invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle markdown-fenced JSON response', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const llmResponse = '```json\n{"Slack": [{"json": {"ok": true}}]}\n```';
|
||||
|
||||
const result = await generateEvalPinData(workflow, {
|
||||
llm: createMockLLM(llmResponse),
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
expect(result.Slack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should wrap raw objects in { json: ... } format', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
// LLM returns items without the { json: ... } wrapper
|
||||
const llmResponse = JSON.stringify({
|
||||
Slack: [{ ok: true, channel: 'C123' }],
|
||||
});
|
||||
|
||||
const result = await generateEvalPinData(workflow, {
|
||||
llm: createMockLLM(llmResponse),
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
expect(result.Slack).toHaveLength(1);
|
||||
expect(result.Slack[0]).toHaveProperty('json');
|
||||
});
|
||||
|
||||
it('should return empty object on LLM failure', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const llm = {
|
||||
invoke: jest.fn().mockRejectedValue(new Error('LLM error')),
|
||||
} as never;
|
||||
|
||||
const result = await generateEvalPinData(workflow, { llm, nodeTypes });
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object on invalid JSON response', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' })],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const result = await generateEvalPinData(workflow, {
|
||||
llm: createMockLLM('not valid json at all'),
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should skip nodes not present in LLM response', async () => {
|
||||
const workflow: SimpleWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
makeNode({ name: 'Slack', type: 'n8n-nodes-base.slack' }),
|
||||
makeNode({ name: 'Linear', type: 'n8n-nodes-base.linear' }),
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
// LLM only returns data for Slack, not Linear
|
||||
const llmResponse = JSON.stringify({
|
||||
Slack: [{ json: { ok: true } }],
|
||||
});
|
||||
|
||||
const result = await generateEvalPinData(workflow, {
|
||||
llm: createMockLLM(llmResponse),
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
expect(Object.keys(result)).toEqual(['Slack']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* LLM-based pin data generator for evaluations.
|
||||
*
|
||||
* Generates realistic mock output data for service nodes in a workflow
|
||||
* via a single LLM call, ensuring cross-node data consistency.
|
||||
* Works with both the code-based and multi-agent builder outputs.
|
||||
*/
|
||||
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { existsSync, readFileSync, readdirSync } from 'fs';
|
||||
import {
|
||||
jsonParse,
|
||||
type IDataObject,
|
||||
type INode,
|
||||
type INodeTypeDescription,
|
||||
type IPinData,
|
||||
} from 'n8n-workflow';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PinDataGeneratorOptions {
|
||||
/** LLM to use for generating mock data */
|
||||
llm: BaseChatModel;
|
||||
/** Loaded node type descriptions (from env.parsedNodeTypes) */
|
||||
nodeTypes: INodeTypeDescription[];
|
||||
/** Path to packages/nodes-base/nodes/ for __schema__ resolution */
|
||||
nodesBasePath?: string;
|
||||
/** Logger for verbose output */
|
||||
logger?: EvalLogger;
|
||||
}
|
||||
|
||||
interface NodeSchemaContext {
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
typeVersion: number;
|
||||
resource?: string;
|
||||
operation?: string;
|
||||
schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility node deny-list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Nodes that define credentials in their type description but don't actually
|
||||
* call external APIs, so they don't need pin data. All other non-service nodes
|
||||
* are excluded naturally because they have no credentials defined.
|
||||
*/
|
||||
const NON_SERVICE_NODES_WITH_CREDENTIALS = new Set([
|
||||
'n8n-nodes-base.wait', // optional webhook-resumption auth
|
||||
'n8n-nodes-base.respondToWebhook', // optional JWT signing when responding
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node identification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a set of node names that are targets of AI-type connections
|
||||
* (ai_languageModel, ai_tool, ai_memory, etc.). These are root AI nodes
|
||||
* (e.g. Agent, Chain) whose sub-nodes can't be individually pinned.
|
||||
* Pinning the root prevents sub-node execution entirely.
|
||||
*/
|
||||
function findAiRootNodeNames(workflow: SimpleWorkflow): Set<string> {
|
||||
const roots = new Set<string>();
|
||||
for (const nodeConns of Object.values(workflow.connections)) {
|
||||
for (const [connType, outputs] of Object.entries(nodeConns)) {
|
||||
if (!connType.startsWith('ai_')) continue;
|
||||
if (!Array.isArray(outputs)) continue;
|
||||
for (const group of outputs) {
|
||||
if (!Array.isArray(group)) continue;
|
||||
for (const conn of group) {
|
||||
if (conn?.node) roots.add(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify which nodes in a workflow need pin data.
|
||||
* In eval context, we pin all service/API nodes since none have real credentials.
|
||||
*/
|
||||
export function identifyPinDataNodes(
|
||||
workflow: SimpleWorkflow,
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
): INode[] {
|
||||
const nodeTypeMap = new Map(nodeTypes.map((nt) => [nt.name, nt]));
|
||||
const aiRootNodes = findAiRootNodeNames(workflow);
|
||||
|
||||
return workflow.nodes.filter((node) => {
|
||||
// Skip disabled nodes
|
||||
if (node.disabled) return false;
|
||||
|
||||
// Pin root AI nodes (Agent, Chain, etc.) — their sub-nodes (tools,
|
||||
// memory, LLMs) can't run without real providers in the eval context.
|
||||
if (aiRootNodes.has(node.name)) return true;
|
||||
|
||||
// Check if the node type definition has credentials (→ it's a service node)
|
||||
const typeDesc = nodeTypeMap.get(node.type);
|
||||
if (typeDesc?.credentials && typeDesc.credentials.length > 0) {
|
||||
// Exclude nodes that define credentials for local/optional auth, not external APIs
|
||||
return !NON_SERVICE_NODES_WITH_CREDENTIALS.has(node.type);
|
||||
}
|
||||
|
||||
// Nodes that require infrastructure unavailable in the eval context:
|
||||
// - HTTP/Webhook: optional credentials, may call external APIs
|
||||
// - DataTable: requires the data-table module
|
||||
// - Code/ExecuteCommand: require a task runner to execute
|
||||
if (
|
||||
node.type === 'n8n-nodes-base.httpRequest' ||
|
||||
node.type === 'n8n-nodes-base.webhook' ||
|
||||
node.type === 'n8n-nodes-base.dataTable' ||
|
||||
node.type === 'n8n-nodes-base.code' ||
|
||||
node.type === 'n8n-nodes-base.executeCommand'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a map from node type name (e.g., "n8n-nodes-base.linear") to the
|
||||
* directory containing its __schema__ folder by scanning .node.ts files.
|
||||
* Cached per nodesBasePath.
|
||||
*/
|
||||
const schemaMapCache = new Map<string, Map<string, string>>();
|
||||
|
||||
function buildSchemaMap(nodesBasePath: string): Map<string, string> {
|
||||
const cached = schemaMapCache.get(nodesBasePath);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = new Map<string, string>();
|
||||
|
||||
function scanDir(dir: string) {
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const entryPath = join(dir, entry.name);
|
||||
const schemaDir = join(entryPath, '__schema__');
|
||||
|
||||
if (existsSync(schemaDir)) {
|
||||
// Find .node.ts/.node.js files to extract the node type name
|
||||
const nodeFiles = readdirSync(entryPath).filter(
|
||||
(f) => f.endsWith('.node.ts') || f.endsWith('.node.js'),
|
||||
);
|
||||
for (const nodeFile of nodeFiles) {
|
||||
try {
|
||||
const content = readFileSync(join(entryPath, nodeFile), 'utf-8');
|
||||
const nameMatch = content.match(/name:\s*['"]([^'"]+)['"]/);
|
||||
if (nameMatch) {
|
||||
result.set(`n8n-nodes-base.${nameMatch[1]}`, entryPath);
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into subdirectories (e.g., Aws/S3, Google/Gmail)
|
||||
scanDir(entryPath);
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(nodesBasePath);
|
||||
schemaMapCache.set(nodesBasePath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a version number to a semver-like string for directory matching.
|
||||
* 1 → "1.0.0", 1.1 → "1.1.0", 2 → "2.0.0"
|
||||
*/
|
||||
function normalizeVersion(version: number): string {
|
||||
const str = String(version);
|
||||
const parts = str.split('.');
|
||||
while (parts.length < 3) parts.push('0');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the __schema__ JSON Schema for a node's output, if available.
|
||||
*/
|
||||
export function resolveSchemaForNode(
|
||||
nodeType: string,
|
||||
typeVersion: number,
|
||||
resource: string | undefined,
|
||||
operation: string | undefined,
|
||||
nodesBasePath: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const schemaMap = buildSchemaMap(nodesBasePath);
|
||||
const nodeDir = schemaMap.get(nodeType);
|
||||
if (!nodeDir) return undefined;
|
||||
|
||||
const schemaBaseDir = join(nodeDir, '__schema__');
|
||||
if (!existsSync(schemaBaseDir)) return undefined;
|
||||
|
||||
const versionStr = normalizeVersion(typeVersion);
|
||||
|
||||
// Try exact version first, then fall back to available versions
|
||||
const versionDirs = [
|
||||
`v${versionStr}`,
|
||||
...readdirSync(schemaBaseDir)
|
||||
.filter((d) => d.startsWith('v'))
|
||||
.sort()
|
||||
.reverse(),
|
||||
];
|
||||
|
||||
for (const vDir of [...new Set(versionDirs)]) {
|
||||
const versionPath = join(schemaBaseDir, vDir);
|
||||
if (!existsSync(versionPath)) continue;
|
||||
|
||||
// Build path with resource and operation
|
||||
const parts = [versionPath, resource, operation ? `${operation}.json` : undefined].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
|
||||
const schemaFile = join(...parts);
|
||||
if (existsSync(schemaFile)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(schemaFile, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema context building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build schema context for all pin-data-eligible nodes.
|
||||
*/
|
||||
export function buildSchemaContexts(nodes: INode[], nodesBasePath?: string): NodeSchemaContext[] {
|
||||
return nodes.map((node) => {
|
||||
const params = node.parameters as Record<string, unknown> | undefined;
|
||||
const resource = typeof params?.resource === 'string' ? params.resource : undefined;
|
||||
const operation = typeof params?.operation === 'string' ? params.operation : undefined;
|
||||
|
||||
let schema: Record<string, unknown> | undefined;
|
||||
if (nodesBasePath) {
|
||||
schema = resolveSchemaForNode(
|
||||
node.type,
|
||||
node.typeVersion,
|
||||
resource,
|
||||
operation,
|
||||
nodesBasePath,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
nodeName: node.name,
|
||||
nodeType: node.type,
|
||||
typeVersion: node.typeVersion,
|
||||
resource,
|
||||
operation,
|
||||
schema,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mermaid diagram generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a workflow's nodes and connections to a mermaid flowchart string.
|
||||
* Includes node type, version, resource, and operation info in labels.
|
||||
*/
|
||||
export function workflowToMermaid(workflow: SimpleWorkflow): string {
|
||||
const lines: string[] = ['flowchart LR'];
|
||||
|
||||
// Build a map for safe mermaid IDs
|
||||
const nodeIdMap = new Map<string, string>();
|
||||
workflow.nodes.forEach((node, i) => {
|
||||
nodeIdMap.set(node.name, `n${i}`);
|
||||
});
|
||||
|
||||
// Add node declarations with labels
|
||||
for (const node of workflow.nodes) {
|
||||
const id = nodeIdMap.get(node.name)!;
|
||||
const params = node.parameters as Record<string, unknown> | undefined;
|
||||
const resource = typeof params?.resource === 'string' ? params.resource : undefined;
|
||||
const operation = typeof params?.operation === 'string' ? params.operation : undefined;
|
||||
|
||||
const shortType = node.type.split('.').pop() ?? node.type;
|
||||
let label = `${node.name} (${shortType} v${node.typeVersion}`;
|
||||
if (resource) label += `, resource:${resource}`;
|
||||
if (operation) label += `, op:${operation}`;
|
||||
label += ')';
|
||||
|
||||
lines.push(` ${id}["${label}"]`);
|
||||
}
|
||||
|
||||
// Add connections
|
||||
const { connections } = workflow;
|
||||
for (const [sourceName, nodeConns] of Object.entries(connections)) {
|
||||
const sourceId = nodeIdMap.get(sourceName);
|
||||
if (!sourceId) continue;
|
||||
|
||||
for (const [, outputConnections] of Object.entries(nodeConns)) {
|
||||
if (!Array.isArray(outputConnections)) continue;
|
||||
|
||||
for (const outputGroup of outputConnections) {
|
||||
if (!Array.isArray(outputGroup)) continue;
|
||||
|
||||
for (const conn of outputGroup) {
|
||||
if (!conn?.node) continue;
|
||||
const targetId = nodeIdMap.get(conn.node);
|
||||
if (targetId) {
|
||||
lines.push(` ${sourceId} --> ${targetId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM prompt construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SYSTEM_PROMPT = `You are a test data generator for n8n workflow automation. Generate realistic mock API response data for service nodes in a workflow.
|
||||
|
||||
RULES:
|
||||
1. Data must be consistent across nodes. If node A creates an entity with id "abc-123", downstream nodes referencing that entity must use "abc-123".
|
||||
2. Generate 1-2 items per node.
|
||||
3. When a JSON Schema is provided, follow its structure exactly.
|
||||
4. When no schema is provided, generate a realistic response based on the node type, resource, and operation.
|
||||
5. Use realistic but clearly fake values (e.g., "jane@example.com", "proj_abc123", "2024-01-15T10:30:00Z").
|
||||
6. Return ONLY a valid JSON object, no explanation or markdown fencing.`;
|
||||
|
||||
function buildUserPrompt(workflow: SimpleWorkflow, contexts: NodeSchemaContext[]): string {
|
||||
const mermaid = workflowToMermaid(workflow);
|
||||
|
||||
const sections: string[] = [
|
||||
'Generate mock output data for service nodes in this workflow.',
|
||||
'',
|
||||
'## Workflow Graph',
|
||||
'',
|
||||
'```mermaid',
|
||||
mermaid,
|
||||
'```',
|
||||
'',
|
||||
'## Nodes Requiring Mock Data',
|
||||
];
|
||||
|
||||
for (const ctx of contexts) {
|
||||
sections.push('');
|
||||
sections.push(`### ${ctx.nodeName} (${ctx.nodeType} v${ctx.typeVersion})`);
|
||||
if (ctx.resource || ctx.operation) {
|
||||
const parts: string[] = [];
|
||||
if (ctx.resource) parts.push(`Resource: ${ctx.resource}`);
|
||||
if (ctx.operation) parts.push(`Operation: ${ctx.operation}`);
|
||||
sections.push(`- ${parts.join(' | ')}`);
|
||||
}
|
||||
if (ctx.schema) {
|
||||
const schemaStr = JSON.stringify(ctx.schema, null, 2);
|
||||
// Truncate very large schemas to keep prompt manageable
|
||||
const truncated = schemaStr.length > 3000 ? schemaStr.slice(0, 3000) + '\n...' : schemaStr;
|
||||
sections.push('- Output JSON Schema:');
|
||||
sections.push('```json');
|
||||
sections.push(truncated);
|
||||
sections.push('```');
|
||||
} else {
|
||||
sections.push('(no schema available — generate based on API knowledge)');
|
||||
}
|
||||
}
|
||||
|
||||
sections.push('');
|
||||
sections.push('## Expected Output Format');
|
||||
sections.push('');
|
||||
sections.push(
|
||||
'Return a JSON object where each key is the exact node name and the value is an array of items, each wrapped in a "json" key:',
|
||||
);
|
||||
sections.push('');
|
||||
sections.push('```json');
|
||||
sections.push('{');
|
||||
for (let i = 0; i < Math.min(contexts.length, 2); i++) {
|
||||
const ctx = contexts[i];
|
||||
const comma = i < Math.min(contexts.length, 2) - 1 ? ',' : '';
|
||||
sections.push(` "${ctx.nodeName}": [{ "json": { ... } }]${comma}`);
|
||||
}
|
||||
if (contexts.length > 2) {
|
||||
sections.push(' ...');
|
||||
}
|
||||
sections.push('}');
|
||||
sections.push('```');
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse the LLM response into IPinData format.
|
||||
* Handles both `{ "json": {...} }` wrapped and unwrapped items.
|
||||
*/
|
||||
function parsePinDataResponse(responseText: string, expectedNodes: string[]): IPinData {
|
||||
// Strip markdown code fences if present
|
||||
let cleaned = responseText.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
||||
}
|
||||
|
||||
const parsed = jsonParse<Record<string, unknown>>(cleaned);
|
||||
const pinData: IPinData = {};
|
||||
|
||||
for (const nodeName of expectedNodes) {
|
||||
const nodeData = parsed[nodeName];
|
||||
if (!Array.isArray(nodeData) || nodeData.length === 0) continue;
|
||||
|
||||
pinData[nodeName] = nodeData.map((item: unknown) => {
|
||||
if (typeof item === 'object' && item !== null && 'json' in item) {
|
||||
return item as { json: IDataObject };
|
||||
}
|
||||
// Wrap raw objects in { json: ... }
|
||||
return { json: (item ?? {}) as IDataObject };
|
||||
});
|
||||
}
|
||||
|
||||
return pinData;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate pin data for all service nodes in a workflow using an LLM.
|
||||
* Produces consistent cross-node mock data in a single LLM call.
|
||||
*
|
||||
* @returns IPinData map (node name → execution data items). Returns {} on failure.
|
||||
*/
|
||||
export async function generateEvalPinData(
|
||||
workflow: SimpleWorkflow,
|
||||
options: PinDataGeneratorOptions,
|
||||
): Promise<IPinData> {
|
||||
const { llm, nodeTypes, nodesBasePath, logger } = options;
|
||||
|
||||
// 1. Identify which nodes need pin data
|
||||
const eligibleNodes = identifyPinDataNodes(workflow, nodeTypes);
|
||||
if (eligibleNodes.length === 0) {
|
||||
logger?.verbose(' Pin data: no service nodes found, skipping');
|
||||
return {};
|
||||
}
|
||||
|
||||
logger?.verbose(` Pin data: generating for ${eligibleNodes.length} node(s)`);
|
||||
|
||||
// 2. Build schema contexts (with optional __schema__ enrichment)
|
||||
const contexts = buildSchemaContexts(eligibleNodes, nodesBasePath);
|
||||
const schemasFound = contexts.filter((c) => c.schema).length;
|
||||
if (schemasFound > 0) {
|
||||
logger?.verbose(` Pin data: found __schema__ for ${schemasFound}/${contexts.length} node(s)`);
|
||||
}
|
||||
|
||||
// 3. Build prompt and call LLM
|
||||
const userPrompt = buildUserPrompt(workflow, contexts);
|
||||
const expectedNodeNames = contexts.map((c) => c.nodeName);
|
||||
|
||||
try {
|
||||
const response = await llm.invoke([
|
||||
new SystemMessage(SYSTEM_PROMPT),
|
||||
new HumanMessage(userPrompt),
|
||||
]);
|
||||
|
||||
const responseText =
|
||||
typeof response.content === 'string' ? response.content : JSON.stringify(response.content);
|
||||
|
||||
const pinData = parsePinDataResponse(responseText, expectedNodeNames);
|
||||
|
||||
const generatedCount = Object.keys(pinData).length;
|
||||
logger?.verbose(
|
||||
` Pin data: generated for ${generatedCount}/${expectedNodeNames.length} node(s)`,
|
||||
);
|
||||
|
||||
return pinData;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger?.warn(` Pin data generation failed: ${message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Markdown Report Generator
|
||||
*
|
||||
* Generates human-readable markdown reports from evaluation results.
|
||||
*/
|
||||
|
||||
import { feedbackKey } from '../harness/feedback';
|
||||
import type { ExampleResult, RunSummary } from '../harness/harness-types';
|
||||
import {
|
||||
groupByEvaluator,
|
||||
selectScoringItems,
|
||||
calculateFiniteAverage,
|
||||
} from '../harness/score-calculator';
|
||||
|
||||
/**
|
||||
* Violation severity levels.
|
||||
*/
|
||||
export type ViolationSeverity = 'critical' | 'major' | 'minor';
|
||||
|
||||
/**
|
||||
* Options for report generation.
|
||||
*/
|
||||
export interface ReportOptions {
|
||||
/** Include detailed per-test results (default: false) */
|
||||
includeDetails?: boolean;
|
||||
/** Include violation breakdown (default: true) */
|
||||
includeViolations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics calculated from evaluation results.
|
||||
*/
|
||||
export interface ReportMetrics {
|
||||
/** Average score per evaluator */
|
||||
evaluatorAverages: Record<string, number>;
|
||||
/** Count of violations by severity */
|
||||
violationCounts: { critical: number; major: number; minor: number };
|
||||
}
|
||||
|
||||
/** Maximum prompt length before truncation */
|
||||
const MAX_PROMPT_LENGTH = 80;
|
||||
|
||||
/**
|
||||
* Extract violation severity from a feedback comment.
|
||||
*
|
||||
* Looks for markers like [CRITICAL], [MAJOR], [MINOR] in the comment.
|
||||
*
|
||||
* @param comment - The feedback comment to parse
|
||||
* @returns The violation severity or null if not found
|
||||
*/
|
||||
export function extractViolationSeverity(comment?: string): ViolationSeverity | null {
|
||||
if (!comment) return null;
|
||||
|
||||
const lowerComment = comment.toLowerCase();
|
||||
|
||||
if (lowerComment.includes('[critical]')) return 'critical';
|
||||
if (lowerComment.includes('[major]')) return 'major';
|
||||
if (lowerComment.includes('[minor]')) return 'minor';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as a percentage string.
|
||||
*/
|
||||
function formatPercentage(value: number, decimals = 1): string {
|
||||
if (!Number.isFinite(value)) return 'N/A';
|
||||
return `${(value * 100).toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length with ellipsis.
|
||||
*/
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate report metrics from evaluation results.
|
||||
*
|
||||
* @param results - Array of example results
|
||||
* @returns Calculated metrics including evaluator averages and violation counts
|
||||
*/
|
||||
export function calculateReportMetrics(results: ExampleResult[]): ReportMetrics {
|
||||
const okResults = results.filter((r) => r.status !== 'error');
|
||||
|
||||
// Calculate evaluator averages (per-example, then average; avoids key-count skew)
|
||||
const evaluatorScores: Record<string, number[]> = {};
|
||||
for (const result of okResults) {
|
||||
const grouped = groupByEvaluator(result.feedback);
|
||||
for (const [evaluator, items] of Object.entries(grouped)) {
|
||||
if (!evaluatorScores[evaluator]) evaluatorScores[evaluator] = [];
|
||||
evaluatorScores[evaluator].push(calculateFiniteAverage(selectScoringItems(items)));
|
||||
}
|
||||
}
|
||||
|
||||
const evaluatorAverages: Record<string, number> = {};
|
||||
for (const [evaluator, scores] of Object.entries(evaluatorScores)) {
|
||||
evaluatorAverages[evaluator] = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
||||
}
|
||||
|
||||
// Count violations by severity
|
||||
const violationCounts = { critical: 0, major: 0, minor: 0 };
|
||||
for (const result of okResults) {
|
||||
for (const feedback of result.feedback) {
|
||||
const severity = extractViolationSeverity(feedback.comment);
|
||||
if (severity) {
|
||||
violationCounts[severity]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
evaluatorAverages,
|
||||
violationCounts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a markdown report from evaluation results.
|
||||
*
|
||||
* @param results - Array of example results
|
||||
* @param summary - Run summary with totals
|
||||
* @param options - Report generation options
|
||||
* @returns Formatted markdown string
|
||||
*/
|
||||
export function generateMarkdownReport(
|
||||
results: ExampleResult[],
|
||||
summary: RunSummary,
|
||||
options: ReportOptions = {},
|
||||
): string {
|
||||
const { includeDetails = false, includeViolations = true } = options;
|
||||
|
||||
const metrics = calculateReportMetrics(results);
|
||||
const passRate = summary.totalExamples > 0 ? summary.passed / summary.totalExamples : 0;
|
||||
|
||||
let report = `# AI Workflow Builder Evaluation Report
|
||||
|
||||
## Summary
|
||||
- Total Tests: ${summary.totalExamples}
|
||||
- Passed: ${summary.passed} (${formatPercentage(passRate)})
|
||||
- Failed: ${summary.failed}
|
||||
- Errors: ${summary.errors}
|
||||
- Average Score: ${formatPercentage(summary.averageScore)}
|
||||
- Total Duration: ${(summary.totalDurationMs / 1000).toFixed(1)}s
|
||||
|
||||
`;
|
||||
|
||||
// Evaluator Averages
|
||||
if (Object.keys(metrics.evaluatorAverages).length > 0) {
|
||||
report += `## Evaluator Averages
|
||||
`;
|
||||
for (const [evaluator, avg] of Object.entries(metrics.evaluatorAverages)) {
|
||||
report += `- ${evaluator}: ${formatPercentage(avg)}
|
||||
`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// Violations Summary
|
||||
if (includeViolations) {
|
||||
const { critical, major, minor } = metrics.violationCounts;
|
||||
report += `## Violations Summary
|
||||
- Critical: ${critical}
|
||||
- Major: ${major}
|
||||
- Minor: ${minor}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// Detailed Results
|
||||
if (includeDetails && results.length > 0) {
|
||||
report += `## Detailed Results
|
||||
|
||||
`;
|
||||
for (const result of results) {
|
||||
const promptPreview = truncate(result.prompt, MAX_PROMPT_LENGTH);
|
||||
const resultScore = result.score;
|
||||
|
||||
report += `### Test ${result.index}: ${promptPreview}
|
||||
- **Status**: ${result.status}
|
||||
- **Score**: ${formatPercentage(resultScore)}
|
||||
- **Duration**: ${result.durationMs}ms
|
||||
`;
|
||||
|
||||
if (result.error) {
|
||||
report += `- **Error**: ${result.error}
|
||||
`;
|
||||
}
|
||||
|
||||
if (result.feedback.length > 0) {
|
||||
report += `- **Feedback**:
|
||||
`;
|
||||
for (const fb of result.feedback) {
|
||||
const scoreStr = formatPercentage(fb.score);
|
||||
const commentStr = fb.comment ? ` - ${fb.comment}` : '';
|
||||
report += ` - [${feedbackKey(fb)}] ${scoreStr}${commentStr}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
report += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Test Case Generator
|
||||
*
|
||||
* Generates test cases for workflow evaluation using LLM with structured output.
|
||||
* For default test cases, see fixtures/default-prompts.csv.
|
||||
*/
|
||||
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Options for test case generation.
|
||||
*/
|
||||
export interface TestCaseGeneratorOptions {
|
||||
/** Number of test cases to generate (default: 10) */
|
||||
count?: number;
|
||||
/** Focus areas for generation */
|
||||
focus?: string;
|
||||
/** Complexity distribution */
|
||||
complexity?: 'balanced' | 'simple' | 'complex';
|
||||
}
|
||||
|
||||
/**
|
||||
* A test case generated by the LLM.
|
||||
*/
|
||||
export interface GeneratedTestCase {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case generator interface.
|
||||
*/
|
||||
export interface TestCaseGenerator {
|
||||
/** Generate test cases */
|
||||
generate(): Promise<GeneratedTestCase[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for structured output.
|
||||
*/
|
||||
const generatedTestCasesSchema = z.object({
|
||||
testCases: z.array(
|
||||
z.object({
|
||||
id: z.string().describe('Unique identifier (e.g., "test_001")'),
|
||||
name: z.string().describe('Short descriptive title'),
|
||||
summary: z.string().describe('Brief description of what the workflow does'),
|
||||
prompt: z.string().describe('User-facing prompt for workflow generation'),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Inferred type from the Zod schema */
|
||||
type GeneratedTestCasesOutput = z.infer<typeof generatedTestCasesSchema>;
|
||||
|
||||
/** Parse and validate LLM output using the Zod schema */
|
||||
function parseTestCasesOutput(value: unknown): GeneratedTestCasesOutput {
|
||||
const parsed = generatedTestCasesSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid LLM output: ${parsed.error.message}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for test case generation.
|
||||
*/
|
||||
const systemPrompt = `You are an expert at generating diverse test cases for an n8n workflow builder AI. Create test cases that cover various real-world scenarios and complexity levels.
|
||||
|
||||
## Test Case Requirements:
|
||||
|
||||
1. **Simple Test Cases**: Single operation workflows
|
||||
- API calls
|
||||
- Data transformations
|
||||
- File operations
|
||||
- Basic integrations
|
||||
|
||||
2. **Medium Test Cases**: Multi-step workflows with logic
|
||||
- Conditional logic (IF nodes)
|
||||
- Data filtering and transformation
|
||||
- Multiple API integrations
|
||||
- Error handling
|
||||
|
||||
3. **Complex Test Cases**: Advanced workflows
|
||||
- Parallel execution branches
|
||||
- Complex error handling and retry logic
|
||||
- Multiple integrations with data synchronization
|
||||
- Webhooks and event-driven flows
|
||||
|
||||
## Guidelines:
|
||||
- Create realistic business scenarios
|
||||
- Include specific requirements that can be evaluated
|
||||
- Vary the domains (e-commerce, HR, marketing, DevOps, etc.)
|
||||
- Include both common and edge-case scenarios
|
||||
- Make prompts clear and unambiguous
|
||||
- Specify expected node types when possible
|
||||
|
||||
## Output Format:
|
||||
Each test case should have:
|
||||
- Unique ID (e.g., "test_001")
|
||||
- Descriptive name
|
||||
- Brief description
|
||||
- Clear prompt that a user would give`;
|
||||
|
||||
/**
|
||||
* Get default focus based on complexity option.
|
||||
*/
|
||||
function getFocus(options?: TestCaseGeneratorOptions): string {
|
||||
const complexity = options?.complexity ?? 'balanced';
|
||||
|
||||
if (options?.focus) {
|
||||
return options.focus;
|
||||
}
|
||||
|
||||
switch (complexity) {
|
||||
case 'simple':
|
||||
return 'simple, single-operation workflows like basic API calls, data transformations, and file operations';
|
||||
case 'complex':
|
||||
return 'complex, multi-step workflows with parallel execution, error handling, and multiple integrations';
|
||||
case 'balanced':
|
||||
default:
|
||||
return 'balanced mix of API integrations, data processing, and automation scenarios';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human message content.
|
||||
*/
|
||||
function buildHumanMessage(count: number, focus: string): string {
|
||||
return `Generate ${count} diverse test cases for workflow generation evaluation.
|
||||
|
||||
Focus on:
|
||||
${focus}
|
||||
|
||||
Ensure a good mix of complexity levels and use cases.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test case generator that uses LLM to generate test cases.
|
||||
*
|
||||
* @param llm - Language model to use for generation
|
||||
* @param options - Generation options
|
||||
* @returns A test case generator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const generator = createTestCaseGenerator(llm, { count: 20 });
|
||||
* const testCases = await generator.generate();
|
||||
* ```
|
||||
*/
|
||||
export function createTestCaseGenerator(
|
||||
llm: BaseChatModel,
|
||||
options?: TestCaseGeneratorOptions,
|
||||
): TestCaseGenerator {
|
||||
const count = options?.count ?? 10;
|
||||
const focus = getFocus(options);
|
||||
|
||||
// Create LLM with structured output
|
||||
const llmWithStructuredOutput = llm.withStructuredOutput(generatedTestCasesSchema);
|
||||
|
||||
return {
|
||||
async generate(): Promise<GeneratedTestCase[]> {
|
||||
const humanMessage = buildHumanMessage(count, focus);
|
||||
|
||||
const rawResult = await llmWithStructuredOutput.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(humanMessage),
|
||||
]);
|
||||
|
||||
// Validate and parse the LLM output using Zod
|
||||
const result = parseTestCasesOutput(rawResult);
|
||||
|
||||
return result.testCases;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { INode, INodeType, INodeTypes, IPinData } from 'n8n-workflow';
|
||||
|
||||
import { findRepoRoot } from './environment';
|
||||
import { executeWorkflowWithPinData, findTriggerByGroup } from './workflow-executor';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock('./environment', () => ({
|
||||
findRepoRoot: jest.fn(() => '/mock/repo/root'),
|
||||
}));
|
||||
|
||||
jest.mock('@n8n/di', () => ({
|
||||
Container: {
|
||||
set: jest.fn(),
|
||||
get: jest.fn(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSimpleWorkflow(nodes?: INode[]): SimpleWorkflow {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
nodes: nodes ?? [
|
||||
{
|
||||
name: 'Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
id: 'trigger-1',
|
||||
} as INode,
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('executeWorkflowWithPinData', () => {
|
||||
describe('error handling — no monorepo root', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(findRepoRoot).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('should return success=false when monorepo root cannot be found', async () => {
|
||||
const workflow = makeSimpleWorkflow();
|
||||
const pinData: IPinData = {};
|
||||
|
||||
const result = await executeWorkflowWithPinData(workflow, pinData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Cannot find monorepo root');
|
||||
expect(result.executedNodes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should always return a non-negative durationMs', async () => {
|
||||
const result = await executeWorkflowWithPinData(makeSimpleWorkflow(), {});
|
||||
|
||||
expect(typeof result.durationMs).toBe('number');
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExecutionResult shape', () => {
|
||||
it('should include success, durationMs, and executedNodes', () => {
|
||||
const result = {
|
||||
success: true,
|
||||
durationMs: 100,
|
||||
executedNodes: ['Trigger', 'Set'],
|
||||
};
|
||||
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('durationMs');
|
||||
expect(result).toHaveProperty('executedNodes');
|
||||
});
|
||||
|
||||
it('should support optional error and errorNode fields', () => {
|
||||
const result = {
|
||||
success: false,
|
||||
error: 'Something went wrong',
|
||||
errorNode: 'HTTP Request',
|
||||
durationMs: 50,
|
||||
executedNodes: ['Trigger'],
|
||||
};
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Something went wrong');
|
||||
expect(result.errorNode).toBe('HTTP Request');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findTriggerByGroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findTriggerByGroup', () => {
|
||||
function makeNodeTypes(types: Record<string, { group: string[] }>): INodeTypes {
|
||||
return {
|
||||
getByName: jest.fn(),
|
||||
getByNameAndVersion(type: string): INodeType {
|
||||
const entry = types[type] ?? { group: ['transform'] };
|
||||
return { description: { group: entry.group } } as unknown as INodeType;
|
||||
},
|
||||
getKnownTypes: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it('should find a chat trigger node by its group', () => {
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
id: '1',
|
||||
} as INode,
|
||||
{
|
||||
name: 'Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {},
|
||||
id: '2',
|
||||
} as INode,
|
||||
];
|
||||
|
||||
const nodeTypes = makeNodeTypes({
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger': { group: ['trigger'] },
|
||||
'@n8n/n8n-nodes-langchain.agent': { group: ['transform'] },
|
||||
});
|
||||
|
||||
const result = findTriggerByGroup(nodes, nodeTypes);
|
||||
expect(result?.name).toBe('Chat Trigger');
|
||||
});
|
||||
|
||||
it('should skip disabled trigger nodes', () => {
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
name: 'Disabled Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
id: '1',
|
||||
disabled: true,
|
||||
} as INode,
|
||||
];
|
||||
|
||||
const nodeTypes = makeNodeTypes({
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger': { group: ['trigger'] },
|
||||
});
|
||||
|
||||
const result = findTriggerByGroup(nodes, nodeTypes);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when no trigger nodes exist', () => {
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
id: '1',
|
||||
} as INode,
|
||||
];
|
||||
|
||||
const nodeTypes = makeNodeTypes({});
|
||||
|
||||
const result = findTriggerByGroup(nodes, nodeTypes);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Lightweight workflow executor for evaluations.
|
||||
*
|
||||
* Executes a SimpleWorkflow with pin data using the n8n execution engine.
|
||||
* Service/API nodes use pin data (skipping real API calls). Utility nodes
|
||||
* (Set, If, Code, etc.) actually execute using their compiled dist
|
||||
* implementations, validating the workflow structure end-to-end.
|
||||
*
|
||||
* Node implementations are loaded directly from the compiled dist/ files in
|
||||
* nodes-base and nodes-langchain, bypassing the DI-based loader infrastructure
|
||||
* and its TypeScript path-alias issues.
|
||||
*
|
||||
* Uses path-based resolution to import WorkflowExecute and
|
||||
* ExecutionLifecycleHooks from @n8n/core dist without adding it as a package
|
||||
* dependency, following the NodeTestHarness pattern.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
IPinData,
|
||||
IRun,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypes,
|
||||
IVersionedNodeType,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
} from 'n8n-workflow';
|
||||
import { createDeferredPromise, createRunExecutionData, NodeHelpers, Workflow } from 'n8n-workflow';
|
||||
import path from 'path';
|
||||
|
||||
import { findRepoRoot } from './environment';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** Node that caused the error, if identifiable */
|
||||
errorNode?: string;
|
||||
/** Total execution duration in ms */
|
||||
durationMs: number;
|
||||
/** Which nodes actually executed (in order) */
|
||||
executedNodes: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DistNodeTypes
|
||||
//
|
||||
// Implements INodeTypes by loading compiled node classes directly from the
|
||||
// dist/ directories of nodes-base and nodes-langchain packages.
|
||||
//
|
||||
// - Reads dist/known/nodes.json for the lazy-load index (no actual loading yet)
|
||||
// - On getByName/getByNameAndVersion: require()s the compiled .js file and
|
||||
// instantiates the class on demand
|
||||
// - Unknown types fall back to a passthrough; service nodes never reach
|
||||
// execute() because the engine short-circuits on pin data
|
||||
//
|
||||
// No DI container, no TypeScript source files, no path alias issues.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KnownNodeEntry {
|
||||
className: string;
|
||||
sourcePath: string;
|
||||
packageDir: string;
|
||||
}
|
||||
|
||||
class DistNodeTypes implements INodeTypes {
|
||||
private readonly knownNodes: Map<string, KnownNodeEntry>;
|
||||
|
||||
private readonly cache = new Map<string, INodeType | IVersionedNodeType>();
|
||||
|
||||
constructor(
|
||||
packages: Array<{
|
||||
packagePrefix: string;
|
||||
packageDir: string;
|
||||
knownNodes: Record<string, { className: string; sourcePath: string }>;
|
||||
}>,
|
||||
) {
|
||||
this.knownNodes = new Map();
|
||||
for (const { packagePrefix, packageDir, knownNodes } of packages) {
|
||||
for (const [shortName, info] of Object.entries(knownNodes)) {
|
||||
// Workflow nodes use the full type name: "n8n-nodes-base.set"
|
||||
// The known.nodes JSON uses just the short name: "set"
|
||||
this.knownNodes.set(`${packagePrefix}.${shortName}`, { ...info, packageDir });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getByName(type: string): INodeType | IVersionedNodeType {
|
||||
return this.loadNode(type);
|
||||
}
|
||||
|
||||
getByNameAndVersion(type: string, version?: number): INodeType {
|
||||
const loaded = this.loadNode(type);
|
||||
if ('nodeVersions' in loaded) {
|
||||
return NodeHelpers.getVersionedNodeType(loaded, version);
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
getKnownTypes() {
|
||||
return {};
|
||||
}
|
||||
|
||||
private loadNode(type: string): INodeType | IVersionedNodeType {
|
||||
const cached = this.cache.get(type);
|
||||
if (cached) return cached;
|
||||
|
||||
const known = this.knownNodes.get(type);
|
||||
if (!known) {
|
||||
// Unknown type — return a passthrough so the engine doesn't crash.
|
||||
return {
|
||||
description: {
|
||||
displayName: type,
|
||||
name: type,
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: '',
|
||||
defaults: { name: type },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
} as unknown as INodeTypeDescription,
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return [this.getInputData()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Load from compiled dist JS file — no path aliases, no DI
|
||||
const filePath = path.join(known.packageDir, known.sourcePath);
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(filePath) as Record<string, new () => INodeType | IVersionedNodeType>;
|
||||
const NodeClass = mod[known.className];
|
||||
const instance = new NodeClass();
|
||||
this.cache.set(type, instance);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedImports: ResolvedImports | undefined;
|
||||
|
||||
interface ResolvedImports {
|
||||
WorkflowExecute: new (
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: string,
|
||||
runExecutionData: unknown,
|
||||
) => { processRunExecutionData: (workflow: Workflow) => Promise<IRun> };
|
||||
ExecutionLifecycleHooks: new (
|
||||
mode: string,
|
||||
executionId: string,
|
||||
workflowData: unknown,
|
||||
) => {
|
||||
addHandler: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
nodeTypes: DistNodeTypes;
|
||||
}
|
||||
|
||||
function getPaths(): { corePath: string; nodesBasePath: string; langchainPath: string } {
|
||||
const repoRoot = findRepoRoot(__dirname);
|
||||
if (!repoRoot) {
|
||||
throw new Error('Cannot find monorepo root — workflow execution requires the n8n monorepo');
|
||||
}
|
||||
return {
|
||||
corePath: path.join(repoRoot, 'packages', 'core'),
|
||||
nodesBasePath: path.join(repoRoot, 'packages', 'nodes-base'),
|
||||
langchainPath: path.join(repoRoot, 'packages', '@n8n', 'nodes-langchain'),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImports(): Promise<ResolvedImports> {
|
||||
if (resolvedImports) return resolvedImports;
|
||||
|
||||
const { corePath, nodesBasePath, langchainPath } = getPaths();
|
||||
const distPath = path.join(corePath, 'dist', 'execution-engine');
|
||||
|
||||
// Import WorkflowExecute and ExecutionLifecycleHooks from compiled dist
|
||||
const weModule = (await import(path.join(distPath, 'workflow-execute.js'))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const hookModule = (await import(path.join(distPath, 'execution-lifecycle-hooks.js'))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Load the lazy-load index from each package's dist/known/nodes.json.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodesBaseKnown = require(path.join(nodesBasePath, 'dist', 'known', 'nodes.json')) as Record<
|
||||
string,
|
||||
{ className: string; sourcePath: string }
|
||||
>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const langchainKnown = require(path.join(langchainPath, 'dist', 'known', 'nodes.json')) as Record<
|
||||
string,
|
||||
{ className: string; sourcePath: string }
|
||||
>;
|
||||
|
||||
const nodeTypes = new DistNodeTypes([
|
||||
{
|
||||
packagePrefix: 'n8n-nodes-base',
|
||||
packageDir: nodesBasePath,
|
||||
knownNodes: nodesBaseKnown,
|
||||
},
|
||||
{
|
||||
packagePrefix: '@n8n/n8n-nodes-langchain',
|
||||
packageDir: langchainPath,
|
||||
knownNodes: langchainKnown,
|
||||
},
|
||||
]);
|
||||
|
||||
resolvedImports = {
|
||||
WorkflowExecute: weModule.WorkflowExecute as ResolvedImports['WorkflowExecute'],
|
||||
ExecutionLifecycleHooks:
|
||||
hookModule.ExecutionLifecycleHooks as ResolvedImports['ExecutionLifecycleHooks'],
|
||||
nodeTypes,
|
||||
};
|
||||
|
||||
return resolvedImports;
|
||||
}
|
||||
|
||||
/*
|
||||
* Proxy-based additionalData stub
|
||||
*
|
||||
* WorkflowExecute calls various methods on additionalData at runtime.
|
||||
* we use a Proxy that returns a no-op function for any property access and
|
||||
* allows specific values to be injected via the `overrides` map.
|
||||
*/
|
||||
function makeAdditionalDataStub(
|
||||
overrides: Record<string, unknown>,
|
||||
): IWorkflowExecuteAdditionalData {
|
||||
return new Proxy({} as IWorkflowExecuteAdditionalData, {
|
||||
get(_target, prop: string) {
|
||||
if (prop in overrides) return overrides[prop];
|
||||
return () => undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start node detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fallback start-node finder for trigger nodes whose type description
|
||||
* includes 'trigger' in its group (e.g. ChatTrigger, which is webhook-based
|
||||
* and not recognised by Workflow.getStartNode()).
|
||||
*/
|
||||
export function findTriggerByGroup(nodes: INode[], nodeTypes: INodeTypes): INode | undefined {
|
||||
return nodes.find((currentNode) => {
|
||||
if (currentNode.disabled) return false;
|
||||
const nt = nodeTypes.getByNameAndVersion(currentNode.type, currentNode.typeVersion);
|
||||
return nt.description.group?.includes('trigger');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main execution function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Execute a workflow with pin data and return the result.
|
||||
*
|
||||
* Service/API nodes use pin data (skipping real API calls). Utility nodes
|
||||
* (Set, If, Code, etc.) execute normally using their compiled dist
|
||||
* implementations
|
||||
*
|
||||
* @param workflow - The workflow to execute
|
||||
* @param pinData - Pin data for service/API nodes
|
||||
*/
|
||||
export async function executeWorkflowWithPinData(
|
||||
workflow: SimpleWorkflow,
|
||||
pinData: IPinData,
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const executedNodes: string[] = [];
|
||||
|
||||
try {
|
||||
const imports = await resolveImports();
|
||||
|
||||
// Create Workflow instance with real node implementations
|
||||
const workflowInstance = new Workflow({
|
||||
id: 'eval-execution',
|
||||
nodes: workflow.nodes,
|
||||
connections: workflow.connections,
|
||||
nodeTypes: imports.nodeTypes,
|
||||
active: false,
|
||||
});
|
||||
|
||||
// Find start node. getStartNode() only recognises nodes with a trigger()/poll()
|
||||
// method or those in STARTING_NODE_TYPES. Webhook-based triggers like ChatTrigger
|
||||
// are missed, so fall back to any node whose description group includes 'trigger'.
|
||||
const startNode = workflowInstance.getStartNode() ?? findTriggerByGroup(workflow.nodes, imports.nodeTypes);
|
||||
if (!startNode) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No start node found in workflow',
|
||||
durationMs: Date.now() - startTime,
|
||||
executedNodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Set up execution lifecycle hooks
|
||||
const hooks = new imports.ExecutionLifecycleHooks('trigger', '1', {} as never);
|
||||
hooks.addHandler('nodeExecuteAfter', (nodeName: unknown) => {
|
||||
if (typeof nodeName === 'string') {
|
||||
executedNodes.push(nodeName);
|
||||
}
|
||||
});
|
||||
|
||||
const waitPromise = createDeferredPromise<IRun>();
|
||||
hooks.addHandler('workflowExecuteAfter', (fullRunData: unknown) => {
|
||||
waitPromise.resolve(fullRunData as IRun);
|
||||
});
|
||||
|
||||
// Set up additional data with a Proxy stub so WorkflowExecute
|
||||
const additionalData = makeAdditionalDataStub({ executionId: '1', hooks });
|
||||
|
||||
const runExecutionData = createRunExecutionData({
|
||||
executionData: {
|
||||
nodeExecutionStack: [
|
||||
{
|
||||
node: startNode,
|
||||
data: { main: [[{ json: {} }]] },
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
resultData: {
|
||||
pinData,
|
||||
},
|
||||
});
|
||||
|
||||
const workflowExecute = new imports.WorkflowExecute(additionalData, 'manual', runExecutionData);
|
||||
await workflowExecute.processRunExecutionData(workflowInstance);
|
||||
|
||||
const result = await waitPromise.promise;
|
||||
|
||||
// Check result for errors
|
||||
const resultError = result.data?.resultData?.error;
|
||||
const hasError = !!resultError;
|
||||
|
||||
return {
|
||||
success: !hasError && result.status === 'success',
|
||||
error: hasError ? ((resultError as Error)?.message ?? resultError) : undefined,
|
||||
errorNode: hasError ? findErrorNode(result) : undefined,
|
||||
durationMs: Date.now() - startTime,
|
||||
executedNodes,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startTime,
|
||||
executedNodes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the node that caused an execution error from run data.
|
||||
*/
|
||||
function findErrorNode(run: IRun): string | undefined {
|
||||
const { runData } = run.data.resultData;
|
||||
for (const [nodeName, nodeRuns] of Object.entries(runData)) {
|
||||
for (const nodeRun of nodeRuns) {
|
||||
if (nodeRun.error) {
|
||||
return nodeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user