Skip to content

Commit b6d5092

Browse files
authoredMar 11, 2025
fix(editor): Disable test step option in context menu for sub-nodes (#13816)
1 parent e8334ee commit b6d5092

File tree

4 files changed

+300
-3
lines changed

4 files changed

+300
-3
lines changed
 

‎packages/frontend/editor-ui/src/composables/useContextMenu.test.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe('useContextMenu', () => {
4747
} as never);
4848

4949
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
50+
vi.spyOn(NodeHelpers, 'isExecutable').mockReturnValue(true);
5051
});
5152

5253
afterEach(() => {
@@ -106,6 +107,18 @@ describe('useContextMenu', () => {
106107
expect(targetNodeIds.value).toEqual([basicChain.id]);
107108
});
108109

110+
it('should disable test step option for sub-nodes (AI tool nodes)', () => {
111+
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
112+
const subNode = nodeFactory({ type: 'n8n-nodes-base.hackerNewsTool' });
113+
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(subNode);
114+
vi.spyOn(NodeHelpers, 'isExecutable').mockReturnValueOnce(false);
115+
open(mockEvent, { source: 'node-right-click', nodeId: subNode.id });
116+
117+
expect(isOpen.value).toBe(true);
118+
expect(actions.value.find((action) => action.id === 'execute')?.disabled).toBe(true);
119+
expect(targetNodeIds.value).toEqual([subNode.id]);
120+
});
121+
109122
it('should return the correct actions when right clicking a Node', () => {
110123
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
111124
const node = nodeFactory();
@@ -141,7 +154,6 @@ describe('useContextMenu', () => {
141154
expect(actions.value).toMatchSnapshot();
142155
expect(targetNodeIds.value).toEqual([sticky.id]);
143156
});
144-
145157
it('should return the correct actions when right clicking a Node', () => {
146158
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
147159
const { open, isOpen, actions, targetNodeIds } = useContextMenu();

‎packages/frontend/editor-ui/src/composables/useContextMenu.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { ActionDropdownItem, XYPosition } from '@/Interface';
1+
import type { ActionDropdownItem, XYPosition, INodeUi } from '@/Interface';
22
import { NOT_DUPLICATABLE_NODE_TYPES, STICKY_NODE_TYPE } from '@/constants';
33
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
44
import { useSourceControlStore } from '@/stores/sourceControl.store';
55
import { useUIStore } from '@/stores/ui.store';
66
import { useWorkflowsStore } from '@/stores/workflows.store';
77
import type { INode, INodeTypeDescription } from 'n8n-workflow';
8+
import { NodeHelpers } from 'n8n-workflow';
89
import { computed, ref, watch } from 'vue';
910
import { getMousePosition } from '../utils/nodeViewUtils';
1011
import { useI18n } from './useI18n';
@@ -94,6 +95,16 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
9495
position.value = [0, 0];
9596
};
9697

98+
const isExecutable = (node: INodeUi) => {
99+
const currentWorkflow = workflowsStore.getCurrentWorkflow();
100+
const workflowNode = currentWorkflow.getNode(node.name) as INode;
101+
const nodeType = nodeTypesStore.getNodeType(
102+
workflowNode.type,
103+
workflowNode.typeVersion,
104+
) as INodeTypeDescription;
105+
return NodeHelpers.isExecutable(currentWorkflow, workflowNode, nodeType);
106+
};
107+
97108
const open = (event: MouseEvent, menuTarget: ContextMenuTarget) => {
98109
event.stopPropagation();
99110

@@ -228,7 +239,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
228239
{
229240
id: 'execute',
230241
label: i18n.baseText('contextMenu.test'),
231-
disabled: isReadOnly.value,
242+
disabled: isReadOnly.value || !isExecutable(nodes[0]),
232243
},
233244
{
234245
id: 'rename',

‎packages/workflow/src/NodeHelpers.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1718,3 +1718,13 @@ export function getVersionedNodeType(
17181718
}
17191719
return object;
17201720
}
1721+
1722+
export function isTriggerNode(nodeTypeData: INodeTypeDescription) {
1723+
return nodeTypeData.group.includes('trigger');
1724+
}
1725+
1726+
export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription) {
1727+
const outputs = getNodeOutputs(workflow, node, nodeTypeData);
1728+
const outputNames = getConnectionTypes(outputs);
1729+
return outputNames.includes(NodeConnectionType.Main) || isTriggerNode(nodeTypeData);
1730+
}

‎packages/workflow/test/NodeHelpers.test.ts

+264
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
isSubNodeType,
1414
applyDeclarativeNodeOptionParameters,
1515
getParameterIssues,
16+
isTriggerNode,
17+
isExecutable,
1618
} from '@/NodeHelpers';
1719
import type { Workflow } from '@/Workflow';
1820

@@ -4248,4 +4250,266 @@ describe('NodeHelpers', () => {
42484250
});
42494251
});
42504252
});
4253+
4254+
describe('isTriggerNode', () => {
4255+
const tests: Array<{
4256+
description: string;
4257+
input: INodeTypeDescription;
4258+
expected: boolean;
4259+
}> = [
4260+
{
4261+
description: 'Should return true for node with trigger in group',
4262+
input: {
4263+
name: 'TriggerNode',
4264+
displayName: 'Trigger Node',
4265+
group: ['trigger'],
4266+
description: 'Trigger node description',
4267+
version: 1,
4268+
defaults: {},
4269+
inputs: [],
4270+
outputs: [NodeConnectionType.Main],
4271+
properties: [],
4272+
},
4273+
expected: true,
4274+
},
4275+
{
4276+
description: 'Should return true for node with multiple groups including trigger',
4277+
input: {
4278+
name: 'MultiGroupTriggerNode',
4279+
displayName: 'Multi-Group Trigger Node',
4280+
group: ['trigger', 'input'],
4281+
description: 'Multi-group trigger node description',
4282+
version: 1,
4283+
defaults: {},
4284+
inputs: [],
4285+
outputs: [NodeConnectionType.Main],
4286+
properties: [],
4287+
},
4288+
expected: true,
4289+
},
4290+
{
4291+
description: 'Should return false for node without trigger in group',
4292+
input: {
4293+
name: 'RegularNode',
4294+
displayName: 'Regular Node',
4295+
group: ['input'],
4296+
description: 'Regular node description',
4297+
version: 1,
4298+
defaults: {},
4299+
inputs: [NodeConnectionType.Main],
4300+
outputs: [NodeConnectionType.Main],
4301+
properties: [],
4302+
},
4303+
expected: false,
4304+
},
4305+
{
4306+
description: 'Should return false for node with empty group array',
4307+
input: {
4308+
name: 'EmptyGroupNode',
4309+
displayName: 'Empty Group Node',
4310+
group: [],
4311+
description: 'Empty group node description',
4312+
version: 1,
4313+
defaults: {},
4314+
inputs: [NodeConnectionType.Main],
4315+
outputs: [NodeConnectionType.Main],
4316+
properties: [],
4317+
},
4318+
expected: false,
4319+
},
4320+
{
4321+
description:
4322+
'Should return false when trigger is called Trigger, but does not have a trigger group',
4323+
input: {
4324+
name: 'AlmostTriggerNode',
4325+
displayName: 'Almost Trigger Node',
4326+
group: ['transform'],
4327+
description: 'Almost trigger node description',
4328+
version: 1,
4329+
defaults: {},
4330+
inputs: [NodeConnectionType.Main],
4331+
outputs: [NodeConnectionType.Main],
4332+
properties: [],
4333+
},
4334+
expected: false,
4335+
},
4336+
];
4337+
4338+
for (const testData of tests) {
4339+
test(testData.description, () => {
4340+
const result = isTriggerNode(testData.input);
4341+
expect(result).toEqual(testData.expected);
4342+
});
4343+
}
4344+
});
4345+
4346+
describe('isExecutable', () => {
4347+
const workflowMock = {
4348+
expression: {
4349+
getSimpleParameterValue: jest.fn().mockReturnValue([NodeConnectionType.Main]),
4350+
},
4351+
} as unknown as Workflow;
4352+
4353+
const tests: Array<{
4354+
description: string;
4355+
node: INode;
4356+
nodeTypeData: INodeTypeDescription;
4357+
expected: boolean;
4358+
mockReturnValue?: NodeConnectionType[];
4359+
}> = [
4360+
{
4361+
description: 'Should return true for trigger node',
4362+
node: {
4363+
id: 'triggerNodeId',
4364+
name: 'TriggerNode',
4365+
position: [0, 0],
4366+
type: 'n8n-nodes-base.TriggerNode',
4367+
typeVersion: 1,
4368+
parameters: {},
4369+
},
4370+
nodeTypeData: {
4371+
name: 'TriggerNode',
4372+
displayName: 'Trigger Node',
4373+
group: ['trigger'],
4374+
description: 'Trigger node description',
4375+
version: 1,
4376+
defaults: {},
4377+
inputs: [],
4378+
outputs: [NodeConnectionType.Main],
4379+
properties: [],
4380+
},
4381+
expected: true,
4382+
},
4383+
{
4384+
description: 'Should return true for node with Main output',
4385+
node: {
4386+
id: 'mainOutputNodeId',
4387+
name: 'MainOutputNode',
4388+
position: [0, 0],
4389+
type: 'n8n-nodes-base.MainOutputNode',
4390+
typeVersion: 1,
4391+
parameters: {},
4392+
},
4393+
nodeTypeData: {
4394+
name: 'MainOutputNode',
4395+
displayName: 'Main Output Node',
4396+
group: ['transform'],
4397+
description: 'Node with Main output',
4398+
version: 1,
4399+
defaults: {},
4400+
inputs: [NodeConnectionType.Main],
4401+
outputs: [NodeConnectionType.Main],
4402+
properties: [],
4403+
},
4404+
expected: true,
4405+
},
4406+
{
4407+
description: 'Should return false for node without Main output and not a trigger',
4408+
node: {
4409+
id: 'nonExecutableNodeId',
4410+
name: 'NonExecutableNode',
4411+
position: [0, 0],
4412+
type: 'n8n-nodes-base.NonExecutableNode',
4413+
typeVersion: 1,
4414+
parameters: {},
4415+
},
4416+
nodeTypeData: {
4417+
name: 'NonExecutableNode',
4418+
displayName: 'Non-Executable Node',
4419+
group: ['output'],
4420+
description: 'Node without Main output and not a trigger',
4421+
version: 1,
4422+
defaults: {},
4423+
inputs: [NodeConnectionType.Main],
4424+
outputs: [NodeConnectionType.AiAgent],
4425+
properties: [],
4426+
},
4427+
expected: false,
4428+
},
4429+
{
4430+
description: 'Should return true for node with mixed outputs including Main',
4431+
node: {
4432+
id: 'mixedOutputNodeId',
4433+
name: 'MixedOutputNode',
4434+
position: [0, 0],
4435+
type: 'n8n-nodes-base.MixedOutputNode',
4436+
typeVersion: 1,
4437+
parameters: {},
4438+
},
4439+
nodeTypeData: {
4440+
name: 'MixedOutputNode',
4441+
displayName: 'Mixed Output Node',
4442+
group: ['transform'],
4443+
description: 'Node with multiple output types including Main',
4444+
version: 1,
4445+
defaults: {},
4446+
inputs: [NodeConnectionType.Main],
4447+
outputs: [NodeConnectionType.Main, NodeConnectionType.AiAgent],
4448+
properties: [],
4449+
},
4450+
expected: true,
4451+
},
4452+
{
4453+
description: 'Should return false for node with only AiTool output and not a trigger',
4454+
node: {
4455+
id: 'aiToolOutputNodeId',
4456+
name: 'AiToolOutputNode',
4457+
position: [0, 0],
4458+
type: 'n8n-nodes-base.AiToolOutputNode',
4459+
typeVersion: 1,
4460+
parameters: {},
4461+
},
4462+
nodeTypeData: {
4463+
name: 'AiToolOutputNode',
4464+
displayName: 'AI Tool Output Node',
4465+
group: ['output'],
4466+
description: 'Node with only AiTool output and not a trigger',
4467+
version: 1,
4468+
defaults: {},
4469+
inputs: [],
4470+
outputs: [NodeConnectionType.AiTool], // Only AiTool output, no Main
4471+
properties: [],
4472+
},
4473+
expected: false,
4474+
},
4475+
{
4476+
description: 'Should return false for node with dynamic outputs set to AiTool only',
4477+
node: {
4478+
id: 'dynamicAiToolNodeId',
4479+
name: 'DynamicAiToolNode',
4480+
position: [0, 0],
4481+
type: 'n8n-nodes-base.DynamicAiToolNode',
4482+
typeVersion: 1,
4483+
parameters: {},
4484+
},
4485+
nodeTypeData: {
4486+
name: 'DynamicAiToolNode',
4487+
displayName: 'Dynamic AiTool Node',
4488+
group: ['output'],
4489+
description: 'Node with dynamic outputs that resolve to only AiTool',
4490+
version: 1,
4491+
defaults: {},
4492+
inputs: [NodeConnectionType.Main],
4493+
outputs: '={{["ai_tool"]}}', // Dynamic expression that resolves to AiTool only
4494+
properties: [],
4495+
},
4496+
expected: false,
4497+
mockReturnValue: [NodeConnectionType.AiTool],
4498+
},
4499+
];
4500+
4501+
for (const testData of tests) {
4502+
test(testData.description, () => {
4503+
// If this test has a custom mock return value, configure it
4504+
if (testData.mockReturnValue) {
4505+
(workflowMock.expression.getSimpleParameterValue as jest.Mock).mockReturnValueOnce(
4506+
testData.mockReturnValue,
4507+
);
4508+
}
4509+
4510+
const result = isExecutable(workflowMock, testData.node, testData.nodeTypeData);
4511+
expect(result).toEqual(testData.expected);
4512+
});
4513+
}
4514+
});
42514515
});

0 commit comments

Comments
 (0)
Please sign in to comment.