mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 12:31:57 +02:00
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import * as fs from 'fs';
|
|
import { deepCopy } from 'n8n-workflow';
|
|
import * as path from 'path';
|
|
|
|
import {
|
|
ensureFixtures,
|
|
FixtureDownloadError,
|
|
DOWNLOADED_FIXTURES_DIR,
|
|
COMMITTED_FIXTURES_DIR,
|
|
} from './__tests__/fixtures-download';
|
|
import { generateWorkflowCode } from './codegen';
|
|
import type { WorkflowJSON } from './types/base';
|
|
import { foldLegacyErrorConnections, normalizeConnections } from './types/base';
|
|
import { workflow } from './workflow-builder';
|
|
|
|
/**
|
|
* Writes a .generated.ts.txt file next to the original JSON fixture.
|
|
* These files are gitignored and provide a TypeScript SDK representation
|
|
* of each workflow for inspection/debugging. The .txt suffix keeps Vitest's
|
|
* Rolldown-based loader from ever attempting to parse them as source —
|
|
* the generated code can include placeholder/unfinished constructs while
|
|
* tests run in parallel.
|
|
*/
|
|
function writeGeneratedTsFile(id: string, json: WorkflowJSON): void {
|
|
try {
|
|
const code = generateWorkflowCode(json);
|
|
const generatedPath = path.join(DOWNLOADED_FIXTURES_DIR, `${id}.generated.ts.txt`);
|
|
fs.writeFileSync(generatedPath, code, 'utf-8');
|
|
} catch {
|
|
// Don't fail the test if code generation fails
|
|
}
|
|
}
|
|
|
|
interface TestWorkflow {
|
|
id: string;
|
|
name: string;
|
|
json: WorkflowJSON;
|
|
nodeCount: number;
|
|
}
|
|
|
|
function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void {
|
|
const manifestPath = path.join(dir, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) {
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse -- Manifest is controlled fixture file
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as {
|
|
workflows: Array<{
|
|
id: string | number;
|
|
name: string;
|
|
success: boolean;
|
|
skip?: boolean;
|
|
skipReason?: string;
|
|
}>;
|
|
};
|
|
|
|
for (const entry of manifest.workflows) {
|
|
if (!entry.success) continue;
|
|
if (entry.skip) continue;
|
|
|
|
const filePath = path.join(dir, `${entry.id}.json`);
|
|
if (fs.existsSync(filePath)) {
|
|
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse -- Test fixture file
|
|
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as WorkflowJSON;
|
|
workflows.push({
|
|
id: String(entry.id),
|
|
name: entry.name,
|
|
json,
|
|
nodeCount: json.nodes?.length ?? 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadTestWorkflows(): TestWorkflow[] {
|
|
const workflows: TestWorkflow[] = [];
|
|
|
|
// Load committed workflows first (always available)
|
|
loadWorkflowsFromDir(COMMITTED_FIXTURES_DIR, workflows);
|
|
|
|
// Load downloaded workflows
|
|
loadWorkflowsFromDir(DOWNLOADED_FIXTURES_DIR, workflows);
|
|
|
|
return workflows;
|
|
}
|
|
|
|
// Load workflows at file parse time for individual test generation
|
|
const workflows = loadTestWorkflows();
|
|
|
|
describe('Real Workflow Round-Trip', () => {
|
|
// Download fixtures if needed. Unpacking ~2000 workflow files is IO-bound and
|
|
// can take well over the default 10s hook timeout on a contended CI runner, so
|
|
// give it a generous budget to avoid flaky "Hook timed out" failures.
|
|
beforeAll(() => {
|
|
try {
|
|
ensureFixtures();
|
|
} catch (error) {
|
|
if (error instanceof FixtureDownloadError) {
|
|
throw new Error(
|
|
`Failed to download test fixtures from n8n.io API: ${error.message}. ` +
|
|
'Check your network connection and ensure the API is accessible.',
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}, 60_000);
|
|
|
|
if (workflows.length === 0) {
|
|
it('should have fixtures available (run tests again after download)', () => {
|
|
expect(workflows.length).toBeGreaterThan(0);
|
|
});
|
|
} else {
|
|
// Helper function for filtering empty connections and normalizing null slots to []
|
|
const filterEmptyConnections = (conns: Record<string, unknown>) => {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [nodeName, nodeConns] of Object.entries(conns)) {
|
|
const nonEmptyTypes: Record<string, unknown> = {};
|
|
for (const [connType, outputs] of Object.entries(nodeConns as Record<string, unknown[]>)) {
|
|
// Normalize null slots to [] for consistent comparison
|
|
const normalized = (outputs ?? []).map((slot: unknown) => (slot === null ? [] : slot));
|
|
const nonEmptyOutputs = normalized.filter(
|
|
(arr: unknown) => Array.isArray(arr) && arr.length > 0,
|
|
);
|
|
if (nonEmptyOutputs.length > 0) {
|
|
nonEmptyTypes[connType] = normalized;
|
|
}
|
|
}
|
|
if (Object.keys(nonEmptyTypes).length > 0) {
|
|
result[nodeName] = nonEmptyTypes;
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
describe('fromJSON().toJSON() round-trip', () => {
|
|
workflows.forEach(({ id, name, json, nodeCount }) => {
|
|
it(`should round-trip workflow ${id}: "${name}" (${nodeCount} nodes)`, () => {
|
|
const wf = workflow.fromJSON(json);
|
|
const exported = wf.toJSON();
|
|
|
|
writeGeneratedTsFile(id, json);
|
|
|
|
expect(exported.nodes.length).toBe(json.nodes.length);
|
|
|
|
// Use greedy matching to handle workflows with duplicate node names
|
|
const matchedIndices = new Set<number>();
|
|
for (const originalNode of json.nodes) {
|
|
const exportedNode = exported.nodes.find(
|
|
(n, i) => !matchedIndices.has(i) && n.name === originalNode.name,
|
|
);
|
|
expect(exportedNode).toBeDefined();
|
|
|
|
if (exportedNode) {
|
|
matchedIndices.add(exported.nodes.indexOf(exportedNode));
|
|
expect(exportedNode.type).toBe(originalNode.type);
|
|
expect(exportedNode.name).toBe(originalNode.name);
|
|
expect(exportedNode.position).toEqual(originalNode.position);
|
|
// SDK defaults undefined typeVersion to 1
|
|
// Compare as numbers since string typeVersions are normalized to numbers
|
|
if (originalNode.typeVersion !== undefined) {
|
|
expect(exportedNode.typeVersion).toBe(Number(originalNode.typeVersion));
|
|
}
|
|
expect(exportedNode.parameters).toEqual(originalNode.parameters);
|
|
|
|
if (originalNode.credentials) {
|
|
expect(exportedNode.credentials).toEqual(originalNode.credentials);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normalize original connections (clone first to avoid mutating input)
|
|
// since the original JSON may have flat tuple connections. Also fold
|
|
// legacy top-level `error` keys into the modern main[last] shape that
|
|
// the SDK now always emits — older templates are kept untouched, so
|
|
// the comparison itself handles the old/new equivalence.
|
|
const normalizedOriginalConns = deepCopy(json.connections);
|
|
normalizeConnections(normalizedOriginalConns);
|
|
foldLegacyErrorConnections(normalizedOriginalConns, json.nodes);
|
|
const filteredOriginal = filterEmptyConnections(normalizedOriginalConns);
|
|
const filteredExported = filterEmptyConnections(exported.connections);
|
|
|
|
const nodeNames = new Set(json.nodes.map((n) => n.name));
|
|
const orphanedConnectionKeys = Object.keys(json.connections).filter(
|
|
(key) => !nodeNames.has(key),
|
|
);
|
|
|
|
if (orphanedConnectionKeys.length === 0) {
|
|
expect(Object.keys(filteredExported).sort()).toEqual(
|
|
Object.keys(filteredOriginal).sort(),
|
|
);
|
|
|
|
for (const nodeName of Object.keys(filteredOriginal)) {
|
|
expect(filteredExported[nodeName]).toEqual(filteredOriginal[nodeName]);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('node retrieval', () => {
|
|
workflows.forEach(({ id, name, json }) => {
|
|
it(`should retrieve all nodes by name in workflow ${id}: "${name}"`, () => {
|
|
const wf = workflow.fromJSON(json);
|
|
|
|
for (const node of json.nodes) {
|
|
// Skip nodes without names (e.g., some sticky notes)
|
|
if (!node.name) continue;
|
|
const retrieved = wf.getNode(node.name);
|
|
expect(retrieved).toBeDefined();
|
|
if (retrieved) {
|
|
expect(retrieved.config.name).toBe(node.name);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('settings preservation', () => {
|
|
const workflowsWithSettings = workflows.filter((w) => w.json.settings);
|
|
workflowsWithSettings.forEach(({ id, name, json }) => {
|
|
it(`should preserve settings in workflow ${id}: "${name}"`, () => {
|
|
const wf = workflow.fromJSON(json);
|
|
const exported = wf.toJSON();
|
|
expect(exported.settings).toEqual(json.settings);
|
|
});
|
|
});
|
|
|
|
if (workflowsWithSettings.length === 0) {
|
|
it('should have no workflows with settings', () => {
|
|
expect(workflowsWithSettings.length).toBe(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('pinData preservation', () => {
|
|
const workflowsWithPinData = workflows.filter(
|
|
(w) => w.json.pinData && Object.keys(w.json.pinData).length > 0,
|
|
);
|
|
workflowsWithPinData.forEach(({ id, name, json }) => {
|
|
it(`should preserve pinData in workflow ${id}: "${name}"`, () => {
|
|
const wf = workflow.fromJSON(json);
|
|
const exported = wf.toJSON();
|
|
expect(exported.pinData).toEqual(json.pinData);
|
|
});
|
|
});
|
|
|
|
if (workflowsWithPinData.length === 0) {
|
|
it('should have no workflows with pinData', () => {
|
|
expect(workflowsWithPinData.length).toBe(0);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('Real Workflow Patterns', () => {
|
|
// Generous timeout: fixture extraction is IO-bound and can exceed the default
|
|
// 10s hook timeout on a contended CI runner.
|
|
beforeAll(() => {
|
|
ensureFixtures();
|
|
}, 60_000);
|
|
|
|
it('should handle AI agent workflows with subnodes', () => {
|
|
expect(workflows.length).toBeGreaterThan(0);
|
|
const aiWorkflow = workflows.find((w) => w.id === '1954');
|
|
if (!aiWorkflow) {
|
|
return;
|
|
}
|
|
|
|
const wf = workflow.fromJSON(aiWorkflow.json);
|
|
const exported = wf.toJSON();
|
|
|
|
const connectionTypes = new Set<string>();
|
|
for (const nodeConnections of Object.values(exported.connections)) {
|
|
for (const connType of Object.keys(nodeConnections)) {
|
|
connectionTypes.add(connType);
|
|
}
|
|
}
|
|
|
|
expect(connectionTypes.has('main') || connectionTypes.size > 0).toBe(true);
|
|
|
|
const hasAiConnections =
|
|
connectionTypes.has('ai_tool') ||
|
|
connectionTypes.has('ai_memory') ||
|
|
connectionTypes.has('ai_languageModel');
|
|
|
|
if (aiWorkflow.json.connections) {
|
|
const originalTypes = new Set<string>();
|
|
for (const nodeConnections of Object.values(aiWorkflow.json.connections)) {
|
|
for (const connType of Object.keys(nodeConnections as object)) {
|
|
originalTypes.add(connType);
|
|
}
|
|
}
|
|
|
|
if (
|
|
originalTypes.has('ai_tool') ||
|
|
originalTypes.has('ai_memory') ||
|
|
originalTypes.has('ai_languageModel')
|
|
) {
|
|
expect(hasAiConnections).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should handle multi-node workflows', () => {
|
|
const complexWorkflow = workflows.find((w) => w.nodeCount >= 10);
|
|
if (!complexWorkflow) {
|
|
return;
|
|
}
|
|
|
|
const wf = workflow.fromJSON(complexWorkflow.json);
|
|
const exported = wf.toJSON();
|
|
|
|
expect(exported.nodes.length).toBe(complexWorkflow.nodeCount);
|
|
});
|
|
|
|
it('should handle workflows with credentials', () => {
|
|
const workflowWithCreds = workflows.find((w) =>
|
|
w.json.nodes.some((n) => n.credentials && Object.keys(n.credentials).length > 0),
|
|
);
|
|
|
|
if (!workflowWithCreds) {
|
|
return;
|
|
}
|
|
|
|
const wf = workflow.fromJSON(workflowWithCreds.json);
|
|
const exported = wf.toJSON();
|
|
|
|
for (const originalNode of workflowWithCreds.json.nodes) {
|
|
if (originalNode.credentials && Object.keys(originalNode.credentials).length > 0) {
|
|
const exportedNode = exported.nodes.find((n) => n.id === originalNode.id);
|
|
expect(exportedNode?.credentials).toEqual(originalNode.credentials);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Expression Preservation', () => {
|
|
// Generous timeout: fixture extraction is IO-bound and can exceed the default
|
|
// 10s hook timeout on a contended CI runner.
|
|
beforeAll(() => {
|
|
ensureFixtures();
|
|
}, 60_000);
|
|
|
|
function extractExpressions(json: WorkflowJSON): string[] {
|
|
const expressions: string[] = [];
|
|
|
|
function findExpressions(obj: unknown): void {
|
|
if (typeof obj === 'string' && obj.startsWith('={{')) {
|
|
expressions.push(obj);
|
|
} else if (Array.isArray(obj)) {
|
|
obj.forEach(findExpressions);
|
|
} else if (obj && typeof obj === 'object') {
|
|
Object.values(obj).forEach(findExpressions);
|
|
}
|
|
}
|
|
|
|
for (const node of json.nodes) {
|
|
findExpressions(node.parameters);
|
|
}
|
|
|
|
return expressions.sort();
|
|
}
|
|
|
|
workflows.forEach(({ id, name, json }) => {
|
|
const expressions = extractExpressions(json);
|
|
if (expressions.length === 0) return;
|
|
|
|
it(`should preserve expressions in workflow ${id}: "${name}"`, () => {
|
|
const wf = workflow.fromJSON(json);
|
|
const exported = wf.toJSON();
|
|
const exportedExpressions = extractExpressions(exported);
|
|
|
|
expect(exportedExpressions).toEqual(expressions);
|
|
});
|
|
});
|
|
});
|