n8n/packages/@n8n/workflow-sdk/src/real-workflow.test.ts
Matsu d605a7b848
test(core): Extend fixture extraction hook timeout to avoid flake (no-changelog) (#32313)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:55:59 +00:00

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);
});
});
});