mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: Allow workflow execution even if it has errors (#9037)
This commit is contained in:
parent
15fb6cbd4a
commit
eaaefd76da
|
@ -592,4 +592,31 @@ describe('Execution', () => {
|
||||||
cy.wait(100);
|
cy.wait(100);
|
||||||
workflowPage.getters.errorToast({ timeout: 1 }).should('not.exist');
|
workflowPage.getters.errorToast({ timeout: 1 }).should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should execute workflow partially up to the node that has issues', () => {
|
||||||
|
cy.createFixtureWorkflow(
|
||||||
|
'Test_workflow_partial_execution_with_missing_credentials.json',
|
||||||
|
'My test workflow',
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
|
||||||
|
|
||||||
|
workflowPage.getters.zoomToFitButton().click();
|
||||||
|
workflowPage.getters.executeWorkflowButton().click();
|
||||||
|
|
||||||
|
// Wait for the execution to return.
|
||||||
|
cy.wait('@workflowRun');
|
||||||
|
|
||||||
|
// Check that the previous nodes executed successfully
|
||||||
|
workflowPage.getters
|
||||||
|
.canvasNodeByName('DebugHelper')
|
||||||
|
.within(() => cy.get('.fa-check'))
|
||||||
|
.should('exist');
|
||||||
|
workflowPage.getters
|
||||||
|
.canvasNodeByName('Filter')
|
||||||
|
.within(() => cy.get('.fa-check'))
|
||||||
|
.should('exist');
|
||||||
|
|
||||||
|
workflowPage.getters.errorToast().should('contain', `Problem in node ‘Telegram‘`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true,
|
||||||
|
"instanceId": "2be09fdcb9594c0827fd4cee80f7e590c93297d9217685f34c2250fe3144ef0c"
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"id": "09e4325e-ede1-40cf-a1ba-58612bbc7f1b",
|
||||||
|
"name": "When clicking \"Test workflow\"",
|
||||||
|
"type": "n8n-nodes-base.manualTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
820,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"category": "randomData"
|
||||||
|
},
|
||||||
|
"id": "4920bf3a-9978-4196-9dcb-8c2892e5641b",
|
||||||
|
"name": "DebugHelper",
|
||||||
|
"type": "n8n-nodes-base.debugHelper",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
1040,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "7508343e-3e99-4d12-96e4-00a35a3d4306",
|
||||||
|
"leftValue": "={{ $json.email }}",
|
||||||
|
"rightValue": ".",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "contains"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "4f6a6a4e-19b6-43f5-ba5c-e40b09d7f873",
|
||||||
|
"name": "Filter",
|
||||||
|
"type": "n8n-nodes-base.filter",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1260,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"chatId": "123123",
|
||||||
|
"text": "1123123",
|
||||||
|
"additionalFields": {}
|
||||||
|
},
|
||||||
|
"id": "1765f352-fc12-4fab-9c24-d666a150266f",
|
||||||
|
"name": "Telegram",
|
||||||
|
"type": "n8n-nodes-base.telegram",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
1480,
|
||||||
|
400
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"When clicking \"Test workflow\"": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "DebugHelper",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DebugHelper": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Filter",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Filter": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Telegram",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {}
|
||||||
|
}
|
|
@ -5,9 +5,7 @@ import { setActivePinia } from 'pinia';
|
||||||
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { IPinData, IRunData, Workflow } from 'n8n-workflow';
|
import type { IPinData, IRunData, Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -70,7 +68,6 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({
|
||||||
|
|
||||||
vi.mock('@/composables/useNodeHelpers', () => ({
|
vi.mock('@/composables/useNodeHelpers', () => ({
|
||||||
useNodeHelpers: vi.fn().mockReturnValue({
|
useNodeHelpers: vi.fn().mockReturnValue({
|
||||||
refreshNodeIssues: vi.fn(),
|
|
||||||
updateNodesExecutionIssues: vi.fn(),
|
updateNodesExecutionIssues: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
@ -94,9 +91,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
let uiStore: ReturnType<typeof useUIStore>;
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
let router: ReturnType<typeof useRouter>;
|
let router: ReturnType<typeof useRouter>;
|
||||||
let toast: ReturnType<typeof useToast>;
|
|
||||||
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
||||||
let nodeHelpers: ReturnType<typeof useNodeHelpers>;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const pinia = createTestingPinia();
|
const pinia = createTestingPinia();
|
||||||
|
@ -108,9 +103,7 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
router = useRouter();
|
router = useRouter();
|
||||||
toast = useToast();
|
|
||||||
workflowHelpers = useWorkflowHelpers({ router });
|
workflowHelpers = useWorkflowHelpers({ router });
|
||||||
nodeHelpers = useNodeHelpers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runWorkflowApi()', () => {
|
describe('runWorkflowApi()', () => {
|
||||||
|
@ -170,22 +163,26 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle workflow issues correctly', async () => {
|
it('should execute workflow even if it has issues', async () => {
|
||||||
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
|
||||||
vi.mocked(uiStore).isActionActive.mockReturnValue(false);
|
vi.mocked(uiStore).isActionActive.mockReturnValue(false);
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
||||||
name: 'Test Workflow',
|
name: 'Test Workflow',
|
||||||
} as unknown as Workflow);
|
} as unknown as Workflow);
|
||||||
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = true;
|
vi.mocked(workflowsStore).nodesIssuesExist = true;
|
||||||
vi.mocked(nodeHelpers).refreshNodeIssues.mockImplementation(() => {});
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
|
||||||
vi.mocked(workflowHelpers).checkReadyForExecution.mockReturnValue({
|
id: 'workflowId',
|
||||||
someNode: { issues: { input: ['issue'] } },
|
nodes: [],
|
||||||
});
|
} as unknown as IWorkflowData);
|
||||||
|
vi.mocked(workflowsStore).getWorkflowRunData = {
|
||||||
|
NodeName: [],
|
||||||
|
};
|
||||||
|
|
||||||
const result = await runWorkflow({});
|
const result = await runWorkflow({});
|
||||||
expect(result).toBeUndefined();
|
expect(result).toEqual(mockExecutionResponse);
|
||||||
expect(toast.showMessage).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should execute workflow successfully', async () => {
|
it('should execute workflow successfully', async () => {
|
||||||
|
@ -198,7 +195,6 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
||||||
name: 'Test Workflow',
|
name: 'Test Workflow',
|
||||||
} as Workflow);
|
} as Workflow);
|
||||||
vi.mocked(nodeHelpers).refreshNodeIssues.mockImplementation(() => {});
|
|
||||||
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
|
||||||
id: 'workflowId',
|
id: 'workflowId',
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|
|
@ -11,17 +11,11 @@ import type {
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
ITaskData,
|
ITaskData,
|
||||||
IPinData,
|
IPinData,
|
||||||
IWorkflowBase,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
StartNodeData,
|
StartNodeData,
|
||||||
IRun,
|
IRun,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import { NodeConnectionType, FORM_TRIGGER_PATH_IDENTIFIER } from 'n8n-workflow';
|
||||||
NodeHelpers,
|
|
||||||
NodeConnectionType,
|
|
||||||
TelemetryHelpers,
|
|
||||||
FORM_TRIGGER_PATH_IDENTIFIER,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
|
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
@ -42,14 +36,12 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import type { useRouter } from 'vue-router';
|
import type { useRouter } from 'vue-router';
|
||||||
import { isEmpty } from '@/utils/typesUtils';
|
import { isEmpty } from '@/utils/typesUtils';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
|
|
||||||
export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }) {
|
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const workflowHelpers = useWorkflowHelpers({ router: options.router });
|
const workflowHelpers = useWorkflowHelpers({ router: useRunWorkflowOpts.router });
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { titleSet } = useTitleChange();
|
const { titleSet } = useTitleChange();
|
||||||
|
|
||||||
|
@ -106,79 +98,6 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
|
||||||
toast.clearAllStickyNotifications();
|
toast.clearAllStickyNotifications();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check first if the workflow has any issues before execute it
|
|
||||||
nodeHelpers.refreshNodeIssues();
|
|
||||||
const issuesExist = workflowsStore.nodesIssuesExist;
|
|
||||||
if (issuesExist) {
|
|
||||||
// If issues exist get all of the issues of all nodes
|
|
||||||
const workflowIssues = workflowHelpers.checkReadyForExecution(
|
|
||||||
workflow,
|
|
||||||
options.destinationNode,
|
|
||||||
);
|
|
||||||
if (workflowIssues !== null) {
|
|
||||||
const errorMessages = [];
|
|
||||||
let nodeIssues: string[];
|
|
||||||
const trackNodeIssues: Array<{
|
|
||||||
node_type: string;
|
|
||||||
error: string;
|
|
||||||
}> = [];
|
|
||||||
const trackErrorNodeTypes: string[] = [];
|
|
||||||
for (const nodeName of Object.keys(workflowIssues)) {
|
|
||||||
nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]);
|
|
||||||
let issueNodeType = 'UNKNOWN';
|
|
||||||
const issueNode = workflowsStore.getNodeByName(nodeName);
|
|
||||||
|
|
||||||
if (issueNode) {
|
|
||||||
issueNodeType = issueNode.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackErrorNodeTypes.push(issueNodeType);
|
|
||||||
const trackNodeIssue = {
|
|
||||||
node_type: issueNodeType,
|
|
||||||
error: '',
|
|
||||||
caused_by_credential: !!workflowIssues[nodeName].credentials,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const nodeIssue of nodeIssues) {
|
|
||||||
errorMessages.push(
|
|
||||||
`<a data-action='openNodeDetail' data-action-parameter-node='${nodeName}'>${nodeName}</a>: ${nodeIssue}`,
|
|
||||||
);
|
|
||||||
trackNodeIssue.error = trackNodeIssue.error.concat(', ', nodeIssue);
|
|
||||||
}
|
|
||||||
trackNodeIssues.push(trackNodeIssue);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.showMessage({
|
|
||||||
title: i18n.baseText('workflowRun.showMessage.title'),
|
|
||||||
message: errorMessages.join('<br />'),
|
|
||||||
type: 'error',
|
|
||||||
duration: 0,
|
|
||||||
});
|
|
||||||
titleSet(workflow.name as string, 'ERROR');
|
|
||||||
void useExternalHooks().run('workflowRun.runError', {
|
|
||||||
errorMessages,
|
|
||||||
nodeName: options.destinationNode,
|
|
||||||
});
|
|
||||||
|
|
||||||
await workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
|
|
||||||
telemetry.track('Workflow execution preflight failed', {
|
|
||||||
workflow_id: workflow.id,
|
|
||||||
workflow_name: workflow.name,
|
|
||||||
execution_type: options.destinationNode || options.triggerNode ? 'node' : 'workflow',
|
|
||||||
node_graph_string: JSON.stringify(
|
|
||||||
TelemetryHelpers.generateNodesGraph(
|
|
||||||
workflowData as IWorkflowBase,
|
|
||||||
workflowHelpers.getNodeTypes(),
|
|
||||||
).nodeGraph,
|
|
||||||
),
|
|
||||||
error_node_types: JSON.stringify(trackErrorNodeTypes),
|
|
||||||
errors: JSON.stringify(trackNodeIssues),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the direct parents of the node
|
// Get the direct parents of the node
|
||||||
let directParentNodes: string[] = [];
|
let directParentNodes: string[] = [];
|
||||||
if (options.destinationNode !== undefined) {
|
if (options.destinationNode !== undefined) {
|
||||||
|
@ -319,7 +238,7 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
|
||||||
executedNode,
|
executedNode,
|
||||||
data: {
|
data: {
|
||||||
resultData: {
|
resultData: {
|
||||||
runData: newRunData || {},
|
runData: newRunData ?? {},
|
||||||
pinData: workflowData.pinData,
|
pinData: workflowData.pinData,
|
||||||
workflowData,
|
workflowData,
|
||||||
},
|
},
|
||||||
|
@ -372,7 +291,9 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
|
||||||
node.parameters.resume === 'form' &&
|
node.parameters.resume === 'form' &&
|
||||||
runWorkflowApiResponse.executionId
|
runWorkflowApiResponse.executionId
|
||||||
) {
|
) {
|
||||||
const workflowTriggerNodes = workflow.getTriggerNodes().map((node) => node.name);
|
const workflowTriggerNodes = workflow
|
||||||
|
.getTriggerNodes()
|
||||||
|
.map((triggerNode) => triggerNode.name);
|
||||||
|
|
||||||
const showForm =
|
const showForm =
|
||||||
options.destinationNode === node.name ||
|
options.destinationNode === node.name ||
|
||||||
|
@ -383,7 +304,7 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
|
||||||
|
|
||||||
if (!showForm) continue;
|
if (!showForm) continue;
|
||||||
|
|
||||||
const { webhookSuffix } = (node.parameters.options || {}) as IDataObject;
|
const { webhookSuffix } = (node.parameters.options ?? {}) as IDataObject;
|
||||||
const suffix = webhookSuffix ? `/${webhookSuffix}` : '';
|
const suffix = webhookSuffix ? `/${webhookSuffix}` : '';
|
||||||
testUrl = `${rootStore.getFormWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
|
testUrl = `${rootStore.getFormWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -515,7 +515,7 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if everything in the workflow is complete and ready to be executed
|
/** Checks if everything in the workflow is complete and ready to be executed */
|
||||||
function checkReadyForExecution(workflow: Workflow, lastNodeName?: string) {
|
function checkReadyForExecution(workflow: Workflow, lastNodeName?: string) {
|
||||||
let node: INode;
|
let node: INode;
|
||||||
let nodeType: INodeType | undefined;
|
let nodeType: INodeType | undefined;
|
||||||
|
|
Loading…
Reference in a new issue