Files
n8n-mcp/tests/unit/parsers/node-parser-outputs.test.ts
czlonkowski f3164e202f feat: add TypeScript type safety with strategic any assertions (v2.17.5)
Added comprehensive TypeScript type definitions for n8n node parsing while
maintaining zero compilation errors. Uses pragmatic "70% benefit with 0%
breakage" approach with strategic `any` assertions.

## Type Definitions (src/types/node-types.ts)
- NodeClass union type replaces `any` in method signatures
- Type guards: isVersionedNodeInstance(), isVersionedNodeClass()
- Utility functions for safe node handling

## Parser Updates
- node-parser.ts: All methods use NodeClass (15+ methods)
- simple-parser.ts: Strongly typed method signatures
- property-extractor.ts: Typed extraction methods
- 30+ method signatures improved

## Strategic Pattern
- Strong types in public method signatures (caller type safety)
- Strategic `as any` assertions for internal union type access
- Pattern: const desc = description as any; // Access union properties

## Benefits
- Better IDE support and auto-complete
- Compile-time safety at call sites
- Type-based documentation
- Zero compilation errors
- Bug prevention (would have caught v2.17.4 baseDescription issue)

## Test Updates
- All test files updated with `as any` for mock objects
- Zero compilation errors maintained

## Known Limitations
- ~70% type coverage (signatures typed, internal logic uses assertions)
- Union types (INodeTypeBaseDescription vs INodeTypeDescription) not fully resolved
- Future work: Conditional types or overloads for 100% type safety

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 22:16:59 +02:00

473 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NodeParser } from '@/parsers/node-parser';
import { PropertyExtractor } from '@/parsers/property-extractor';
// Mock PropertyExtractor
vi.mock('@/parsers/property-extractor');
describe('NodeParser - Output Extraction', () => {
let parser: NodeParser;
let mockPropertyExtractor: any;
beforeEach(() => {
vi.clearAllMocks();
mockPropertyExtractor = {
extractProperties: vi.fn().mockReturnValue([]),
extractCredentials: vi.fn().mockReturnValue([]),
detectAIToolCapability: vi.fn().mockReturnValue(false),
extractOperations: vi.fn().mockReturnValue([])
};
(PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor);
parser = new NodeParser();
});
describe('extractOutputs method', () => {
it('should extract outputs array from base description', () => {
const outputs = [
{ displayName: 'Done', description: 'Final results when loop completes' },
{ displayName: 'Loop', description: 'Current batch data during iteration' }
];
const nodeDescription = {
name: 'splitInBatches',
displayName: 'Split In Batches',
outputs
};
const NodeClass = class {
description = nodeDescription;
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(outputs);
expect(result.outputNames).toBeUndefined();
});
it('should extract outputNames array from base description', () => {
const outputNames = ['done', 'loop'];
const nodeDescription = {
name: 'splitInBatches',
displayName: 'Split In Batches',
outputNames
};
const NodeClass = class {
description = nodeDescription;
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputNames).toEqual(outputNames);
expect(result.outputs).toBeUndefined();
});
it('should extract both outputs and outputNames when both are present', () => {
const outputs = [
{ displayName: 'Done', description: 'Final results when loop completes' },
{ displayName: 'Loop', description: 'Current batch data during iteration' }
];
const outputNames = ['done', 'loop'];
const nodeDescription = {
name: 'splitInBatches',
displayName: 'Split In Batches',
outputs,
outputNames
};
const NodeClass = class {
description = nodeDescription;
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(outputs);
expect(result.outputNames).toEqual(outputNames);
});
it('should convert single output to array format', () => {
const singleOutput = { displayName: 'Output', description: 'Single output' };
const nodeDescription = {
name: 'singleOutputNode',
displayName: 'Single Output Node',
outputs: singleOutput
};
const NodeClass = class {
description = nodeDescription;
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual([singleOutput]);
});
it('should convert single outputName to array format', () => {
const nodeDescription = {
name: 'singleOutputNode',
displayName: 'Single Output Node',
outputNames: 'main'
};
const NodeClass = class {
description = nodeDescription;
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputNames).toEqual(['main']);
});
it('should extract outputs from versioned node when not in base description', () => {
const versionedOutputs = [
{ displayName: 'True', description: 'Items that match condition' },
{ displayName: 'False', description: 'Items that do not match condition' }
];
const NodeClass = class {
description = {
name: 'if',
displayName: 'IF'
// No outputs in base description
};
nodeVersions = {
1: {
description: {
outputs: versionedOutputs
}
},
2: {
description: {
outputs: versionedOutputs,
outputNames: ['true', 'false']
}
}
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
// Should get outputs from latest version (2)
expect(result.outputs).toEqual(versionedOutputs);
expect(result.outputNames).toEqual(['true', 'false']);
});
it('should handle node instantiation failure gracefully', () => {
const NodeClass = class {
// Static description that can be accessed when instantiation fails
static description = {
name: 'problematic',
displayName: 'Problematic Node'
};
constructor() {
throw new Error('Cannot instantiate');
}
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toBeUndefined();
expect(result.outputNames).toBeUndefined();
});
it('should return empty result when no outputs found anywhere', () => {
const nodeDescription = {
name: 'noOutputs',
displayName: 'No Outputs Node'
// No outputs or outputNames
};
const NodeClass = class {
description = nodeDescription;
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toBeUndefined();
expect(result.outputNames).toBeUndefined();
});
it('should handle complex versioned node structure', () => {
const NodeClass = class VersionedNodeType {
baseDescription = {
name: 'complexVersioned',
displayName: 'Complex Versioned Node',
defaultVersion: 3
};
nodeVersions = {
1: {
description: {
outputs: [{ displayName: 'V1 Output' }]
}
},
2: {
description: {
outputs: [
{ displayName: 'V2 Output 1' },
{ displayName: 'V2 Output 2' }
]
}
},
3: {
description: {
outputs: [
{ displayName: 'V3 True', description: 'True branch' },
{ displayName: 'V3 False', description: 'False branch' }
],
outputNames: ['true', 'false']
}
}
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
// Should use latest version (3)
expect(result.outputs).toEqual([
{ displayName: 'V3 True', description: 'True branch' },
{ displayName: 'V3 False', description: 'False branch' }
]);
expect(result.outputNames).toEqual(['true', 'false']);
});
it('should prefer base description outputs over versioned when both exist', () => {
const baseOutputs = [{ displayName: 'Base Output' }];
const versionedOutputs = [{ displayName: 'Versioned Output' }];
const NodeClass = class {
description = {
name: 'preferBase',
displayName: 'Prefer Base',
outputs: baseOutputs
};
nodeVersions = {
1: {
description: {
outputs: versionedOutputs
}
}
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(baseOutputs);
});
it('should handle IF node with typical output structure', () => {
const ifOutputs = [
{ displayName: 'True', description: 'Items that match the condition' },
{ displayName: 'False', description: 'Items that do not match the condition' }
];
const NodeClass = class {
description = {
name: 'if',
displayName: 'IF',
outputs: ifOutputs,
outputNames: ['true', 'false']
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(ifOutputs);
expect(result.outputNames).toEqual(['true', 'false']);
});
it('should handle SplitInBatches node with counterintuitive output structure', () => {
const splitInBatchesOutputs = [
{ displayName: 'Done', description: 'Final results when loop completes' },
{ displayName: 'Loop', description: 'Current batch data during iteration' }
];
const NodeClass = class {
description = {
name: 'splitInBatches',
displayName: 'Split In Batches',
outputs: splitInBatchesOutputs,
outputNames: ['done', 'loop']
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(splitInBatchesOutputs);
expect(result.outputNames).toEqual(['done', 'loop']);
// Verify the counterintuitive order: done=0, loop=1
expect(result.outputs).toBeDefined();
expect(result.outputNames).toBeDefined();
expect(result.outputs![0].displayName).toBe('Done');
expect(result.outputs![1].displayName).toBe('Loop');
expect(result.outputNames![0]).toBe('done');
expect(result.outputNames![1]).toBe('loop');
});
it('should handle Switch node with multiple outputs', () => {
const switchOutputs = [
{ displayName: 'Output 1', description: 'First branch' },
{ displayName: 'Output 2', description: 'Second branch' },
{ displayName: 'Output 3', description: 'Third branch' },
{ displayName: 'Fallback', description: 'Default branch when no conditions match' }
];
const NodeClass = class {
description = {
name: 'switch',
displayName: 'Switch',
outputs: switchOutputs,
outputNames: ['0', '1', '2', 'fallback']
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(switchOutputs);
expect(result.outputNames).toEqual(['0', '1', '2', 'fallback']);
});
it('should handle empty outputs array', () => {
const NodeClass = class {
description = {
name: 'emptyOutputs',
displayName: 'Empty Outputs',
outputs: [],
outputNames: []
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual([]);
expect(result.outputNames).toEqual([]);
});
it('should handle mismatched outputs and outputNames arrays', () => {
const outputs = [
{ displayName: 'Output 1' },
{ displayName: 'Output 2' }
];
const outputNames = ['first', 'second', 'third']; // One extra
const NodeClass = class {
description = {
name: 'mismatched',
displayName: 'Mismatched Arrays',
outputs,
outputNames
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toEqual(outputs);
expect(result.outputNames).toEqual(outputNames);
});
});
describe('real-world node structures', () => {
it('should handle actual n8n SplitInBatches node structure', () => {
// This mimics the actual structure from n8n-nodes-base
const NodeClass = class {
description = {
name: 'splitInBatches',
displayName: 'Split In Batches',
description: 'Split data into batches and iterate over each batch',
icon: 'fa:th-large',
group: ['transform'],
version: 3,
outputs: [
{
displayName: 'Done',
name: 'done',
type: 'main',
hint: 'Receives the final data after all batches have been processed'
},
{
displayName: 'Loop',
name: 'loop',
type: 'main',
hint: 'Receives the current batch data during each iteration'
}
],
outputNames: ['done', 'loop']
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toHaveLength(2);
expect(result.outputs).toBeDefined();
expect(result.outputs![0].displayName).toBe('Done');
expect(result.outputs![1].displayName).toBe('Loop');
expect(result.outputNames).toEqual(['done', 'loop']);
});
it('should handle actual n8n IF node structure', () => {
// This mimics the actual structure from n8n-nodes-base
const NodeClass = class {
description = {
name: 'if',
displayName: 'IF',
description: 'Route items to different outputs based on conditions',
icon: 'fa:map-signs',
group: ['transform'],
version: 2,
outputs: [
{
displayName: 'True',
name: 'true',
type: 'main',
hint: 'Items that match the condition'
},
{
displayName: 'False',
name: 'false',
type: 'main',
hint: 'Items that do not match the condition'
}
],
outputNames: ['true', 'false']
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toHaveLength(2);
expect(result.outputs).toBeDefined();
expect(result.outputs![0].displayName).toBe('True');
expect(result.outputs![1].displayName).toBe('False');
expect(result.outputNames).toEqual(['true', 'false']);
});
it('should handle single-output nodes like HTTP Request', () => {
const NodeClass = class {
description = {
name: 'httpRequest',
displayName: 'HTTP Request',
description: 'Make HTTP requests',
icon: 'fa:at',
group: ['input'],
version: 4
// No outputs specified - single main output implied
};
};
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
expect(result.outputs).toBeUndefined();
expect(result.outputNames).toBeUndefined();
});
});
});