mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Charlie Kolb <charlie@n8n.io>
1018 lines
30 KiB
TypeScript
1018 lines
30 KiB
TypeScript
import { createTestNode, createTestNodeProperties } from '@/__tests__/mocks';
|
|
import type { INodeUi } from '@/Interface';
|
|
import type { INodeTypeDescription } from 'n8n-workflow';
|
|
|
|
import {
|
|
getNodeCredentialTypes,
|
|
getNodeParametersIssues,
|
|
groupCredentialsByType,
|
|
isCredentialCardComplete,
|
|
buildTriggerSetupState,
|
|
type CompletionContext,
|
|
} from '@/features/setupPanel/setupPanel.utils';
|
|
import type { CredentialTypeSetupState } from '@/features/setupPanel/setupPanel.types';
|
|
|
|
const mockGetNodeTypeDisplayableCredentials = vi.fn().mockReturnValue([]);
|
|
|
|
vi.mock('@/app/utils/nodes/nodeTransforms', () => ({
|
|
getNodeTypeDisplayableCredentials: (...args: unknown[]) =>
|
|
mockGetNodeTypeDisplayableCredentials(...args),
|
|
}));
|
|
|
|
const createNode = (overrides: Partial<INodeUi> = {}): INodeUi =>
|
|
createTestNode({
|
|
name: 'TestNode',
|
|
type: 'n8n-nodes-base.testNode',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
...overrides,
|
|
}) as INodeUi;
|
|
|
|
const mockNodeTypeProvider = { getNodeType: vi.fn() };
|
|
|
|
describe('setupPanel.utils', () => {
|
|
beforeEach(() => {
|
|
mockGetNodeTypeDisplayableCredentials.mockReset().mockReturnValue([]);
|
|
});
|
|
|
|
describe('getNodeCredentialTypes', () => {
|
|
it('should return credential types from displayable credentials', () => {
|
|
const node = createNode();
|
|
mockGetNodeTypeDisplayableCredentials.mockReturnValue([
|
|
{ name: 'openAiApi' },
|
|
{ name: 'slackApi' },
|
|
]);
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toEqual(['openAiApi', 'slackApi']);
|
|
});
|
|
|
|
it('should include credential types from node issues when displayable', () => {
|
|
const node = createNode({
|
|
issues: {
|
|
credentials: {
|
|
httpHeaderAuth: ['Credentials not set'],
|
|
},
|
|
},
|
|
});
|
|
mockGetNodeTypeDisplayableCredentials.mockReturnValue([{ name: 'httpHeaderAuth' }]);
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toContain('httpHeaderAuth');
|
|
});
|
|
|
|
it('should include credential types from node issues even when not in displayable list', () => {
|
|
const node = createNode({
|
|
issues: {
|
|
credentials: {
|
|
httpHeaderAuth: ['Credentials not set'],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toContain('httpHeaderAuth');
|
|
});
|
|
|
|
it('should include credential types from assigned credentials when displayable', () => {
|
|
const node = createNode({
|
|
credentials: {
|
|
slackApi: { id: 'cred-1', name: 'My Slack' },
|
|
},
|
|
});
|
|
mockGetNodeTypeDisplayableCredentials.mockReturnValue([{ name: 'slackApi' }]);
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toContain('slackApi');
|
|
});
|
|
|
|
it('should include credential types from assigned credentials even when not in displayable list', () => {
|
|
const node = createNode({
|
|
credentials: {
|
|
slackApi: { id: 'cred-1', name: 'My Slack' },
|
|
},
|
|
});
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toContain('slackApi');
|
|
});
|
|
|
|
it('should deduplicate credential types from all sources', () => {
|
|
const node = createNode({
|
|
credentials: {
|
|
testApi: { id: 'cred-1', name: 'Test' },
|
|
},
|
|
issues: {
|
|
credentials: {
|
|
testApi: ['Some issue'],
|
|
},
|
|
},
|
|
});
|
|
mockGetNodeTypeDisplayableCredentials.mockReturnValue([{ name: 'testApi' }]);
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toEqual(['testApi']);
|
|
});
|
|
|
|
it('should return empty array when node has no credentials', () => {
|
|
const node = createNode();
|
|
|
|
const result = getNodeCredentialTypes(mockNodeTypeProvider, node);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('groupCredentialsByType', () => {
|
|
const displayNameLookup = (type: string) => `Display: ${type}`;
|
|
|
|
it('should group multiple nodes sharing the same credential type', () => {
|
|
const nodeA = createNode({
|
|
name: 'NodeA',
|
|
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
|
|
});
|
|
const nodeB = createNode({
|
|
name: 'NodeB',
|
|
credentials: { slackApi: { id: 'cred-2', name: 'Slack 2' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: nodeA, credentialTypes: ['slackApi'] },
|
|
{ node: nodeB, credentialTypes: ['slackApi'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].credentialType).toBe('slackApi');
|
|
expect(result[0].credentialDisplayName).toBe('Display: slackApi');
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['NodeA', 'NodeB']);
|
|
});
|
|
|
|
it('should pick selectedCredentialId from the first node that has it', () => {
|
|
const nodeA = createNode({ name: 'NodeA' });
|
|
const nodeB = createNode({
|
|
name: 'NodeB',
|
|
credentials: { slackApi: { id: 'cred-2', name: 'Slack' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: nodeA, credentialTypes: ['slackApi'] },
|
|
{ node: nodeB, credentialTypes: ['slackApi'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].selectedCredentialId).toBe('cred-2');
|
|
});
|
|
|
|
it('should merge issues from multiple nodes without duplicates', () => {
|
|
const nodeA = createNode({
|
|
name: 'NodeA',
|
|
issues: { credentials: { slackApi: ['Token expired'] } },
|
|
});
|
|
const nodeB = createNode({
|
|
name: 'NodeB',
|
|
issues: { credentials: { slackApi: ['Token expired', 'Rate limited'] } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: nodeA, credentialTypes: ['slackApi'] },
|
|
{ node: nodeB, credentialTypes: ['slackApi'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].issues).toEqual(['Token expired', 'Rate limited']);
|
|
});
|
|
|
|
it('should collect all nodes in the group', () => {
|
|
const nodes = ['A', 'B', 'C'].map((name) =>
|
|
createNode({ name, credentials: { api: { id: `cred-${name}`, name } } }),
|
|
);
|
|
|
|
const result = groupCredentialsByType(
|
|
nodes.map((node) => ({ node, credentialTypes: ['api'] })),
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['A', 'B', 'C']);
|
|
});
|
|
|
|
it('should set isComplete to true when selectedCredentialId exists and no issues', () => {
|
|
const node = createNode({
|
|
name: 'NodeA',
|
|
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[{ node, credentialTypes: ['slackApi'] }],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].isComplete).toBe(true);
|
|
});
|
|
|
|
it('should set isComplete to false when selectedCredentialId is missing', () => {
|
|
const node = createNode({ name: 'NodeA' });
|
|
|
|
const result = groupCredentialsByType(
|
|
[{ node, credentialTypes: ['slackApi'] }],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].isComplete).toBe(false);
|
|
});
|
|
|
|
it('should set isComplete to false when there are issues', () => {
|
|
const node = createNode({
|
|
name: 'NodeA',
|
|
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
|
|
issues: { credentials: { slackApi: ['Token expired'] } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[{ node, credentialTypes: ['slackApi'] }],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].isComplete).toBe(false);
|
|
});
|
|
|
|
it('should return empty array for empty input', () => {
|
|
const result = groupCredentialsByType([], displayNameLookup);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should create separate entries for different credential types', () => {
|
|
const node = createNode({
|
|
name: 'NodeA',
|
|
credentials: {
|
|
slackApi: { id: 'cred-1', name: 'Slack' },
|
|
githubApi: { id: 'cred-2', name: 'GitHub' },
|
|
},
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[{ node, credentialTypes: ['slackApi', 'githubApi'] }],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.map((s) => s.credentialType)).toEqual(['slackApi', 'githubApi']);
|
|
});
|
|
|
|
it('should include all nodes in nodes array', () => {
|
|
const node = createNode({
|
|
name: 'NodeA',
|
|
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[{ node, credentialTypes: ['slackApi'] }],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result[0].nodes).toHaveLength(1);
|
|
expect(result[0].nodes[0].name).toBe('NodeA');
|
|
});
|
|
|
|
it('should add all nodes to the same credential group', () => {
|
|
const regularNode = createNode({
|
|
name: 'SlackNode',
|
|
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
|
|
});
|
|
const triggerNode = createNode({
|
|
name: 'SlackTrigger',
|
|
type: 'n8n-nodes-base.slackTrigger',
|
|
credentials: { slackApi: { id: 'cred-1', name: 'Slack' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: regularNode, credentialTypes: ['slackApi'] },
|
|
{ node: triggerNode, credentialTypes: ['slackApi'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['SlackNode', 'SlackTrigger']);
|
|
});
|
|
|
|
it('should group HTTP Request nodes with the same credential type and URL', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'Google',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: 'https://www.google.com' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'Google 2',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: 'https://www.google.com' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].credentialType).toBe('httpHeaderAuth');
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['Google', 'Google 2']);
|
|
});
|
|
|
|
it('should create separate cards for HTTP Request nodes with different URLs', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'Google',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: 'https://www.google.com' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'Example',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: 'https://www.example.com' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-2', name: 'Auth 2' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].nodes[0].name).toBe('Google');
|
|
expect(result[1].nodes[0].name).toBe('Example');
|
|
});
|
|
|
|
it('should still group non-HTTP-Request nodes normally alongside HTTP Request cards', () => {
|
|
const slackNode1 = createNode({
|
|
name: 'Slack1',
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const slackNode2 = createNode({
|
|
name: 'Slack2',
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode = createNode({
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: 'https://api.example.com' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-2', name: 'Auth 2' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: slackNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: slackNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
// First entry: grouped non-HTTP nodes
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['Slack1', 'Slack2']);
|
|
// Second entry: HTTP Request card
|
|
expect(result[1].nodes.map((n) => n.name)).toEqual(['HTTP Request']);
|
|
});
|
|
|
|
it('should apply URL-based grouping to HTTP Request Tool nodes', () => {
|
|
const toolNode1 = createNode({
|
|
name: 'Tool 1',
|
|
type: 'n8n-nodes-base.httpRequestTool',
|
|
parameters: { url: 'https://api.example.com/batch' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const toolNode2 = createNode({
|
|
name: 'Tool 2',
|
|
type: 'n8n-nodes-base.httpRequestTool',
|
|
parameters: { url: 'https://api.example.com/batch' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const toolNode3 = createNode({
|
|
name: 'Tool 3',
|
|
type: 'n8n-nodes-base.httpRequestTool',
|
|
parameters: { url: 'https://api.example.com/db' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: toolNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: toolNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: toolNode3, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['Tool 1', 'Tool 2']);
|
|
expect(result[1].nodes.map((n) => n.name)).toEqual(['Tool 3']);
|
|
});
|
|
|
|
it('should create separate cards for HTTP Request nodes with unresolvable expression URLs', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ $json.url }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'HTTP Request1',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ $json.url }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
);
|
|
|
|
// Same expression but can't be resolved — each gets its own card
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].nodes[0].name).toBe('HTTP Request');
|
|
expect(result[1].nodes[0].name).toBe('HTTP Request1');
|
|
});
|
|
|
|
it('should group HTTP Request nodes when expression URLs resolve to the same value', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ "https://api.example.com" }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'HTTP Request1',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ "https://api.example.com" }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const resolveExpressionUrl = vi.fn().mockReturnValue('https://api.example.com');
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
resolveExpressionUrl,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['HTTP Request', 'HTTP Request1']);
|
|
});
|
|
|
|
it('should create separate cards when expression URLs resolve to different values', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ "https://api.google.com" }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'HTTP Request1',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ "https://api.example.com" }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const resolveExpressionUrl = vi.fn().mockImplementation((url: string) => {
|
|
if (url.includes('google')) return 'https://api.google.com';
|
|
return 'https://api.example.com';
|
|
});
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
resolveExpressionUrl,
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].nodes[0].name).toBe('HTTP Request');
|
|
expect(result[1].nodes[0].name).toBe('HTTP Request1');
|
|
});
|
|
|
|
it('should group resolved expression URL with matching static URL', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'HTTP Static',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: 'https://api.example.com' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'HTTP Expression',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ "https://api.example.com" }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const resolveExpressionUrl = vi.fn().mockReturnValue('https://api.example.com');
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
resolveExpressionUrl,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].nodes.map((n) => n.name)).toEqual(['HTTP Static', 'HTTP Expression']);
|
|
});
|
|
|
|
it('should fall back to separate cards when resolveExpressionUrl returns null', () => {
|
|
const httpNode1 = createNode({
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ $json.url }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
const httpNode2 = createNode({
|
|
name: 'HTTP Request1',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
parameters: { url: '={{ $json.url }}' },
|
|
credentials: { httpHeaderAuth: { id: 'cred-1', name: 'Auth' } },
|
|
});
|
|
|
|
const resolveExpressionUrl = vi.fn().mockReturnValue(null);
|
|
|
|
const result = groupCredentialsByType(
|
|
[
|
|
{ node: httpNode1, credentialTypes: ['httpHeaderAuth'] },
|
|
{ node: httpNode2, credentialTypes: ['httpHeaderAuth'] },
|
|
],
|
|
displayNameLookup,
|
|
resolveExpressionUrl,
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].nodes[0].name).toBe('HTTP Request');
|
|
expect(result[1].nodes[0].name).toBe('HTTP Request1');
|
|
});
|
|
});
|
|
|
|
describe('isCredentialCardComplete', () => {
|
|
const isTriggerNode = (type: string) => type.includes('Trigger');
|
|
const noUnfilledParams = () => false;
|
|
|
|
function makeCtx(
|
|
overrides: {
|
|
hasTriggerExecuted?: (name: string) => boolean;
|
|
isCredentialTestedOk?: (id: string) => boolean;
|
|
firstTriggerName?: string | null;
|
|
} = {},
|
|
): CompletionContext {
|
|
return {
|
|
firstTriggerName: overrides.firstTriggerName ?? null,
|
|
hasTriggerExecuted: overrides.hasTriggerExecuted ?? (() => false),
|
|
isTriggerNode,
|
|
isCredentialTestedOk: overrides.isCredentialTestedOk,
|
|
hasUnfilledTemplateParams: noUnfilledParams,
|
|
};
|
|
}
|
|
|
|
it('should return true when credential is set, no issues, and no triggers', () => {
|
|
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [slackNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(isCredentialCardComplete(state, makeCtx())).toBe(true);
|
|
});
|
|
|
|
it('should return false when credential is missing', () => {
|
|
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: undefined,
|
|
issues: [],
|
|
nodes: [slackNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(isCredentialCardComplete(state, makeCtx())).toBe(false);
|
|
});
|
|
|
|
it('should return false when there are issues', () => {
|
|
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: ['Token expired'],
|
|
nodes: [slackNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(isCredentialCardComplete(state, makeCtx())).toBe(false);
|
|
});
|
|
|
|
it('should return false when trigger has not executed', () => {
|
|
const triggerNode = createNode({ name: 'SlackTrigger', type: 'n8n-nodes-base.slackTrigger' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [triggerNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(
|
|
isCredentialCardComplete(
|
|
state,
|
|
makeCtx({
|
|
hasTriggerExecuted: () => false,
|
|
firstTriggerName: 'SlackTrigger',
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('should return true when credential is set and all triggers have executed', () => {
|
|
const triggerNode = createNode({ name: 'SlackTrigger', type: 'n8n-nodes-base.slackTrigger' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [triggerNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(
|
|
isCredentialCardComplete(
|
|
state,
|
|
makeCtx({
|
|
hasTriggerExecuted: () => true,
|
|
firstTriggerName: 'SlackTrigger',
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should return true when single embedded trigger has executed', () => {
|
|
const trigger = createNode({ name: 'Trigger1', type: 'n8n-nodes-base.slackTrigger' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [trigger],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(
|
|
isCredentialCardComplete(
|
|
state,
|
|
makeCtx({
|
|
hasTriggerExecuted: () => true,
|
|
firstTriggerName: 'Trigger1',
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should return false when credential test has not passed', () => {
|
|
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [slackNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(
|
|
isCredentialCardComplete(
|
|
state,
|
|
makeCtx({
|
|
isCredentialTestedOk: () => false,
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('should return true when credential test has passed', () => {
|
|
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [slackNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(
|
|
isCredentialCardComplete(
|
|
state,
|
|
makeCtx({
|
|
isCredentialTestedOk: () => true,
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should complete when isCredentialTestedOk is not provided (non-testable type)', () => {
|
|
const slackNode = createNode({ name: 'SlackNode', type: 'n8n-nodes-base.slack' });
|
|
const state: CredentialTypeSetupState = {
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [slackNode],
|
|
isComplete: false,
|
|
};
|
|
|
|
expect(isCredentialCardComplete(state, makeCtx())).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getNodeParametersIssues', () => {
|
|
it('should detect issues for the active variant when a parameter name has multiple displayOptions', () => {
|
|
// Simulates node types like Google Drive Trigger that define multiple
|
|
// properties with the same name (e.g. "event") for different triggerOn values.
|
|
const nodeType = {
|
|
properties: [
|
|
createTestNodeProperties({
|
|
displayName: 'Trigger On',
|
|
name: 'triggerOn',
|
|
type: 'options',
|
|
required: true,
|
|
default: '',
|
|
options: [
|
|
{ name: 'Specific File', value: 'specificFile' },
|
|
{ name: 'Specific Folder', value: 'specificFolder' },
|
|
{ name: 'Any File/Folder', value: 'anyFileFolder' },
|
|
],
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Watch For',
|
|
name: 'event',
|
|
type: 'options',
|
|
required: true,
|
|
default: 'fileUpdated',
|
|
displayOptions: { show: { triggerOn: ['specificFile'] } },
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Watch For',
|
|
name: 'event',
|
|
type: 'options',
|
|
required: true,
|
|
default: '',
|
|
displayOptions: { show: { triggerOn: ['specificFolder'] } },
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Watch For',
|
|
name: 'event',
|
|
type: 'options',
|
|
required: true,
|
|
default: 'fileCreated',
|
|
displayOptions: { show: { triggerOn: ['anyFileFolder'] } },
|
|
}),
|
|
],
|
|
} as unknown as INodeTypeDescription;
|
|
|
|
mockNodeTypeProvider.getNodeType.mockReturnValue(nodeType);
|
|
|
|
const node = createTestNode({
|
|
type: 'n8n-nodes-base.googleDriveTrigger',
|
|
parameters: {
|
|
triggerOn: 'specificFolder',
|
|
event: '',
|
|
},
|
|
});
|
|
|
|
const issues = getNodeParametersIssues(mockNodeTypeProvider, node);
|
|
|
|
expect(issues).toHaveProperty('event');
|
|
});
|
|
|
|
it('should detect required parameter issues when a controlling default has same-named siblings', () => {
|
|
// Mirrors OpenAI v2's shape: a single `resource` parameter controls
|
|
// which `operation` (and which `modelId`) is displayed. Naively
|
|
// filling defaults picks the first `operation` regardless of resource.
|
|
const nodeType = {
|
|
properties: [
|
|
createTestNodeProperties({
|
|
displayName: 'Resource',
|
|
name: 'resource',
|
|
type: 'options',
|
|
default: 'text',
|
|
options: [
|
|
{ name: 'Audio', value: 'audio' },
|
|
{ name: 'Text', value: 'text' },
|
|
],
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
default: 'generate',
|
|
displayOptions: { show: { resource: ['audio'] } },
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
default: 'response',
|
|
displayOptions: { show: { resource: ['text'] } },
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Model',
|
|
name: 'modelId',
|
|
type: 'resourceLocator',
|
|
required: true,
|
|
default: { mode: 'list', value: '' },
|
|
modes: [
|
|
{ displayName: 'From List', name: 'list', type: 'list' },
|
|
{ displayName: 'ID', name: 'id', type: 'string' },
|
|
],
|
|
displayOptions: { show: { resource: ['text'], operation: ['response'] } },
|
|
}),
|
|
],
|
|
} as unknown as INodeTypeDescription;
|
|
|
|
mockNodeTypeProvider.getNodeType.mockReturnValue(nodeType);
|
|
|
|
const node = createTestNode({
|
|
type: '@n8n/n8n-nodes-langchain.openAi',
|
|
parameters: {
|
|
modelId: { __rl: true, value: '', mode: 'id' },
|
|
},
|
|
});
|
|
|
|
const issues = getNodeParametersIssues(mockNodeTypeProvider, node);
|
|
|
|
expect(issues).toHaveProperty('modelId');
|
|
});
|
|
|
|
it('should not include issues for parameter variants that are not displayed', () => {
|
|
const nodeType = {
|
|
properties: [
|
|
createTestNodeProperties({
|
|
displayName: 'Trigger On',
|
|
name: 'triggerOn',
|
|
type: 'options',
|
|
required: true,
|
|
default: 'specificFolder',
|
|
}),
|
|
createTestNodeProperties({
|
|
displayName: 'Watch For',
|
|
name: 'event',
|
|
type: 'options',
|
|
required: true,
|
|
default: '',
|
|
displayOptions: { show: { triggerOn: ['specificFile'] } },
|
|
}),
|
|
],
|
|
} as unknown as INodeTypeDescription;
|
|
|
|
mockNodeTypeProvider.getNodeType.mockReturnValue(nodeType);
|
|
|
|
const node = createTestNode({
|
|
type: 'n8n-nodes-base.testTrigger',
|
|
parameters: {
|
|
triggerOn: 'specificFolder',
|
|
event: '',
|
|
},
|
|
});
|
|
|
|
const issues = getNodeParametersIssues(mockNodeTypeProvider, node);
|
|
|
|
expect(issues).not.toHaveProperty('event');
|
|
});
|
|
});
|
|
|
|
describe('buildTriggerSetupState', () => {
|
|
it('should be complete when trigger has no credential types and has executed', () => {
|
|
const node = createNode({ name: 'Trigger' });
|
|
|
|
const result = buildTriggerSetupState(node, [], [], true);
|
|
|
|
expect(result.node).toBe(node);
|
|
expect(result.isComplete).toBe(true);
|
|
});
|
|
|
|
it('should be incomplete when trigger has no credential types and has not executed', () => {
|
|
const node = createNode({ name: 'Trigger' });
|
|
|
|
const result = buildTriggerSetupState(node, [], [], false);
|
|
|
|
expect(result.isComplete).toBe(false);
|
|
});
|
|
|
|
it('should be incomplete when credentials are complete but trigger has not executed', () => {
|
|
const node = createNode({ name: 'Trigger' });
|
|
const credentialTypeStates: CredentialTypeSetupState[] = [
|
|
{
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [node],
|
|
isComplete: true,
|
|
},
|
|
];
|
|
|
|
const result = buildTriggerSetupState(node, ['slackApi'], credentialTypeStates, false);
|
|
|
|
expect(result.isComplete).toBe(false);
|
|
});
|
|
|
|
it('should be incomplete when trigger has executed but credentials are incomplete', () => {
|
|
const node = createNode({ name: 'Trigger' });
|
|
const credentialTypeStates: CredentialTypeSetupState[] = [
|
|
{
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: undefined,
|
|
issues: [],
|
|
nodes: [node],
|
|
isComplete: false,
|
|
},
|
|
];
|
|
|
|
const result = buildTriggerSetupState(node, ['slackApi'], credentialTypeStates, true);
|
|
|
|
expect(result.isComplete).toBe(false);
|
|
});
|
|
|
|
it('should be complete when all credentials are complete and trigger has executed', () => {
|
|
const node = createNode({ name: 'Trigger' });
|
|
const credentialTypeStates: CredentialTypeSetupState[] = [
|
|
{
|
|
credentialType: 'slackApi',
|
|
credentialDisplayName: 'Slack',
|
|
selectedCredentialId: 'cred-1',
|
|
issues: [],
|
|
nodes: [node],
|
|
isComplete: true,
|
|
},
|
|
{
|
|
credentialType: 'githubApi',
|
|
credentialDisplayName: 'GitHub',
|
|
selectedCredentialId: 'cred-2',
|
|
issues: [],
|
|
nodes: [node],
|
|
isComplete: true,
|
|
},
|
|
];
|
|
|
|
const result = buildTriggerSetupState(
|
|
node,
|
|
['slackApi', 'githubApi'],
|
|
credentialTypeStates,
|
|
true,
|
|
);
|
|
|
|
expect(result.isComplete).toBe(true);
|
|
});
|
|
|
|
it('should treat missing credential type states as complete', () => {
|
|
const node = createNode({ name: 'Trigger' });
|
|
|
|
const result = buildTriggerSetupState(node, ['unknownApi'], [], true);
|
|
|
|
expect(result.isComplete).toBe(true);
|
|
});
|
|
});
|
|
});
|