Skip to content

Commit

Permalink
feat: Allow workflow execution even if it has errors
Browse files Browse the repository at this point in the history
Backend does execute the workflow up to the node(s) that have
issues. That way the workflow can be partially tested even if it's
e.g. missing credentials.
  • Loading branch information
tomi committed Apr 3, 2024
1 parent 571b613 commit 4925aa8
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 103 deletions.
27 changes: 27 additions & 0 deletions cypress/e2e/19-execution.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,4 +592,31 @@ describe('Execution', () => {
cy.wait(100);
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‘`);
});
});
Original file line number Diff line number Diff line change
@@ -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": {}
}
26 changes: 11 additions & 15 deletions packages/editor-ui/src/composables/useRunWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { setActivePinia } from 'pinia';
import type { IStartRunData, IWorkflowData } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRouter } from 'vue-router';
import type { IPinData, IRunData, Workflow } from 'n8n-workflow';

Expand Down Expand Up @@ -70,7 +68,6 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({

vi.mock('@/composables/useNodeHelpers', () => ({
useNodeHelpers: vi.fn().mockReturnValue({
refreshNodeIssues: vi.fn(),
updateNodesExecutionIssues: vi.fn(),
}),
}));
Expand All @@ -94,9 +91,7 @@ describe('useRunWorkflow({ router })', () => {
let uiStore: ReturnType<typeof useUIStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let router: ReturnType<typeof useRouter>;
let toast: ReturnType<typeof useToast>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
let nodeHelpers: ReturnType<typeof useNodeHelpers>;

beforeAll(() => {
const pinia = createTestingPinia();
Expand All @@ -108,9 +103,7 @@ describe('useRunWorkflow({ router })', () => {
workflowsStore = useWorkflowsStore();

router = useRouter();
toast = useToast();
workflowHelpers = useWorkflowHelpers({ router });
nodeHelpers = useNodeHelpers();
});

describe('runWorkflowApi()', () => {
Expand Down Expand Up @@ -170,22 +163,26 @@ describe('useRunWorkflow({ router })', () => {
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 });

vi.mocked(uiStore).isActionActive.mockReturnValue(false);
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
name: 'Test Workflow',
} as unknown as Workflow);
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = true;
vi.mocked(nodeHelpers).refreshNodeIssues.mockImplementation(() => {});
vi.mocked(workflowHelpers).checkReadyForExecution.mockReturnValue({
someNode: { issues: { input: ['issue'] } },
});
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [],
} as unknown as IWorkflowData);
vi.mocked(workflowsStore).getWorkflowRunData = {
NodeName: [],
};

const result = await runWorkflow({});
expect(result).toBeUndefined();
expect(toast.showMessage).toHaveBeenCalled();
expect(result).toEqual(mockExecutionResponse);
});

it('should execute workflow successfully', async () => {
Expand All @@ -198,7 +195,6 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
name: 'Test Workflow',
} as Workflow);
vi.mocked(nodeHelpers).refreshNodeIssues.mockImplementation(() => {});
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [],
Expand Down
95 changes: 8 additions & 87 deletions packages/editor-ui/src/composables/useRunWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,10 @@ import type {
IRunExecutionData,
ITaskData,
IPinData,
IWorkflowBase,
Workflow,
StartNodeData,
} from 'n8n-workflow';
import {
NodeHelpers,
NodeConnectionType,
TelemetryHelpers,
FORM_TRIGGER_PATH_IDENTIFIER,
} from 'n8n-workflow';
import { NodeConnectionType, FORM_TRIGGER_PATH_IDENTIFIER } from 'n8n-workflow';

import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
Expand All @@ -40,14 +34,12 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
import { isEmpty } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
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 workflowHelpers = useWorkflowHelpers({ router: options.router });
const workflowHelpers = useWorkflowHelpers({ router: useRunWorkflowOpts.router });
const i18n = useI18n();
const telemetry = useTelemetry();
const toast = useToast();
const { titleSet } = useTitleChange();

Expand Down Expand Up @@ -104,79 +96,6 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
toast.clearAllStickyNotifications();

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
let directParentNodes: string[] = [];
if (options.destinationNode !== undefined) {
Expand Down Expand Up @@ -317,7 +236,7 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
executedNode,
data: {
resultData: {
runData: newRunData || {},
runData: newRunData ?? {},
pinData: workflowData.pinData,
workflowData,
},
Expand Down Expand Up @@ -370,7 +289,9 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
node.parameters.resume === 'form' &&
runWorkflowApiResponse.executionId
) {
const workflowTriggerNodes = workflow.getTriggerNodes().map((node) => node.name);
const workflowTriggerNodes = workflow
.getTriggerNodes()
.map((triggerNode) => triggerNode.name);

const showForm =
options.destinationNode === node.name ||
Expand All @@ -381,7 +302,7 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }

if (!showForm) continue;

const { webhookSuffix } = (node.parameters.options || {}) as IDataObject;
const { webhookSuffix } = (node.parameters.options ?? {}) as IDataObject;
const suffix = webhookSuffix ? `/${webhookSuffix}` : '';
testUrl = `${rootStore.getFormWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/editor-ui/src/composables/useWorkflowHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
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) {
let node: INode;
let nodeType: INodeType | undefined;
Expand Down

0 comments on commit 4925aa8

Please sign in to comment.