feat(editor): Refactor the executable node logic (no-changelog) (#16848)

This commit is contained in:
Daria
2025-07-01 12:29:49 +03:00
committed by GitHub
parent c51842bd52
commit 503beea8d1
5 changed files with 398 additions and 73 deletions

View File

@@ -1,13 +1,23 @@
import { setActivePinia } from 'pinia';
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow';
import {
NodeConnectionTypes,
NodeHelpers,
type INode,
type INodeTypeDescription,
type Workflow,
} from 'n8n-workflow';
import { createTestingPinia } from '@pinia/testing';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { createTestNode } from '@/__tests__/mocks';
import { createTestNode, createMockEnterpriseSettings } from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { CUSTOM_API_CALL_KEY, EnterpriseEditionFeature } from '@/constants';
import { mockedStore } from '@/__tests__/utils';
import { mock } from 'vitest-mock-extended';
import type { ExecutionStatus, IRunData } from 'n8n-workflow';
import { faker } from '@faker-js/faker';
import type { INodeUi, IUsedCredential } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
describe('useNodeHelpers()', () => {
beforeAll(() => {
@@ -18,6 +28,293 @@ describe('useNodeHelpers()', () => {
vi.clearAllMocks();
});
describe('isNodeExecutable()', () => {
it('should return true if the node is null but explicitly executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const result = isNodeExecutable(null, true, []);
expect(result).toBe(true);
});
it('should return false if node has no Main input and is not trigger or tool', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
id: 'workflow-id',
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.AiDocument]);
const result = isNodeExecutable(node, true, []);
expect(result).toBe(false);
});
it('should return true if node has Main input and is marked executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
const result = isNodeExecutable(node, true, []);
expect(result).toBe(true);
});
it('should return true if node has foreign credentials even if not marked executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
const result = isNodeExecutable(node, false, ['foreign-cred-id']);
expect(result).toBe(true);
});
it('should return true for trigger nodes regardless of inputs', () => {
const { isNodeExecutable } = useNodeHelpers();
const triggerNode: INodeUi = {
id: 'node-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => triggerNode,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(true);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([]);
const result = isNodeExecutable(triggerNode, true, []);
expect(result).toBe(true);
});
it('should return true for tool nodes regardless of inputs', () => {
const { isNodeExecutable } = useNodeHelpers();
const toolNode: INodeUi = {
id: 'node-id',
name: 'Tool Node',
type: 'n8n-nodes-base.ai-tool',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => toolNode,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(true);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([]);
const result = isNodeExecutable(toolNode, true, []);
expect(result).toBe(true);
});
it('should return true if node is structurally valid and has foreign credentials, even if not executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
const result = isNodeExecutable(node, false, ['cred-1']);
expect(result).toBe(true);
});
});
describe('getForeignCredentialsIfSharingEnabled()', () => {
it('should return an empty array when user has the wrong license', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
const credentialWithoutAccess: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: false,
};
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: false,
});
mockedStore(useWorkflowsStore).usedCredentials = {
[credentialWithoutAccess.id]: credentialWithoutAccess,
};
const result = getForeignCredentialsIfSharingEnabled({
[credentialWithoutAccess.id]: {
id: credentialWithoutAccess.id,
name: credentialWithoutAccess.name,
},
});
expect(result).toEqual([]);
});
it('should return an empty array when credentials are undefined', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: true,
});
const result = getForeignCredentialsIfSharingEnabled(undefined);
expect(result).toEqual([]);
});
it('should return an empty array when user has access to all credentials', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
const credentialWithAccess1: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: true,
};
const credentialWithAccess2: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: true,
};
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: true,
});
mockedStore(useWorkflowsStore).usedCredentials = {
[credentialWithAccess1.id]: credentialWithAccess1,
[credentialWithAccess2.id]: credentialWithAccess2,
};
const result = getForeignCredentialsIfSharingEnabled({
[credentialWithAccess1.id]: {
id: credentialWithAccess1.id,
name: credentialWithAccess1.name,
},
[credentialWithAccess2.id]: {
id: credentialWithAccess2.id,
name: credentialWithAccess2.name,
},
});
expect(result).toEqual([]);
});
it('should return an array of foreign credentials', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
const credentialWithAccess: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: true,
};
const credentialWithoutAccess: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: false,
};
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: true,
});
mockedStore(useWorkflowsStore).usedCredentials = {
[credentialWithAccess.id]: credentialWithAccess,
[credentialWithoutAccess.id]: credentialWithoutAccess,
};
const result = getForeignCredentialsIfSharingEnabled({
[credentialWithAccess.id]: {
id: credentialWithAccess.id,
name: credentialWithAccess.name,
},
[credentialWithoutAccess.id]: {
id: credentialWithoutAccess.id,
name: credentialWithoutAccess.name,
},
});
expect(result).toEqual([credentialWithoutAccess.id]);
});
});
describe('isCustomApiCallSelected', () => {
test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => {
const nodeValues = {