diff --git a/cypress/composables/executions.ts b/cypress/composables/executions.ts new file mode 100644 index 0000000000..cd07eb7e6a --- /dev/null +++ b/cypress/composables/executions.ts @@ -0,0 +1,29 @@ +/** + * Getters + */ + +export const getExecutionsSidebar = () => cy.getByTestId('executions-sidebar'); + +export const getWorkflowExecutionPreviewIframe = () => cy.getByTestId('workflow-preview-iframe'); + +export const getExecutionPreviewBody = () => + getWorkflowExecutionPreviewIframe() + .its('0.contentDocument.body') + .then((el) => cy.wrap(el)); + +export const getExecutionPreviewBodyNodes = () => + getExecutionPreviewBody().findChildByTestId('canvas-node'); + +export const getExecutionPreviewBodyNodesByName = (name: string) => + getExecutionPreviewBody().findChildByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0); + +export function getExecutionPreviewOutputPanelRelatedExecutionLink() { + return getExecutionPreviewBody().findChildByTestId('related-execution-link'); +} + +/** + * Actions + */ + +export const openExecutionPreviewNode = (name: string) => + getExecutionPreviewBodyNodesByName(name).dblclick(); diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index b7ea33cb69..05d783ec5e 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -48,10 +48,38 @@ export function getOutputTableRow(row: number) { return getOutputTableRows().eq(row); } +export function getOutputTableHeaders() { + return getOutputPanelDataContainer().find('table thead th'); +} + +export function getOutputTableHeaderByText(text: string) { + return getOutputTableHeaders().contains(text); +} + +export function getOutputTbodyCell(row: number, col: number) { + return getOutputTableRows().eq(row).find('td').eq(col); +} + +export function getOutputRunSelector() { + return getOutputPanel().findChildByTestId('run-selector'); +} + +export function getOutputRunSelectorInput() { + return getOutputRunSelector().find('input'); +} + export function getOutputPanelTable() { return getOutputPanelDataContainer().get('table'); } +export function getOutputPanelItemsCount() { + return getOutputPanel().getByTestId('ndv-items-count'); +} + +export function getOutputPanelRelatedExecutionLink() { + return getOutputPanel().getByTestId('related-execution-link'); +} + /** * Actions */ @@ -90,3 +118,8 @@ export function setParameterSelectByContent(name: string, content: string) { getParameterInputByName(name).realClick(); getVisibleSelect().find('.option-headline').contains(content).click(); } + +export function changeOutputRunSelector(runName: string) { + getOutputRunSelector().click(); + getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click(); +} diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index bab18587e0..251db6e75d 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -76,6 +76,14 @@ export function getCanvasNodes() { ); } +export function getSaveButton() { + return cy.getByTestId('workflow-save-button'); +} + +export function getZoomToFitButton() { + return cy.getByTestId('zoom-to-fit'); +} + /** * Actions */ @@ -170,3 +178,19 @@ export function clickManualChatButton() { export function openNode(nodeName: string) { getNodeByName(nodeName).dblclick(); } + +export function saveWorkflowOnButtonClick() { + cy.intercept('POST', '/rest/workflows').as('createWorkflow'); + getSaveButton().should('contain', 'Save'); + getSaveButton().click(); + getSaveButton().should('contain', 'Saved'); + cy.url().should('not.have.string', '/new'); +} + +export function pasteWorkflow(workflow: object) { + cy.get('body').paste(JSON.stringify(workflow)); +} + +export function clickZoomToFit() { + getZoomToFitButton().click(); +} diff --git a/cypress/e2e/24-ndv-paired-item.cy.ts b/cypress/e2e/24-ndv-paired-item.cy.ts index 1261a0fcd1..49257a8a12 100644 --- a/cypress/e2e/24-ndv-paired-item.cy.ts +++ b/cypress/e2e/24-ndv-paired-item.cy.ts @@ -31,29 +31,31 @@ describe('NDV', () => { ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.inputTableRow(1).realHover(); + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTableRow(1).realMouseMove(10, 1); ndv.getters.outputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.inputTableRow(2).realHover(); + ndv.getters.inputTableRow(2).realMouseMove(10, 1); ndv.getters.outputTableRow(2).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.inputTableRow(3).realHover(); + ndv.getters.inputTableRow(3).realMouseMove(10, 1); ndv.getters.outputTableRow(6).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); // output to input - ndv.getters.outputTableRow(1).realHover(); + ndv.actions.dragMainPanelToLeft(); + ndv.getters.outputTableRow(1).realMouseMove(10, 1); ndv.getters.inputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.outputTableRow(4).realHover(); + ndv.getters.outputTableRow(4).realMouseMove(10, 1); ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.outputTableRow(2).realHover(); + ndv.getters.outputTableRow(2).realMouseMove(10, 1); ndv.getters.inputTableRow(2).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.outputTableRow(6).realHover(); + ndv.getters.outputTableRow(6).realMouseMove(10, 1); ndv.getters.inputTableRow(3).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.outputTableRow(1).realHover(); + ndv.getters.outputTableRow(1).realMouseMove(10, 1); ndv.getters.inputTableRow(4).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); }); @@ -75,31 +77,32 @@ describe('NDV', () => { ndv.actions.switchInputMode('Table'); ndv.actions.switchOutputMode('Table'); - ndv.getters.backToCanvas().realHover(); // reset to default hover + ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover ndv.getters.outputHoveringItem().should('not.exist'); ndv.getters.parameterExpressionPreview('value').should('include.text', '1111'); ndv.actions.selectInputNode('Set1'); - ndv.getters.backToCanvas().realHover(); // reset to default hover + ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover ndv.getters.inputTableRow(1).should('have.text', '1000'); ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.inputTableRow(1).realHover(); - cy.wait(50); + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTbodyCell(1, 0).realMouseMove(10, 1); ndv.getters.outputHoveringItem().should('have.text', '1000'); ndv.getters.parameterExpressionPreview('value').should('include.text', '1000'); ndv.actions.selectInputNode('Sort'); + ndv.actions.dragMainPanelToLeft(); ndv.actions.changeOutputRunSelector('1 of 2 (6 items)'); - ndv.getters.backToCanvas().realHover(); // reset to default hover + ndv.getters.backToCanvas().realMouseMove(10, 1); // reset to default hover ndv.getters.inputTableRow(1).should('have.text', '1111'); ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); - ndv.getters.inputTableRow(1).realHover(); - cy.wait(50); + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTbodyCell(1, 0).realMouseMove(10, 1); ndv.getters.outputHoveringItem().should('have.text', '1111'); ndv.getters.parameterExpressionPreview('value').should('include.text', '1111'); }); @@ -132,20 +135,22 @@ describe('NDV', () => { ndv.getters.inputTableRow(1).should('have.text', '1111'); ndv.getters.inputTableRow(1).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); + + ndv.actions.dragMainPanelToLeft(); ndv.getters.outputTableRow(1).should('have.text', '1111'); - ndv.getters.outputTableRow(1).realHover(); + ndv.getters.outputTableRow(1).realMouseMove(10, 1); ndv.getters.outputTableRow(3).should('have.text', '4444'); - ndv.getters.outputTableRow(3).realHover(); + ndv.getters.outputTableRow(3).realMouseMove(10, 1); ndv.getters.inputTableRow(3).should('have.text', '4444'); ndv.getters.inputTableRow(3).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); ndv.actions.changeOutputRunSelector('2 of 2 (6 items)'); - cy.wait(50); ndv.getters.inputTableRow(1).should('have.text', '1000'); - ndv.getters.inputTableRow(1).realHover(); + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTableRow(1).realMouseMove(10, 1); ndv.getters.outputTableRow(1).should('have.text', '1000'); ndv.getters @@ -155,7 +160,8 @@ describe('NDV', () => { .should('equal', 'hovering-item'); ndv.getters.outputTableRow(3).should('have.text', '2000'); - ndv.getters.outputTableRow(3).realHover(); + ndv.actions.dragMainPanelToLeft(); + ndv.getters.outputTableRow(3).realMouseMove(10, 1); ndv.getters.inputTableRow(3).should('have.text', '2000'); @@ -175,14 +181,15 @@ describe('NDV', () => { ndv.actions.switchOutputBranch('False Branch (2 items)'); ndv.getters.outputTableRow(1).should('have.text', '8888'); - ndv.getters.outputTableRow(1).realHover(); + ndv.actions.dragMainPanelToLeft(); + ndv.getters.outputTableRow(1).realMouseMove(10, 1); ndv.getters.inputTableRow(5).should('have.text', '8888'); ndv.getters.inputTableRow(5).invoke('attr', 'data-test-id').should('equal', 'hovering-item'); ndv.getters.outputTableRow(2).should('have.text', '9999'); - ndv.getters.outputTableRow(2).realHover(); + ndv.getters.outputTableRow(2).realMouseMove(10, 1); ndv.getters.inputTableRow(6).should('have.text', '9999'); @@ -192,29 +199,35 @@ describe('NDV', () => { workflowPage.actions.openNode('Set5'); + ndv.actions.dragMainPanelToRight(); ndv.actions.switchInputBranch('True Branch'); + + ndv.actions.dragMainPanelToLeft(); ndv.actions.changeOutputRunSelector('1 of 2 (2 items)'); ndv.getters.outputTableRow(1).should('have.text', '8888'); - ndv.getters.outputTableRow(1).realHover(); - cy.wait(100); + ndv.getters.outputTableRow(1).realMouseMove(10, 1); ndv.getters.inputHoveringItem().should('not.exist'); ndv.getters.inputTableRow(1).should('have.text', '1111'); - ndv.getters.inputTableRow(1).realHover(); - cy.wait(100); + + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTableRow(1).realMouseMove(10, 1); ndv.getters.outputHoveringItem().should('not.exist'); ndv.actions.switchInputBranch('False Branch'); ndv.getters.inputTableRow(1).should('have.text', '8888'); - ndv.getters.inputTableRow(1).realHover(); + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTableRow(1).realMouseMove(10, 1); + ndv.actions.dragMainPanelToLeft(); ndv.actions.changeOutputRunSelector('2 of 2 (4 items)'); ndv.getters.outputTableRow(1).should('have.text', '1111'); - ndv.getters.outputTableRow(1).realHover(); + ndv.getters.outputTableRow(1).realMouseMove(10, 1); ndv.actions.changeOutputRunSelector('1 of 2 (2 items)'); ndv.getters.inputTableRow(1).should('have.text', '8888'); - ndv.getters.inputTableRow(1).realHover(); + ndv.actions.dragMainPanelToRight(); + ndv.getters.inputTableRow(1).realMouseMove(10, 1); ndv.getters.outputHoveringItem().should('have.text', '8888'); // todo there's a bug here need to fix ADO-534 // ndv.getters.outputHoveringItem().should('not.exist'); diff --git a/cypress/e2e/47-subworkflow-debugging.cy.ts b/cypress/e2e/47-subworkflow-debugging.cy.ts new file mode 100644 index 0000000000..f808bdd044 --- /dev/null +++ b/cypress/e2e/47-subworkflow-debugging.cy.ts @@ -0,0 +1,140 @@ +import { + getExecutionPreviewOutputPanelRelatedExecutionLink, + getExecutionsSidebar, + getWorkflowExecutionPreviewIframe, + openExecutionPreviewNode, +} from '../composables/executions'; +import { + changeOutputRunSelector, + getOutputPanelItemsCount, + getOutputPanelRelatedExecutionLink, + getOutputRunSelectorInput, + getOutputTableHeaders, + getOutputTableRows, + getOutputTbodyCell, +} from '../composables/ndv'; +import { + clickExecuteWorkflowButton, + clickZoomToFit, + getCanvasNodes, + navigateToNewWorkflowPage, + openNode, + pasteWorkflow, + saveWorkflowOnButtonClick, +} from '../composables/workflow'; +import SUBWORKFLOW_DEBUGGING_EXAMPLE from '../fixtures/Subworkflow-debugging-execute-workflow.json'; + +describe('Subworkflow debugging', () => { + beforeEach(() => { + navigateToNewWorkflowPage(); + pasteWorkflow(SUBWORKFLOW_DEBUGGING_EXAMPLE); + saveWorkflowOnButtonClick(); + getCanvasNodes().should('have.length', 11); + clickZoomToFit(); + + clickExecuteWorkflowButton(); + }); + + describe('can inspect sub executed workflow', () => { + it('(Run once with all items/ Wait for Sub-workflow completion) (default behavior)', () => { + openNode('Execute Workflow with param'); + + getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution'); + getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution'); + getOutputPanelRelatedExecutionLink().should('have.attr', 'href'); + + // ensure workflow executed and waited on output + getOutputTableHeaders().should('have.length', 2); + getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore'); + }); + + it('(Run once for each item/ Wait for Sub-workflow completion)', () => { + openNode('Execute Workflow with param1'); + + getOutputPanelItemsCount().should('contain.text', '2 items, 2 sub-execution'); + getOutputPanelRelatedExecutionLink().should('not.exist'); + + // ensure workflow executed and waited on output + getOutputTableHeaders().should('have.length', 3); + getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href'); + getOutputTbodyCell(1, 1).should('have.text', 'world Natalie Moore'); + }); + + it('(Run once with all items/ Wait for Sub-workflow completion)', () => { + openNode('Execute Workflow with param2'); + + getOutputPanelItemsCount().should('not.exist'); + getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution'); + getOutputPanelRelatedExecutionLink().should('have.attr', 'href'); + + // ensure workflow executed but returned same data as input + getOutputRunSelectorInput().should('have.value', '2 of 2 (3 items, 1 sub-execution)'); + getOutputTableHeaders().should('have.length', 6); + getOutputTableHeaders().eq(0).should('have.text', 'uid'); + getOutputTableRows().should('have.length', 4); + getOutputTbodyCell(1, 1).should('include.text', 'Jon_Ebert@yahoo.com'); + + changeOutputRunSelector('1 of 2 (2 items, 1 sub-execution)'); + getOutputRunSelectorInput().should('have.value', '1 of 2 (2 items, 1 sub-execution)'); + getOutputTableHeaders().should('have.length', 6); + getOutputTableHeaders().eq(0).should('have.text', 'uid'); + getOutputTableRows().should('have.length', 3); + getOutputTbodyCell(1, 1).should('include.text', 'Terry.Dach@hotmail.com'); + }); + + it('(Run once for each item/ Wait for Sub-workflow completion)', () => { + openNode('Execute Workflow with param3'); + + // ensure workflow executed but returned same data as input + getOutputRunSelectorInput().should('have.value', '2 of 2 (3 items, 3 sub-executions)'); + getOutputTableHeaders().should('have.length', 7); + getOutputTableHeaders().eq(1).should('have.text', 'uid'); + getOutputTableRows().should('have.length', 4); + getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href'); + getOutputTbodyCell(1, 2).should('include.text', 'Jon_Ebert@yahoo.com'); + + changeOutputRunSelector('1 of 2 (2 items, 2 sub-executions)'); + getOutputRunSelectorInput().should('have.value', '1 of 2 (2 items, 2 sub-executions)'); + getOutputTableHeaders().should('have.length', 7); + getOutputTableHeaders().eq(1).should('have.text', 'uid'); + getOutputTableRows().should('have.length', 3); + + getOutputTbodyCell(1, 0).find('a').should('have.attr', 'href'); + getOutputTbodyCell(1, 2).should('include.text', 'Terry.Dach@hotmail.com'); + }); + }); + + it('can inspect parent executions', () => { + cy.url().then((workflowUrl) => { + openNode('Execute Workflow with param'); + + getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution'); + getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution'); + getOutputPanelRelatedExecutionLink().should('have.attr', 'href'); + + // ensure workflow executed and waited on output + getOutputTableHeaders().should('have.length', 2); + getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore'); + + // cypress cannot handle new tabs so removing it + getOutputPanelRelatedExecutionLink().invoke('removeAttr', 'target').click(); + + getExecutionsSidebar().should('be.visible'); + getWorkflowExecutionPreviewIframe().should('be.visible'); + openExecutionPreviewNode('Execute Workflow Trigger'); + + getExecutionPreviewOutputPanelRelatedExecutionLink().should( + 'include.text', + 'Inspect Parent Execution', + ); + + getExecutionPreviewOutputPanelRelatedExecutionLink() + .invoke('removeAttr', 'target') + .click({ force: true }); + + cy.url().then((currentUrl) => { + expect(currentUrl === workflowUrl); + }); + }); + }); +}); diff --git a/cypress/fixtures/Subworkflow-debugging-execute-workflow.json b/cypress/fixtures/Subworkflow-debugging-execute-workflow.json new file mode 100644 index 0000000000..c336a80b41 --- /dev/null +++ b/cypress/fixtures/Subworkflow-debugging-execute-workflow.json @@ -0,0 +1,354 @@ +{ + "meta": { + "instanceId": "08ce71ad998aeaade0abedb8dd96153d8eaa03fcb84cfccc1530095bf9ee478e" + }, + "nodes": [ + { + "parameters": {}, + "id": "4535ce3e-280e-49b0-8854-373472ec86d1", + "name": "When clicking ‘Test workflow’", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [80, 860] + }, + { + "parameters": { + "category": "randomData", + "randomDataSeed": "0", + "randomDataCount": 2 + }, + "id": "d7fba18a-d51f-4509-af45-68cd9425ac6b", + "name": "DebugHelper1", + "type": "n8n-nodes-base.debugHelper", + "typeVersion": 1, + "position": [280, 860] + }, + { + "parameters": { + "source": "parameter", + "workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}", + "mode": "each", + "options": { + "waitForSubWorkflow": false + } + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [680, 1540], + "id": "f90a25da-dd89-4bf8-8f5b-bf8ee1de0b70", + "name": "Execute Workflow with param3" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "c93f26bd-3489-467b-909e-6462e1463707", + "name": "uid", + "value": "={{ $json.uid }}", + "type": "string" + }, + { + "id": "3dd706ce-d925-4219-8531-ad12369972fe", + "name": "email", + "value": "={{ $json.email }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [900, 1540], + "id": "3be57648-3be8-4b0f-abfa-8fdcafee804d", + "name": "Edit Fields8" + }, + { + "parameters": { + "source": "parameter", + "workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}", + "options": { + "waitForSubWorkflow": false + } + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [620, 1220], + "id": "dabc2356-3660-4d17-b305-936a002029ba", + "name": "Execute Workflow with param2" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "c93f26bd-3489-467b-909e-6462e1463707", + "name": "uid", + "value": "={{ $json.uid }}", + "type": "string" + }, + { + "id": "3dd706ce-d925-4219-8531-ad12369972fe", + "name": "email", + "value": "={{ $json.email }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [840, 1220], + "id": "9d2a9dda-e2a1-43e8-a66f-a8a555692e5f", + "name": "Edit Fields7" + }, + { + "parameters": { + "source": "parameter", + "workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}", + "mode": "each", + "options": { + "waitForSubWorkflow": true + } + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [560, 900], + "id": "07e47f60-622a-484c-ab24-35f6f2280595", + "name": "Execute Workflow with param1" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "c93f26bd-3489-467b-909e-6462e1463707", + "name": "uid", + "value": "={{ $json.uid }}", + "type": "string" + }, + { + "id": "3dd706ce-d925-4219-8531-ad12369972fe", + "name": "email", + "value": "={{ $json.email }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [760, 900], + "id": "80563d0a-0bab-444f-a04c-4041a505d78b", + "name": "Edit Fields6" + }, + { + "parameters": { + "source": "parameter", + "workflowJson": "{\n \"meta\": {\n \"instanceId\": \"a786b722078489c1fa382391a9f3476c2784761624deb2dfb4634827256d51a0\"\n },\n \"nodes\": [\n {\n \"parameters\": {},\n \"type\": \"n8n-nodes-base.executeWorkflowTrigger\",\n \"typeVersion\": 1,\n \"position\": [\n 0,\n 0\n ],\n \"id\": \"00600a51-e63a-4b6e-93f5-f01d50a21e0c\",\n \"name\": \"Execute Workflow Trigger\"\n },\n {\n \"parameters\": {\n \"assignments\": {\n \"assignments\": [\n {\n \"id\": \"87ff01af-2e28-48da-ae6c-304040200b15\",\n \"name\": \"hello\",\n \"value\": \"=world {{ $json.firstname }} {{ $json.lastname }}\",\n \"type\": \"string\"\n }\n ]\n },\n \"includeOtherFields\": false,\n \"options\": {}\n },\n \"type\": \"n8n-nodes-base.set\",\n \"typeVersion\": 3.4,\n \"position\": [\n 280,\n 0\n ],\n \"id\": \"642219a1-d655-4a30-af5c-fcccbb690322\",\n \"name\": \"Edit Fields\"\n }\n ],\n \"connections\": {\n \"Execute Workflow Trigger\": {\n \"main\": [\n [\n {\n \"node\": \"Edit Fields\",\n \"type\": \"main\",\n \"index\": 0\n }\n ]\n ]\n }\n },\n \"pinData\": {}\n}", + "options": { + "waitForSubWorkflow": true + } + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [560, 580], + "id": "f04af481-f4d9-4d91-a60a-a377580e8393", + "name": "Execute Workflow with param" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "c93f26bd-3489-467b-909e-6462e1463707", + "name": "uid", + "value": "={{ $json.uid }}", + "type": "string" + }, + { + "id": "3dd706ce-d925-4219-8531-ad12369972fe", + "name": "email", + "value": "={{ $json.email }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [760, 580], + "id": "80c10607-a0ac-4090-86a1-890da0a2aa52", + "name": "Edit Fields2" + }, + { + "parameters": { + "content": "## Execute Workflow (Run once with all items/ DONT Wait for Sub-workflow completion)", + "height": 254.84308966329985, + "width": 457.58120569815793 + }, + "id": "534ef523-3453-4a16-9ff0-8ac9f025d47d", + "name": "Sticky Note5", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [500, 1080] + }, + { + "parameters": { + "content": "## Execute Workflow (Run once with for each item/ DONT Wait for Sub-workflow completion) ", + "height": 284.59778445962905, + "width": 457.58120569815793 + }, + "id": "838f0fa3-5ee4-4d1a-afb8-42e009f1aa9e", + "name": "Sticky Note4", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [580, 1400] + }, + { + "parameters": { + "category": "randomData", + "randomDataSeed": "1", + "randomDataCount": 3 + }, + "id": "86699a49-2aa7-488e-8ea9-828404c98f08", + "name": "DebugHelper", + "type": "n8n-nodes-base.debugHelper", + "typeVersion": 1, + "position": [320, 1120] + }, + { + "parameters": { + "content": "## Execute Workflow (Run once with for each item/ Wait for Sub-workflow completion) ", + "height": 284.59778445962905, + "width": 457.58120569815793 + }, + "id": "885d35f0-8ae6-45ec-821b-a82c27e7577a", + "name": "Sticky Note3", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [480, 760] + }, + { + "parameters": { + "content": "## Execute Workflow (Run once with all items/ Wait for Sub-workflow completion) (default behavior)", + "height": 254.84308966329985, + "width": 457.58120569815793 + }, + "id": "505bd7f2-767e-41b8-9325-77300aed5883", + "name": "Sticky Note2", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [460, 460] + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "DebugHelper1", + "type": "main", + "index": 0 + }, + { + "node": "DebugHelper", + "type": "main", + "index": 0 + } + ] + ] + }, + "DebugHelper1": { + "main": [ + [ + { + "node": "Execute Workflow with param3", + "type": "main", + "index": 0 + }, + { + "node": "Execute Workflow with param2", + "type": "main", + "index": 0 + }, + { + "node": "Execute Workflow with param1", + "type": "main", + "index": 0 + }, + { + "node": "Execute Workflow with param", + "type": "main", + "index": 0 + } + ] + ] + }, + "Execute Workflow with param3": { + "main": [ + [ + { + "node": "Edit Fields8", + "type": "main", + "index": 0 + } + ] + ] + }, + "Execute Workflow with param2": { + "main": [ + [ + { + "node": "Edit Fields7", + "type": "main", + "index": 0 + } + ] + ] + }, + "Execute Workflow with param1": { + "main": [ + [ + { + "node": "Edit Fields6", + "type": "main", + "index": 0 + } + ] + ] + }, + "Execute Workflow with param": { + "main": [ + [ + { + "node": "Edit Fields2", + "type": "main", + "index": 0 + } + ] + ] + }, + "DebugHelper": { + "main": [ + [ + { + "node": "Execute Workflow with param2", + "type": "main", + "index": 0 + }, + { + "node": "Execute Workflow with param3", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {} +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 4504552e26..516a0a1ea8 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -323,6 +323,12 @@ export class NDV extends BasePage { addItemToFixedCollection: (paramName: string) => { this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click(); }, + dragMainPanelToLeft: () => { + cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true }); + }, + dragMainPanelToRight: () => { + cy.drag('[data-test-id=panel-drag-button]', [1000, 0], { moveTwice: true }); + }, }; } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 6cad68b34f..bc5a18a34f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -177,6 +177,16 @@ Cypress.Commands.add('drag', (selector, pos, options) => { pageY: newPosition.y, force: true, }); + if (options?.moveTwice) { + // first move like hover to trigger object to be visible + // like in main panel in ndv + element.trigger('mousemove', { + which: 1, + pageX: newPosition.x, + pageY: newPosition.y, + force: true, + }); + } if (options?.clickToFinish) { // Click to finish the drag // For some reason, mouseup isn't working when moving nodes diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 6be4631503..864e64f1c4 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -59,7 +59,13 @@ declare global { drag( selector: string | Chainable>, target: [number, number], - options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean }, + options?: { + abs?: boolean; + index?: number; + realMouse?: boolean; + clickToFinish?: boolean; + moveTwice?: boolean; + }, ): void; draganddrop( draggableSelector: string, diff --git a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts index 0aafabf1d4..bbee553999 100644 --- a/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/retrievers/RetrieverWorkflow/RetrieverWorkflow.node.ts @@ -10,6 +10,7 @@ import type { INodeTypeDescription, SupplyData, INodeParameterResourceLocator, + ExecuteWorkflowData, } from 'n8n-workflow'; import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; @@ -293,6 +294,8 @@ export class RetrieverWorkflow implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const workflowProxy = this.getWorkflowDataProxy(0); + class WorkflowRetriever extends BaseRetriever { lc_namespace = ['n8n-nodes-langchain', 'retrievers', 'workflow']; @@ -349,6 +352,9 @@ export class RetrieverWorkflow implements INodeType { }, ); } + + // same as current workflow + baseMetadata.workflowId = workflowProxy.$workflow.id; } const rawData: IDataObject = { query }; @@ -384,21 +390,29 @@ export class RetrieverWorkflow implements INodeType { const items = [newItem] as INodeExecutionData[]; - let receivedItems: INodeExecutionData[][]; + let receivedData: ExecuteWorkflowData; try { - receivedItems = (await this.executeFunctions.executeWorkflow( + receivedData = await this.executeFunctions.executeWorkflow( workflowInfo, items, config?.getChild(), - )) as INodeExecutionData[][]; + { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, + }, + }, + ); } catch (error) { // Make sure a valid error gets returned that can by json-serialized else it will // not show up in the frontend throw new NodeOperationError(this.executeFunctions.getNode(), error as Error); } + const receivedItems = receivedData.data?.[0] ?? []; + const returnData: Document[] = []; - for (const [index, itemData] of receivedItems[0].entries()) { + for (const [index, itemData] of receivedItems.entries()) { const pageContent = objectToString(itemData.json); returnData.push( new Document({ @@ -406,6 +420,7 @@ export class RetrieverWorkflow implements INodeType { metadata: { ...baseMetadata, itemIndex: index, + executionId: receivedData.executionId, }, }), ); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index f912e162d9..b76bcc8ec3 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -14,8 +14,10 @@ import type { ISupplyDataFunctions, SupplyData, ExecutionError, + ExecuteWorkflowData, IDataObject, INodeParameterResourceLocator, + ITaskMetadata, } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; @@ -358,9 +360,14 @@ export class ToolWorkflow implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const workflowProxy = this.getWorkflowDataProxy(0); + const name = this.getNodeParameter('name', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex) as string; + let subExecutionId: string | undefined; + let subWorkflowId: string | undefined; + const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; @@ -396,11 +403,16 @@ export class ToolWorkflow implements INodeType { ) as INodeParameterResourceLocator; workflowInfo.id = value as string; } + + subWorkflowId = workflowInfo.id; } else if (source === 'parameter') { // Read workflow from parameter const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; try { workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; + + // subworkflow is same as parent workflow + subWorkflowId = workflowProxy.$workflow.id; } catch (error) { throw new NodeOperationError( this.getNode(), @@ -440,13 +452,15 @@ export class ToolWorkflow implements INodeType { const items = [newItem] as INodeExecutionData[]; - let receivedData: INodeExecutionData; + let receivedData: ExecuteWorkflowData; try { - receivedData = (await this.executeWorkflow( - workflowInfo, - items, - runManager?.getChild(), - )) as INodeExecutionData; + receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, + }, + }); + subExecutionId = receivedData.executionId; } catch (error) { // Make sure a valid error gets returned that can by json-serialized else it will // not show up in the frontend @@ -454,6 +468,7 @@ export class ToolWorkflow implements INodeType { } const response: string | undefined = get(receivedData, [ + 'data', 0, 0, 'json', @@ -503,10 +518,25 @@ export class ToolWorkflow implements INodeType { response = `There was an error: "${executionError.message}"`; } + let metadata: ITaskMetadata | undefined; + if (subExecutionId && subWorkflowId) { + metadata = { + subExecution: { + executionId: subExecutionId, + workflowId: subWorkflowId, + }, + }; + } + if (executionError) { - void this.addOutputData(NodeConnectionType.AiTool, index, executionError); + void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata); } else { - void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); + void this.addOutputData( + NodeConnectionType.AiTool, + index, + [[{ json: { response } }]], + metadata, + ); } return response; }; diff --git a/packages/@n8n/nodes-langchain/utils/logWrapper.ts b/packages/@n8n/nodes-langchain/utils/logWrapper.ts index 10e55ba6ef..fa1a38b31a 100644 --- a/packages/@n8n/nodes-langchain/utils/logWrapper.ts +++ b/packages/@n8n/nodes-langchain/utils/logWrapper.ts @@ -10,7 +10,12 @@ import type { Tool } from '@langchain/core/tools'; import { VectorStore } from '@langchain/core/vectorstores'; import { TextSplitter } from '@langchain/textsplitters'; import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base'; -import type { IExecuteFunctions, INodeExecutionData, ISupplyDataFunctions } from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + ISupplyDataFunctions, + ITaskMetadata, +} from 'n8n-workflow'; import { NodeOperationError, NodeConnectionType } from 'n8n-workflow'; import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from './helpers'; @@ -220,8 +225,24 @@ export function logWrapper( arguments: [query, config], })) as Array>>; + const executionId: string | undefined = response[0]?.metadata?.executionId as string; + const workflowId: string | undefined = response[0]?.metadata?.workflowId as string; + + const metadata: ITaskMetadata = {}; + if (executionId && workflowId) { + metadata.subExecution = { + executionId, + workflowId, + }; + } + logAiEvent(executeFunctions, 'ai-documents-retrieved', { query }); - executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]); + executeFunctions.addOutputData( + connectionType, + index, + [[{ json: { response } }]], + metadata, + ); return response; }; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 3f2b0397ab..db203199f7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", "test": "pnpm test:sqlite", + "test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch", "test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest", "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --no-coverage", "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --no-coverage", diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index 88aee51540..d0aeb3111f 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -1,9 +1,11 @@ import { mock } from 'jest-mock-extended'; -import type { - IExecuteWorkflowInfo, - IWorkflowExecuteAdditionalData, - ExecuteWorkflowOptions, - IRun, +import type { IWorkflowBase } from 'n8n-workflow'; +import { + type IExecuteWorkflowInfo, + type IWorkflowExecuteAdditionalData, + type ExecuteWorkflowOptions, + type IRun, + type INodeExecutionData, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; import Container from 'typedi'; @@ -21,43 +23,59 @@ import { WorkflowStatisticsService } from '@/services/workflow-statistics.servic import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; import { Telemetry } from '@/telemetry'; import { PermissionChecker } from '@/user-management/permission-checker'; -import { executeWorkflow, getBase } from '@/workflow-execute-additional-data'; +import { executeWorkflow, getBase, getRunData } from '@/workflow-execute-additional-data'; import { mockInstance } from '@test/mocking'; -const run = mock({ - data: { resultData: {} }, - finished: true, - mode: 'manual', - startedAt: new Date(), - status: 'new', -}); +const EXECUTION_ID = '123'; +const LAST_NODE_EXECUTED = 'Last node executed'; -const cancelablePromise = mock>({ - then: jest - .fn() - .mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)), - catch: jest - .fn() - .mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)), - finally: jest - .fn() - .mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)), - [Symbol.toStringTag]: 'PCancelable', -}); +const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array }) => + mock({ + data: { + resultData: { + runData: { + [LAST_NODE_EXECUTED]: [ + { + startTime: 100, + data: { + main: lastNodeOutput, + }, + }, + ], + }, + lastNodeExecuted: LAST_NODE_EXECUTED, + }, + }, + finished: true, + mode: 'manual', + startedAt: new Date(), + status: 'new', + }); + +const getCancelablePromise = async (run: IRun) => + await mock>({ + then: jest + .fn() + .mockImplementation(async (onfulfilled) => await Promise.resolve(run).then(onfulfilled)), + catch: jest + .fn() + .mockImplementation(async (onrejected) => await Promise.resolve(run).catch(onrejected)), + finally: jest + .fn() + .mockImplementation(async (onfinally) => await Promise.resolve(run).finally(onfinally)), + [Symbol.toStringTag]: 'PCancelable', + }); + +const processRunExecutionData = jest.fn(); jest.mock('n8n-core', () => ({ __esModule: true, ...jest.requireActual('n8n-core'), WorkflowExecute: jest.fn().mockImplementation(() => ({ - processRunExecutionData: jest.fn().mockReturnValue(cancelablePromise), + processRunExecutionData, })), })); -jest.mock('../workflow-helpers', () => ({ - ...jest.requireActual('../workflow-helpers'), - getDataLastExecutedNodeData: jest.fn().mockReturnValue({ data: { main: [] } }), -})); - describe('WorkflowExecuteAdditionalData', () => { const variablesService = mockInstance(VariablesService); variablesService.getAllCached.mockResolvedValue([]); @@ -95,17 +113,129 @@ describe('WorkflowExecuteAdditionalData', () => { expect(eventService.emit).toHaveBeenCalledWith(eventName, payload); }); - it('`executeWorkflow` should set subworkflow execution as running', async () => { - const executionId = '123'; - workflowRepository.get.mockResolvedValue(mock({ id: executionId, nodes: [] })); - activeExecutions.add.mockResolvedValue(executionId); + describe('executeWorkflow', () => { + const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] }); - await executeWorkflow( - mock(), - mock(), - mock({ loadedWorkflowData: undefined }), - ); + beforeEach(() => { + workflowRepository.get.mockResolvedValue( + mock({ id: EXECUTION_ID, nodes: [] }), + ); + activeExecutions.add.mockResolvedValue(EXECUTION_ID); + processRunExecutionData.mockReturnValue(getCancelablePromise(runWithData)); + }); - expect(executionRepository.setRunning).toHaveBeenCalledWith(executionId); + it('should execute workflow, return data and execution id', async () => { + const response = await executeWorkflow( + mock(), + mock(), + mock({ loadedWorkflowData: undefined, doNotWaitToFinish: false }), + ); + + expect(response).toEqual({ + data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main, + executionId: EXECUTION_ID, + }); + }); + + it('should execute workflow, skip waiting', async () => { + const response = await executeWorkflow( + mock(), + mock(), + mock({ loadedWorkflowData: undefined, doNotWaitToFinish: true }), + ); + + expect(response).toEqual({ + data: [null], + executionId: EXECUTION_ID, + }); + }); + + it('should set sub workflow execution as running', async () => { + await executeWorkflow( + mock(), + mock(), + mock({ loadedWorkflowData: undefined }), + ); + + expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID); + }); + }); + + describe('getRunData', () => { + it('should throw error to add trigger ndoe', async () => { + const workflow = mock({ + id: '1', + name: 'test', + nodes: [], + active: false, + }); + await expect(getRunData(workflow)).rejects.toThrowError('Missing node to start execution'); + }); + + const workflow = mock({ + id: '1', + name: 'test', + nodes: [ + { + type: 'n8n-nodes-base.executeWorkflowTrigger', + }, + ], + active: false, + }); + + it('should return default data', async () => { + expect(await getRunData(workflow)).toEqual({ + executionData: { + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack: [ + { + data: { main: [[{ json: {} }]] }, + metadata: { parentExecution: undefined }, + node: workflow.nodes[0], + source: null, + }, + ], + waitingExecution: {}, + waitingExecutionSource: {}, + }, + resultData: { runData: {} }, + startData: {}, + }, + executionMode: 'integrated', + workflowData: workflow, + }); + }); + + it('should return run data with input data and metadata', async () => { + const data = [{ json: { test: 1 } }]; + const parentExecution = { + executionId: '123', + workflowId: '567', + }; + expect(await getRunData(workflow, data, parentExecution)).toEqual({ + executionData: { + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack: [ + { + data: { main: [data] }, + metadata: { parentExecution }, + node: workflow.nodes[0], + source: null, + }, + ], + waitingExecution: {}, + waitingExecutionSource: {}, + }, + resultData: { runData: {} }, + startData: {}, + }, + executionMode: 'integrated', + workflowData: workflow, + }); + }); }); }); diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index c79f3d67e5..e50b96c384 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -1,6 +1,5 @@ import type { Scope } from '@n8n/permissions'; import type { Application } from 'express'; -import type { WorkflowExecute } from 'n8n-core'; import type { ExecutionError, ICredentialDataDecryptedObject, @@ -14,7 +13,6 @@ import type { ITelemetryTrackProperties, IWorkflowBase, CredentialLoadingDetails, - Workflow, WorkflowExecuteMode, ExecutionStatus, ExecutionSummary, @@ -300,12 +298,6 @@ export interface IWorkflowErrorData { }; } -export interface IWorkflowExecuteProcess { - startedAt: Date; - workflow: Workflow; - workflowExecute: WorkflowExecute; -} - export interface IWorkflowStatisticsDataLoaded { dataLoaded: boolean; } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 78c21b44c0..08d6ba09e4 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -36,6 +36,8 @@ import type { ExecuteWorkflowOptions, IWorkflowExecutionDataProcess, EnvProviderState, + ExecuteWorkflowData, + RelatedExecution, } from 'n8n-workflow'; import { Container } from 'typedi'; @@ -45,11 +47,7 @@ import { CredentialsHelper } from '@/credentials-helper'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map'; import { ExternalHooks } from '@/external-hooks'; -import type { - IWorkflowExecuteProcess, - IWorkflowErrorData, - UpdateExecutionPayload, -} from '@/interfaces'; +import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; @@ -650,6 +648,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { export async function getRunData( workflowData: IWorkflowBase, inputData?: INodeExecutionData[], + parentExecution?: RelatedExecution, ): Promise { const mode = 'integrated'; @@ -669,6 +668,7 @@ export async function getRunData( data: { main: [inputData], }, + metadata: { parentExecution }, source: null, }); @@ -740,7 +740,41 @@ export async function executeWorkflow( workflowInfo: IExecuteWorkflowInfo, additionalData: IWorkflowExecuteAdditionalData, options: ExecuteWorkflowOptions, -): Promise | IWorkflowExecuteProcess> { +): Promise { + const activeExecutions = Container.get(ActiveExecutions); + + const workflowData = + options.loadedWorkflowData ?? + (await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings)); + + const runData = + options.loadedRunData ?? + (await getRunData(workflowData, options.inputData, options.parentExecution)); + + const executionId = await activeExecutions.add(runData); + + const executionPromise = startExecution( + additionalData, + options, + executionId, + runData, + workflowData, + ); + + if (options.doNotWaitToFinish) { + return { executionId, data: [null] }; + } + + return await executionPromise; +} + +async function startExecution( + additionalData: IWorkflowExecuteAdditionalData, + options: ExecuteWorkflowOptions, + executionId: string, + runData: IWorkflowExecutionDataProcess, + workflowData: IWorkflowBase, +): Promise { const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); @@ -749,10 +783,6 @@ export async function executeWorkflow( const eventService = Container.get(EventService); const executionRepository = Container.get(ExecutionRepository); - const workflowData = - options.loadedWorkflowData ?? - (await getWorkflowData(workflowInfo, options.parentWorkflowId, options.parentWorkflowSettings)); - const workflowName = workflowData ? workflowData.name : undefined; const workflow = new Workflow({ id: workflowData.id, @@ -765,10 +795,6 @@ export async function executeWorkflow( settings: workflowData.settings, }); - const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData)); - - const executionId = await activeExecutions.add(runData); - /** * A subworkflow execution in queue mode is not enqueued, but rather runs in the * same worker process as the parent execution. Hence ensure the subworkflow @@ -890,7 +916,10 @@ export async function executeWorkflow( activeExecutions.finalizeExecution(executionId, data); const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data); - return returnData!.data!.main; + return { + executionId, + data: returnData!.data!.main, + }; } activeExecutions.finalizeExecution(executionId, data); diff --git a/packages/core/package.json b/packages/core/package.json index 5ad1ac4d5d..446569e1c6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,8 @@ "lint": "eslint . --quiet", "lintfix": "eslint . --fix", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"", - "test": "jest" + "test": "jest", + "test:dev": "jest --watch" }, "files": [ "dist", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index b3701c1ca3..29f60b8979 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -39,6 +39,7 @@ import type { BinaryHelperFunctions, CloseFunction, ContextType, + ExecuteWorkflowData, FieldType, FileSystemHelperFunctions, FunctionsBase, @@ -78,6 +79,7 @@ import type { IRunExecutionData, ITaskData, ITaskDataConnections, + ITaskMetadata, ITriggerFunctions, IWebhookData, IWebhookDescription, @@ -109,6 +111,7 @@ import type { ISupplyDataFunctions, WebhookType, SchedulingFunctions, + RelatedExecution, } from 'n8n-workflow'; import { NodeConnectionType, @@ -2721,6 +2724,7 @@ const addExecutionDataFunctions = async ( sourceNodeName: string, sourceNodeRunIndex: number, currentNodeRunIndex: number, + metadata?: ITaskMetadata, ): Promise => { if (connectionType === NodeConnectionType.Main) { throw new ApplicationError('Setting type is not supported for main connection', { @@ -2746,6 +2750,7 @@ const addExecutionDataFunctions = async ( if (taskData === undefined) { return; } + taskData.metadata = metadata; } taskData = taskData!; @@ -3622,6 +3627,12 @@ export function getExecuteFunctions( itemIndex, ), getExecuteData: () => executeData, + setMetadata: (metadata: ITaskMetadata): void => { + executeData.metadata = { + ...(executeData.metadata ?? {}), + ...metadata, + }; + }, continueOnFail: () => { return continueOnFail(node); }, @@ -3643,23 +3654,28 @@ export function getExecuteFunctions( workflowInfo: IExecuteWorkflowInfo, inputData?: INodeExecutionData[], parentCallbackManager?: CallbackManager, - ): Promise { + options?: { + doNotWaitToFinish?: boolean; + parentExecution?: RelatedExecution; + }, + ): Promise { return await additionalData .executeWorkflow(workflowInfo, additionalData, { + ...options, parentWorkflowId: workflow.id?.toString(), inputData, parentWorkflowSettings: workflow.settings, node, parentCallbackManager, }) - .then( - async (result) => - await Container.get(BinaryDataService).duplicateBinaryData( - workflow.id, - additionalData.executionId!, - result, - ), - ); + .then(async (result) => { + const data = await Container.get(BinaryDataService).duplicateBinaryData( + workflow.id, + additionalData.executionId!, + result.data, + ); + return { ...result, data }; + }); }, getContext(type: ContextType): IContextObject { return NodeHelpers.getContext(runExecutionData, type, node); @@ -3853,6 +3869,7 @@ export function getExecuteFunctions( connectionType: NodeConnectionType, currentNodeRunIndex: number, data: INodeExecutionData[][] | ExecutionBaseError, + metadata?: ITaskMetadata, ): void { addExecutionDataFunctions( 'output', @@ -3864,6 +3881,7 @@ export function getExecuteFunctions( node.name, runIndex, currentNodeRunIndex, + metadata, ).catch((error) => { Logger.warn( `There was a problem logging output data of node "${this.getNode().name}": ${ @@ -3972,7 +3990,11 @@ export function getSupplyDataFunctions( workflowInfo: IExecuteWorkflowInfo, inputData?: INodeExecutionData[], parentCallbackManager?: CallbackManager, - ) => + options?: { + doNotWaitToFinish?: boolean; + parentExecution?: RelatedExecution; + }, + ): Promise => await additionalData .executeWorkflow(workflowInfo, additionalData, { parentWorkflowId: workflow.id?.toString(), @@ -3980,15 +4002,16 @@ export function getSupplyDataFunctions( parentWorkflowSettings: workflow.settings, node, parentCallbackManager, + ...options, }) - .then( - async (result) => - await Container.get(BinaryDataService).duplicateBinaryData( - workflow.id, - additionalData.executionId!, - result, - ), - ), + .then(async (result) => { + const data = await Container.get(BinaryDataService).duplicateBinaryData( + workflow.id, + additionalData.executionId!, + result.data, + ); + return { ...result, data }; + }), getNodeOutputs() { const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); return NodeHelpers.getNodeOutputs(workflow, node, nodeType.description).map((output) => { @@ -4143,6 +4166,7 @@ export function getSupplyDataFunctions( connectionType: NodeConnectionType, currentNodeRunIndex: number, data: INodeExecutionData[][], + metadata?: ITaskMetadata, ): void { addExecutionDataFunctions( 'output', @@ -4154,6 +4178,7 @@ export function getSupplyDataFunctions( node.name, runIndex, currentNodeRunIndex, + metadata, ).catch((error) => { Logger.warn( `There was a problem logging output data of node "${this.getNode().name}": ${ diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index bb1b1633df..2ae12908ac 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -408,7 +408,10 @@ export class WorkflowExecute { let metaRunData: ITaskMetadata; for (const nodeName of Object.keys(metadata)) { for ([index, metaRunData] of metadata[nodeName].entries()) { - runData[nodeName][index].metadata = metaRunData; + runData[nodeName][index].metadata = { + ...(runData[nodeName][index].metadata ?? {}), + ...metaRunData, + }; } } } @@ -1448,6 +1451,7 @@ export class WorkflowExecute { startTime, executionTime: new Date().getTime() - startTime, source: !executionData.source ? [] : executionData.source.main, + metadata: executionData.metadata, executionStatus: 'success', }; diff --git a/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts b/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts index dcd8509c1d..8a2fc72be3 100644 --- a/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts @@ -17,6 +17,7 @@ import type { IContextObject, ICredentialDataDecryptedObject, ISourceData, + ITaskMetadata, } from 'n8n-workflow'; import { ApplicationError, NodeHelpers } from 'n8n-workflow'; @@ -298,4 +299,33 @@ describe('ExecuteSingleContext', () => { }); }); }); + + describe('setMetadata', () => { + it('sets metadata on execution data', () => { + const context = new ExecuteSingleContext( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + itemIndex, + executeData, + abortSignal, + ); + + const metadata: ITaskMetadata = { + subExecution: { + workflowId: '123', + executionId: 'xyz', + }, + }; + + expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined); + context.setMetadata(metadata); + expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution); + }); + }); }); diff --git a/packages/core/src/node-execution-context/execute-single-context.ts b/packages/core/src/node-execution-context/execute-single-context.ts index 2b03a81974..dd08aa0fc6 100644 --- a/packages/core/src/node-execution-context/execute-single-context.ts +++ b/packages/core/src/node-execution-context/execute-single-context.ts @@ -13,6 +13,7 @@ import type { ContextType, AiEvent, ISourceData, + ITaskMetadata, } from 'n8n-workflow'; import { ApplicationError, @@ -85,6 +86,13 @@ export class ExecuteSingleContext extends NodeExecutionContext implements IExecu this.abortSignal?.addEventListener('abort', fn); } + setMetadata(metadata: ITaskMetadata): void { + this.executeData.metadata = { + ...(this.executeData.metadata ?? {}), + ...metadata, + }; + } + continueOnFail() { return continueOnFail(this.node); } diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 4bf5f02e84..29d7d78e04 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -182,6 +182,10 @@ export interface IAiDataContent { metadata: { executionTime: number; startTime: number; + subExecution?: { + workflowId: string; + executionId: string; + }; }; } @@ -202,6 +206,10 @@ export interface ITableData { columns: string[]; data: GenericValue[][]; hasJson: { [key: string]: boolean }; + metadata: { + hasExecutionIds: boolean; + data: Array; + }; } // Simple version of n8n-workflow.Workflow diff --git a/packages/editor-ui/src/components/PanelDragButton.vue b/packages/editor-ui/src/components/PanelDragButton.vue index 929d5f9b18..c203e9e335 100644 --- a/packages/editor-ui/src/components/PanelDragButton.vue +++ b/packages/editor-ui/src/components/PanelDragButton.vue @@ -35,7 +35,7 @@ const onDragStart = () => { @dragend="onDragEnd" >