first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,355 @@
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
HTTP_REQUEST_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
EDIT_FIELDS_SET_NODE_NAME,
AGENT_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe('Canvas Actions', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should add first step', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
});
test('should add a connected node using plus endpoint', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.clickNodeCreatorItemName(CODE_NODE_DISPLAY_NAME);
await n8n.page.keyboard.press('Enter');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should add a connected node dragging from node creator', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME);
await n8n.page.keyboard.press('Enter');
await n8n.canvas
.nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME)
.dragTo(n8n.canvas.canvasPane(), { targetPosition: { x: 100, y: 100 } });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should open a category when trying to drag and drop it on the canvas', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME);
const categoryItem = n8n.canvas.nodeCreatorActionItems().first();
await categoryItem.dragTo(n8n.canvas.canvasPane(), {
targetPosition: { x: 100, y: 100 },
});
await expect(n8n.canvas.nodeCreatorCategoryItems()).toHaveCount(1);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should add disconnected node if nothing is selected', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.deselectAll();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should add node between two connected nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
HTTP_REQUEST_NODE_NAME,
);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
});
test('should delete node by pressing keyboard backspace', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.page.keyboard.press('Backspace');
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should delete connections by clicking on the delete button', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.deleteConnectionBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
test.describe('Node hover actions', () => {
test('should execute node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.deselectAll();
await n8n.canvas.executeNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await expect(
n8n.notifications.getNotificationByTitle('Node executed successfully'),
).toHaveCount(1);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
});
test('should disable and enable node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.deselectAll();
const disableButton = n8n.canvas.nodeDisableButton(CODE_NODE_DISPLAY_NAME);
await disableButton.click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
await disableButton.click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
});
test('should delete node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.deleteNodeByName(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME)).toBeVisible();
});
});
test('should copy selected nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvasComposer.selectAllAndCopy();
await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.canvasComposer.copySelectedNodesWithToast();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
test('should select/deselect all nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.selectAll();
await expect(n8n.canvas.selectedNodes()).toHaveCount(2);
await n8n.canvas.deselectAll();
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
});
test('should select nodes using arrow keys', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.getCanvasNodes().first().waitFor();
await n8n.canvas.navigateNodesWithArrows('left');
const selectedNodes = n8n.canvas.selectedNodes();
await expect(selectedNodes.first()).toHaveClass(/selected/);
await n8n.canvas.navigateNodesWithArrows('right');
await expect(selectedNodes.last()).toHaveClass(/selected/);
});
test('should select nodes using shift and arrow keys', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.getCanvasNodes().first().waitFor();
await n8n.canvas.extendSelectionWithArrows('left');
await expect(n8n.canvas.selectedNodes()).toHaveCount(2);
});
test.describe('Node insertion positioning', () => {
test('should not shift downstream nodes when there is enough space', async ({ n8n }) => {
// Create trigger -> code with large gap between them
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
// Move code node far to the right to create space
await n8n.canvas.dragNodeToRelativePosition(CODE_NODE_DISPLAY_NAME, 400, 0);
// Get code node position before insertion
const codePositionBefore = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Insert node between trigger and code
await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
HTTP_REQUEST_NODE_NAME,
);
// Get code node position after insertion
const codePositionAfter = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Code node should not have moved since there was enough space
expect(codePositionAfter.x).toBe(codePositionBefore.x);
expect(codePositionAfter.y).toBe(codePositionBefore.y);
});
test('should shift downstream nodes when there is not enough space', async ({ n8n }) => {
// Create trigger -> code (close together)
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
// Get code node position before insertion
const codePositionBefore = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Insert node between trigger and code
await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
HTTP_REQUEST_NODE_NAME,
);
// Get code node position after insertion
const codePositionAfter = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Code node should have moved to the right
expect(codePositionAfter.x).toBeGreaterThan(codePositionBefore.x);
});
test('should shift connected downstream nodes together', async ({ n8n }) => {
// Create trigger -> code -> edit fields chain
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
// Get positions before insertion
const codePositionBefore = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
const editFieldsPositionBefore = await n8n.canvas.getNodePosition('Edit Fields');
// Insert node between trigger and code
await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
HTTP_REQUEST_NODE_NAME,
);
// Get positions after insertion
const codePositionAfter = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
const editFieldsPositionAfter = await n8n.canvas.getNodePosition('Edit Fields');
// Both downstream nodes should have moved by approximately the same amount
const codeDeltaX = codePositionAfter.x - codePositionBefore.x;
const editFieldsDeltaX = editFieldsPositionAfter.x - editFieldsPositionBefore.x;
expect(codeDeltaX).toBeGreaterThan(0);
expect(editFieldsDeltaX).toBeGreaterThan(0);
// They should move by similar amounts (within tolerance for rounding)
expect(Math.abs(codeDeltaX - editFieldsDeltaX)).toBeLessThan(20);
});
test('should shift downstream nodes correctly when inserting configurable nodes like AI Agent', async ({
n8n,
}) => {
// Create trigger -> code (close together)
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
// Get code node position before insertion
const codePositionBefore = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Insert AI Agent (a configurable/wider node) between trigger and code
await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
AGENT_NODE_NAME,
);
// Get positions after insertion
const codePositionAfter = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
const agentPosition = await n8n.canvas.getNodePosition(AGENT_NODE_NAME);
// Code node should have moved to the right
expect(codePositionAfter.x).toBeGreaterThan(codePositionBefore.x);
// AI Agent node should be positioned between trigger and code (not overlapping)
// AI Agent is 256px wide, so its right edge should be left of code node
// Agent position is top-left, so right edge is agentPosition.x + 256
const agentRightEdge = agentPosition.x + 256;
expect(agentRightEdge).toBeLessThan(codePositionAfter.x);
});
test('should not shift downstream nodes when there is enough space for configurable nodes', async ({
n8n,
}) => {
// Create trigger -> code with large gap between them
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
// Move code node far to the right to create space for AI Agent (which is 256px wide)
await n8n.canvas.dragNodeToRelativePosition(CODE_NODE_DISPLAY_NAME, 500, 0);
// Get code node position before insertion
const codePositionBefore = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Insert AI Agent between trigger and code
await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
AGENT_NODE_NAME,
);
// Get code node position after insertion
const codePositionAfter = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
// Code node should not have moved since there was enough space
expect(codePositionAfter.x).toBe(codePositionBefore.x);
expect(codePositionAfter.y).toBe(codePositionBefore.y);
});
});
});
@@ -0,0 +1,232 @@
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
SWITCH_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
MERGE_NODE_NAME,
CODE_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe('Canvas Node Manipulation and Navigation', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should add switch node and test connections', async ({ n8n }) => {
const desiredOutputs = 4;
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(SWITCH_NODE_NAME);
for (let i = 0; i < desiredOutputs; i++) {
await n8n.page.getByText('Add Routing Rule').click();
}
await n8n.ndv.close();
for (let i = 0; i < desiredOutputs; i++) {
await n8n.canvas.clickNodePlusEndpoint(SWITCH_NODE_NAME);
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
await n8n.canvas.fillNodeCreatorSearchBar(EDIT_FIELDS_SET_NODE_NAME);
await n8n.canvas.clickNodeCreatorItemName(EDIT_FIELDS_SET_NODE_NAME);
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickZoomToFitButton();
}
await n8n.canvas.canvasPane().click({ position: { x: 10, y: 10 } });
await n8n.canvas.clickNodePlusEndpoint('Edit Fields3');
await n8n.canvas.fillNodeCreatorSearchBar(SWITCH_NODE_NAME);
await n8n.canvas.clickNodeCreatorItemName(SWITCH_NODE_NAME);
await n8n.page.keyboard.press('Escape');
await n8n.canvasComposer.waitForWorkflowSaveAndUrl();
await n8n.canvasComposer.reloadAndWaitForCanvas();
await expect(
n8n.canvas.connectionBetweenNodes('Edit Fields3', `${SWITCH_NODE_NAME}1`),
).toBeAttached();
const editFieldsNodes = ['Edit Fields', 'Edit Fields1', 'Edit Fields2', 'Edit Fields3'];
for (const nodeName of editFieldsNodes) {
await expect(n8n.canvas.connectionBetweenNodes(SWITCH_NODE_NAME, nodeName)).toBeAttached();
}
});
test('should add merge node and test connections', async ({ n8n }) => {
const editFieldsNodeCount = 2;
const checkConnections = async () => {
await expect(
n8n.canvas.connectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, 'Edit Fields1').first(),
).toBeAttached();
await expect(
n8n.canvas.connectionBetweenNodes('Edit Fields', MERGE_NODE_NAME).first(),
).toBeAttached();
await expect(
n8n.canvas.connectionBetweenNodes('Edit Fields1', MERGE_NODE_NAME).first(),
).toBeAttached();
};
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < editFieldsNodeCount; i++) {
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.canvasPane().click({
position: { x: (i + 1) * 200, y: (i + 1) * 200 },
// eslint-disable-next-line playwright/no-force-option
force: true,
});
}
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.addNode(MERGE_NODE_NAME, { closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.connectNodesByDrag(MANUAL_TRIGGER_NODE_DISPLAY_NAME, 'Edit Fields1', 0, 0);
await expect(
n8n.canvas.connectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, 'Edit Fields1').first(),
).toBeAttached();
await n8n.canvas.connectNodesByDrag('Edit Fields', MERGE_NODE_NAME, 0, 0);
await expect(
n8n.canvas.connectionBetweenNodes('Edit Fields', MERGE_NODE_NAME).first(),
).toBeAttached();
await n8n.canvas.connectNodesByDrag('Edit Fields1', MERGE_NODE_NAME, 0, 1);
await expect(
n8n.canvas.connectionBetweenNodes('Edit Fields1', MERGE_NODE_NAME).first(),
).toBeAttached();
await n8n.canvasComposer.waitForWorkflowSaveAndUrl();
await n8n.canvasComposer.reloadAndWaitForCanvas();
await checkConnections();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await n8n.canvasComposer.reloadAndWaitForCanvas();
await checkConnections();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(
n8n.canvas.getConnectionLabelBetweenNodes('Edit Fields1', MERGE_NODE_NAME).first(),
).toContainText('1 item');
await expect(n8n.canvas.getNodeOutputHandle(MERGE_NODE_NAME).first()).toContainText('2 items');
});
test('should add nodes and check execution success', async ({ n8n }) => {
const nodeCount = 3;
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < nodeCount; i++) {
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
}
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.getSuccessEdges()).toHaveCount(nodeCount);
await expect(n8n.canvas.getAllNodeSuccessIndicators()).toHaveCount(nodeCount + 1);
await expect(
n8n.canvas.getCanvasHandlePlusWrapperByName('Code in JavaScript2'),
).toHaveAttribute('data-plus-type', 'success');
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
await expect(
n8n.canvas.getCanvasHandlePlusWrapperByName('Code in JavaScript3'),
).not.toHaveAttribute('data-plus-type', 'success');
await expect(n8n.canvas.getSuccessEdges()).toHaveCount(nodeCount + 1);
await expect(n8n.canvas.getAllNodeSuccessIndicators()).toHaveCount(nodeCount + 1);
});
test('should delete node using context menu', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should delete node using keyboard shortcut', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.page.keyboard.press('Backspace');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should delete node between two connected nodes', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.canvas.clickZoomToFitButton();
await n8n.page.keyboard.press('Backspace');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should delete multiple nodes (context menu or shortcut)', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.hitDeleteAllNodes();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.rightClickCanvas();
await n8n.canvas.getContextMenuItem('select_all').click();
await n8n.canvas.rightClickCanvas();
await n8n.canvas.getContextMenuItem('delete').click();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
});
test('should move node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
const pos1 = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
await n8n.canvas.dragNodeToRelativePosition(CODE_NODE_DISPLAY_NAME, 50, 150);
const pos2 = await n8n.canvas.getNodePosition(CODE_NODE_DISPLAY_NAME);
expect(pos2.x).toBeGreaterThan(pos1.x);
expect(pos2.y).toBeGreaterThan(pos1.y);
});
});
@@ -0,0 +1,253 @@
import fs from 'fs';
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
import { resolveFromRoot } from '../../../../../utils/path-helper';
const DEFAULT_ZOOM_FACTOR = 1;
const ZOOM_IN_X1_FACTOR = 1.25; // Expected zoom after 1 zoom-in click (125%)
const ZOOM_IN_X2_FACTOR = 1.5625; // Expected zoom after 2 zoom-in clicks (156.25%)
const ZOOM_OUT_X1_FACTOR = 0.8; // Expected zoom after 1 zoom-out click (80%)
const ZOOM_OUT_X2_FACTOR = 0.64; // Expected zoom after 2 zoom-out clicks (64%)
const ZOOM_TOLERANCE = 0.2; // Acceptable variance for floating-point zoom comparisons
test.describe(
'Canvas Zoom Functionality',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
const expectZoomLevel = async (n8n: n8nPage, expectedFactor: number) => {
const actual = await n8n.canvas.getCanvasZoomLevel();
expect(actual).toBeGreaterThanOrEqual(expectedFactor - ZOOM_TOLERANCE);
expect(actual).toBeLessThanOrEqual(expectedFactor + ZOOM_TOLERANCE);
};
test('should zoom in', async ({ n8n }) => {
await expect(n8n.canvas.getZoomInButton()).toBeVisible();
const initialZoom = await n8n.canvas.getCanvasZoomLevel();
await n8n.canvas.clickZoomInButton();
await expectZoomLevel(n8n, initialZoom * ZOOM_IN_X1_FACTOR);
await n8n.canvas.clickZoomInButton();
await expectZoomLevel(n8n, initialZoom * ZOOM_IN_X2_FACTOR);
});
test('should zoom out', async ({ n8n }) => {
await n8n.canvas.clickZoomOutButton();
await expectZoomLevel(n8n, ZOOM_OUT_X1_FACTOR);
await n8n.canvas.clickZoomOutButton();
const finalZoom = await n8n.canvas.getCanvasZoomLevel();
expect(finalZoom).toBeGreaterThanOrEqual(ZOOM_OUT_X2_FACTOR - ZOOM_TOLERANCE);
expect(finalZoom).toBeLessThanOrEqual(ZOOM_OUT_X2_FACTOR + ZOOM_TOLERANCE);
});
test('should reset zoom', async ({ n8n }) => {
await expect(n8n.canvas.getResetZoomButton()).not.toBeAttached();
await n8n.canvas.clickZoomInButton();
await expect(n8n.canvas.getResetZoomButton()).toBeVisible();
await n8n.canvas.getResetZoomButton().click();
await expectZoomLevel(n8n, DEFAULT_ZOOM_FACTOR);
});
test('should zoom to fit', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomOutButton();
await n8n.canvas.clickZoomOutButton();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomInButton();
await n8n.canvas.clickZoomInButton();
await expect(n8n.canvas.getCanvasNodes().last()).not.toBeInViewport();
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes().last()).toBeInViewport();
});
test('should disable node (context menu or shortcut)', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.getCanvasNodes().last().click();
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.canvas.disableNodeFromContextMenu(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
});
test('should disable multiple nodes (context menu or shortcut)', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
await n8n.page.keyboard.press('Escape');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.selectAll();
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(2);
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.canvas.deselectAll();
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.canvas.selectAll();
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(2);
await n8n.canvas.selectAll();
await n8n.canvas.rightClickCanvas();
await n8n.canvas.getContextMenuItem('toggle_activation').click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.canvas.rightClickCanvas();
await n8n.canvas.getContextMenuItem('toggle_activation').click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(2);
await n8n.canvas.deselectAll();
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.rightClickCanvas();
await n8n.canvas.getContextMenuItem('toggle_activation').click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.canvas.selectAll();
await n8n.canvas.rightClickCanvas();
await n8n.canvas.getContextMenuItem('toggle_activation').click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(2);
});
test('should rename node (context menu or shortcut)', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvasComposer.renameNodeViaShortcut(CODE_NODE_DISPLAY_NAME, 'Something else');
await expect(n8n.canvas.nodeByName('Something else')).toBeAttached();
await n8n.canvas.rightClickNode('Something else');
await n8n.canvas.getContextMenuItem('rename').click();
await expect(n8n.canvas.getRenamePrompt()).toBeVisible();
await n8n.page.keyboard.type('Something different');
await n8n.page.keyboard.press('Enter');
await expect(n8n.canvas.nodeByName('Something different')).toBeAttached();
});
test('should allow typing space while holding Shift in rename dialog', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.getCanvasNodes().last().click();
await n8n.page.keyboard.press('F2');
await expect(n8n.canvas.getRenamePrompt()).toBeVisible();
await n8n.page.keyboard.press('ControlOrMeta+a');
await n8n.page.keyboard.type('X:');
await n8n.page.keyboard.press('Shift+Space');
await n8n.page.keyboard.type('Y');
await expect(n8n.canvas.getRenameInput()).toHaveValue('X: Y');
await n8n.page.keyboard.press('Enter');
await expect(n8n.canvas.nodeByName('X: Y')).toBeAttached();
});
test('should not allow empty strings for node names', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.getCanvasNodes().last().click();
await n8n.page.keyboard.press('F2');
await expect(n8n.canvas.getRenamePrompt()).toBeVisible();
await n8n.page.keyboard.press('Backspace');
await n8n.page.keyboard.press('Enter');
await expect(n8n.canvas.getRenamePrompt()).toContainText('Invalid Name');
});
test('should duplicate nodes (context menu or shortcut)', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.duplicateNode(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.selectAll();
await n8n.page.keyboard.press('ControlOrMeta+d');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
});
test('should preserve connections after rename & node-view switch', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Workflow executed successfully'),
).toBeVisible();
await n8n.notifications.closeNotificationByText('Workflow executed successfully');
await n8n.canvas.openExecutions();
await expect(n8n.executions.getSuccessfulExecutionItems()).toHaveCount(1);
await n8n.canvas.clickEditorTab();
await n8n.canvas.openExecutions();
await expect(n8n.executions.getSuccessfulExecutionItems()).toHaveCount(1);
await n8n.canvas.clickEditorTab();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await n8n.canvasComposer.renameNodeViaShortcut(CODE_NODE_DISPLAY_NAME, 'Something else');
await expect(n8n.canvas.nodeByName('Something else')).toBeAttached();
await n8n.canvasComposer.waitForWorkflowSaveAndUrl();
await n8n.canvasComposer.reloadAndWaitForCanvas();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should remove unknown credentials on pasting workflow', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'workflow-with-unknown-credentials.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await n8n.canvas.nodeByName('n8n').hover();
await n8n.canvas.nodeByName('n8n').getByTestId('overflow-node-button').click();
await n8n.page.getByTestId('context-menu-item-open').click();
await expect(n8n.ndv.getNodesWithIssues()).toHaveCount(1);
});
test.fixme('should open and close the about modal on keyboard shortcut', async ({ n8n }) => {
await n8n.sideBar.openAboutModalViaShortcut();
await expect(n8n.sideBar.getAboutModal()).toBeVisible();
await n8n.sideBar.closeAboutModal();
});
},
);
@@ -0,0 +1,44 @@
import { test, expect } from '../../../../../fixtures/base';
import type { TestRequirements } from '../../../../../Types';
test.describe('Focus panel', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.describe('With experimental NDV in focus panel enabled', () => {
const requirements: TestRequirements = {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ ndv_in_focus_panel: 'variant' }),
},
};
test('should keep showing selected node when canvas is clicked while mapper popover is shown', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deselectAll();
await n8n.canvas.toggleFocusPanelButton().click();
await n8n.canvas.nodeByName('Set').click();
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toHaveText('Set');
await n8n.canvas.focusPanel.getParameterInputField('assignments.assignments.0.value').focus();
await expect(n8n.canvas.focusPanel.getMapper()).toBeVisible();
// Assert that mapper is closed but the Set node is still selected and shown in
await n8n.canvas.canvasBody().click({ position: { x: 1, y: 1 } });
await expect(n8n.canvas.focusPanel.getMapper()).toBeHidden();
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toHaveText('Set');
await expect(n8n.canvas.selectedNodes()).toHaveCount(1);
// Assert that another click on canvas does de-select the Set node
await n8n.canvas.canvasBody().click({ position: { x: 1, y: 1 } });
await expect(n8n.canvas.focusPanel.getHeaderNodeName()).toBeHidden();
await expect(n8n.canvas.selectedNodes()).toHaveCount(0);
});
});
});
@@ -0,0 +1,33 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('Canvas Actions', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('adds sticky to canvas with default text and position', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await expect(n8n.canvas.sticky.getAddButton()).toBeVisible();
await n8n.canvas.sticky.addSticky();
const firstSticky = n8n.canvas.sticky.getStickies().first();
await expect(firstSticky).toHaveCSS('height', '160px');
await expect(firstSticky).toHaveCSS('width', '240px');
await n8n.canvas.deselectAll();
await n8n.canvas.sticky.addFromContextMenu(n8n.canvas.canvasPane());
await n8n.page.keyboard.press('Shift+s');
await expect(n8n.canvas.sticky.getStickies()).toHaveCount(3);
await n8n.page.keyboard.press('ControlOrMeta+Shift+s');
await expect(n8n.canvas.sticky.getStickies()).toHaveCount(3);
await expect(n8n.canvas.sticky.getStickies().first()).toHaveText(
'Im a note\nDouble click to edit me. Guide\n',
);
const guideLink = n8n.canvas.sticky.getDefaultStickyGuideLink();
await expect(guideLink).toHaveAttribute('href');
});
});
@@ -0,0 +1,286 @@
import fs from 'fs';
import {
SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import { resolveFromRoot } from '../../../../../utils/path-helper';
test.describe('Undo/Redo', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should undo/redo deleting node using context menu', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should undo/redo deleting node using keyboard shortcut', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.canvas.clickZoomToFitButton();
await n8n.page.keyboard.press('Backspace');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should undo/redo deleting node between two connected nodes', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.canvas.clickZoomToFitButton();
await n8n.page.keyboard.press('Backspace');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should undo/redo deleting whole workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.page.keyboard.press('Escape');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.hitDeleteAllNodes();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should undo/redo moving nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.clickZoomToFitButton();
const codeNodeName = CODE_NODE_DISPLAY_NAME;
const initialPosition = await n8n.canvas.getNodePosition(codeNodeName);
await n8n.canvas.dragNodeToRelativePosition(codeNodeName, 50, 150);
const newPosition = await n8n.canvas.getNodePosition(codeNodeName);
expect(newPosition.x).toBeGreaterThan(initialPosition.x);
expect(newPosition.y).toBeGreaterThan(initialPosition.y);
await n8n.canvas.hitUndo();
const undoPosition = await n8n.canvas.getNodePosition(codeNodeName);
expect(undoPosition.x).toBeCloseTo(initialPosition.x, 1);
expect(undoPosition.y).toBeCloseTo(initialPosition.y, 1);
await n8n.canvas.hitRedo();
const redoPosition = await n8n.canvas.getNodePosition(codeNodeName);
expect(redoPosition.x).toBeGreaterThan(initialPosition.x);
expect(redoPosition.y).toBeGreaterThan(initialPosition.y);
});
test('should undo/redo deleting a connection using context menu', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.deleteConnectionBetweenNodes(
SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
test('should undo/redo disabling a node using context menu', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.disableNodeFromContextMenu(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
});
test('should undo/redo disabling a node using keyboard shortcut', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.getCanvasNodes().last().click();
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
});
test('should undo/redo disabling multiple nodes', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.page.keyboard.press('Escape');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.selectAll();
await n8n.page.keyboard.press('d');
await expect(n8n.canvas.disabledNodes()).toHaveCount(2);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.disabledNodes()).toHaveCount(2);
});
test('should undo/redo duplicating a node', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await n8n.canvas.duplicateNode(CODE_NODE_DISPLAY_NAME);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
test('should undo/redo pasting nodes', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_paste-data.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await n8n.canvas.hitRedo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
});
test('should be able to copy and paste pinned data nodes in workflows with dynamic Switch node', async ({
n8n,
}) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow_form_switch.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await expect(n8n.canvas.getNodeInputHandles('Switch')).toHaveCount(1);
// Wait for clipboard paste throttling
await n8n.page.waitForTimeout(1000);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(4);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await expect(n8n.canvas.getNodeInputHandles('Switch')).toHaveCount(1);
});
test('should not undo/redo when NDV or a modal is open', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await n8n.ndv.clickBackToCanvasButton();
await n8n.sideBar.clickAboutMenuItem();
await expect(n8n.sideBar.getAboutModal()).toBeVisible();
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await n8n.sideBar.closeAboutModal();
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
});
test('should not undo/redo when NDV or a prompt is open', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.clickWorkflowMenu();
await n8n.canvas.clickImportFromURL();
await n8n.canvas.getImportURLInput().click();
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await n8n.canvas.clickCancelImportURL();
await n8n.canvas.hitUndo();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
});
});
@@ -0,0 +1,229 @@
import { nanoid } from 'nanoid';
import {
CODE_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe('Code node', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.describe('Code editor', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
});
test('should show correct placeholders switching modes', async ({ n8n }) => {
await expect(
n8n.ndv.getPlaceholderText('// Loop over input items and add a new field'),
).toBeVisible();
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click();
await expect(
n8n.ndv.getPlaceholderText("// Add a new field called 'myNewField'"),
).toBeVisible();
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for All Items' }).click();
await expect(
n8n.ndv.getPlaceholderText('// Loop over input items and add a new field'),
).toBeVisible();
});
test('should execute the placeholder successfully in both modes', async ({ n8n }) => {
await n8n.ndv.execute();
await expect(
n8n.notifications.getNotificationByTitle('Node executed successfully').first(),
).toBeVisible();
await n8n.ndv.getParameterInput('mode').click();
await n8n.page.getByRole('option', { name: 'Run Once for Each Item' }).click();
await n8n.ndv.execute();
await expect(
n8n.notifications.getNotificationByTitle('Node executed successfully').first(),
).toBeVisible();
});
test('should allow switching between sibling code nodes', async ({ n8n }) => {
await n8n.ndv.getCodeEditor().fill("console.log('Code in JavaScript1')");
await n8n.ndv.close();
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
await n8n.ndv.getCodeEditor().fill("console.log('Code in JavaScript2')");
await n8n.ndv.close();
await n8n.canvas.openNode(CODE_NODE_DISPLAY_NAME);
await n8n.ndv.clickFloatingNode(CODE_NODE_DISPLAY_NAME + '1');
await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('Code in JavaScript2')");
await n8n.ndv.clickFloatingNode(CODE_NODE_DISPLAY_NAME);
await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('Code in JavaScript1')");
});
test('should show lint errors in `runOnceForAllItems` mode', async ({ n8n }) => {
await n8n.ndv.getCodeEditor().fill(`$input.itemMatching()
$input.item
$('When clicking Execute workflow').item
$input.first(1)
for (const item of $input.all()) {
item.foo
}
return
`);
await expect(n8n.ndv.getLintErrors()).toHaveCount(6);
const firstLintError = n8n.ndv.getLintErrors().first();
await expect(firstLintError).toBeVisible();
await firstLintError.hover({ force: true });
// Wait for lint tooltip to appear after hover
await expect(n8n.ndv.getLintTooltip()).toBeVisible({ timeout: 5000 });
await expect(n8n.ndv.getLintTooltip()).toContainText(
'`.itemMatching()` expects an item index to be passed in as its argument.',
);
});
});
test.describe
.serial('Run Once for Each Item', () => {
test('should show lint errors in `runOnceForEachItem` mode', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
await n8n.ndv.toggleCodeMode('Run Once for Each Item');
await n8n.ndv.getCodeEditor().fill(`$input.itemMatching()
$input.all()
$input.first()
$input.item()
return []
`);
// Verify lint errors are detected (tooltip hover tested in runOnceForAllItems test)
await expect(n8n.ndv.getLintErrors()).toHaveCount(7);
});
});
test.describe('Ask AI', () => {
test.describe('Enabled', () => {
test.beforeEach(async ({ api, n8n }) => {
await api.enableFeature('askAi');
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
});
test('tab should exist if experiment selected and be selectable', async ({ n8n }) => {
await n8n.ndv.clickAskAiTab();
await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible();
await expect(n8n.ndv.getHeyAiText()).toBeVisible();
});
test('generate code button should have correct state & tooltips', async ({ n8n }) => {
await n8n.ndv.clickAskAiTab();
await expect(n8n.ndv.getAskAiTabPanel()).toBeVisible();
await expect(n8n.ndv.getAskAiCtaButton()).toBeDisabled();
await n8n.ndv.getAskAiCtaButton().hover();
await expect(n8n.ndv.getAskAiCtaTooltipNoInputData()).toBeVisible();
await n8n.ndv.executePrevious();
await n8n.ndv.getAskAiCtaButton().hover();
await expect(n8n.ndv.getAskAiCtaTooltipNoPrompt()).toBeVisible();
await n8n.ndv.getAskAiPromptInput().fill(nanoid(14));
await n8n.ndv.getAskAiCtaButton().hover();
await expect(n8n.ndv.getAskAiCtaTooltipPromptTooShort()).toBeVisible();
await n8n.ndv.getAskAiPromptInput().fill(nanoid(15));
await expect(n8n.ndv.getAskAiCtaButton()).toBeEnabled();
await expect(n8n.ndv.getAskAiPromptCounter()).toContainText('15 / 600');
});
test('should send correct schema and replace code', async ({ n8n }) => {
const prompt = nanoid(20);
await n8n.ndv.clickAskAiTab();
await n8n.ndv.executePrevious();
await n8n.ndv.getAskAiPromptInput().fill(prompt);
await n8n.page.route('**/rest/ai/ask-ai', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
code: 'console.log("Hello World")',
},
}),
});
});
const [request] = await Promise.all([
n8n.page.waitForRequest('**/rest/ai/ask-ai'),
n8n.ndv.getAskAiCtaButton().click(),
]);
const requestBody = request.postDataJSON();
expect(requestBody).toHaveProperty('question');
expect(requestBody).toHaveProperty('context');
expect(requestBody).toHaveProperty('forNode');
expect(requestBody.context).toHaveProperty('schema');
expect(requestBody.context).toHaveProperty('ndvPushRef');
expect(requestBody.context).toHaveProperty('pushRef');
expect(requestBody.context).toHaveProperty('inputSchema');
await expect(n8n.ndv.getCodeGenerationCompletedText()).toBeVisible();
await expect(n8n.ndv.getCodeTabPanel()).toContainText('console.log("Hello World")');
await expect(n8n.ndv.getCodeTab()).toHaveClass(/is-active/);
});
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{
code: 500,
message:
'Code generation failed with error: Request failed with status code 500. Try again in a few minutes',
},
];
handledCodes.forEach(({ code, message }) => {
test(`should show error based on status code ${code}`, async ({ n8n }) => {
const prompt = nanoid(20);
await n8n.ndv.clickAskAiTab();
await n8n.ndv.executePrevious();
await n8n.ndv.getAskAiPromptInput().fill(prompt);
await n8n.page.route('**/rest/ai/ask-ai', async (route) => {
await route.fulfill({
status: code,
});
});
await n8n.ndv.getAskAiCtaButton().click();
await expect(n8n.ndv.getErrorMessageText(message)).toBeVisible();
});
});
});
});
});
@@ -0,0 +1,233 @@
import fs from 'fs';
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import { resolveFromRoot } from '../../../../../utils/path-helper';
test.describe('Editors', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addInitialNodeToCanvas('Manual Trigger');
});
test.describe('SQL Editor', () => {
test('should preserve changes when opening-closing Postgres node', async ({ n8n }) => {
await n8n.canvas.addNode('Postgres', { action: 'Execute a SQL query' });
const sqlEditor = n8n.ndv.getParameterEditor('query');
await sqlEditor.click();
await sqlEditor.fill('SELECT * FROM `testTable`');
await n8n.page.keyboard.press('Escape');
await n8n.ndv.close();
await n8n.canvas.openNode('Execute a SQL query');
await sqlEditor.click();
await sqlEditor.press('End');
await sqlEditor.pressSequentially(' LIMIT 10');
await n8n.page.keyboard.press('Escape');
await n8n.ndv.close();
await n8n.canvas.openNode('Execute a SQL query');
await expect(sqlEditor).toContainText('SELECT * FROM `testTable` LIMIT 10');
});
test('should update expression output dropdown as the query is edited', async ({ n8n }) => {
await n8n.canvas.addNode('MySQL', { action: 'Execute a SQL query', closeNDV: true });
await n8n.canvas.openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.ndv.setPinnedData([{ table: 'test_table' }]);
await n8n.ndv.close();
await n8n.canvas.openNode('Execute a SQL query');
const sqlEditor = n8n.ndv.getParameterEditor('query');
await sqlEditor.click();
await sqlEditor.fill('SELECT * FROM {{ $json.table }}');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText(
'SELECT * FROM test_table',
);
});
test('should not push NDV header out with a lot of code in Postgres editor', async ({
n8n,
}) => {
await n8n.canvas.addNode('Postgres', { action: 'Execute a SQL query' });
const dummyCode = fs.readFileSync(
resolveFromRoot('fixtures', 'Dummy_javascript.txt'),
'utf8',
);
const sqlEditor = n8n.ndv.getParameterEditor('query');
await sqlEditor.click();
await n8n.clipboard.paste(dummyCode);
await expect(n8n.ndv.getExecuteNodeButton()).toBeVisible();
});
test('should not push NDV header out with a lot of code in MySQL editor', async ({ n8n }) => {
await n8n.canvas.addNode('MySQL', { action: 'Execute a SQL query' });
const dummyCode = fs.readFileSync(
resolveFromRoot('fixtures', 'Dummy_javascript.txt'),
'utf8',
);
const sqlEditor = n8n.ndv.getParameterEditor('query');
await sqlEditor.click();
await n8n.clipboard.paste(dummyCode);
await expect(n8n.ndv.getExecuteNodeButton()).toBeVisible();
});
test('should not trigger dirty flag if nothing is changed', async ({ n8n }) => {
await n8n.canvas.addNode('Postgres', { action: 'Execute a SQL query' });
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.canvas.openNode('Execute a SQL query');
await n8n.ndv.close();
await expect(n8n.canvas.waitForSaveWorkflowCompleted()).rejects.toThrow();
});
test('should trigger dirty flag if query is updated', async ({ n8n }) => {
await n8n.canvas.addNode('Postgres', { action: 'Execute a SQL query' });
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.canvas.openNode('Execute a SQL query');
const sqlEditor = n8n.ndv.getParameterEditor('query');
await sqlEditor.click();
await sqlEditor.fill('SELECT * FROM `testTable`');
await n8n.page.keyboard.press('Escape');
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
});
test('should allow switching between SQL editors in connected nodes', async ({ n8n }) => {
await n8n.canvas.addNode('Postgres', { action: 'Execute a SQL query' });
const sqlEditor = n8n.ndv.getParameterEditor('query');
await sqlEditor.click();
await n8n.clipboard.paste('SELECT * FROM `firstTable`');
await n8n.ndv.close();
await n8n.canvas.addNode('Postgres', { action: 'Execute a SQL query' });
await sqlEditor.click();
await n8n.clipboard.paste('SELECT * FROM `secondTable`');
await n8n.ndv.close();
await n8n.canvas.openNode('Execute a SQL query');
await n8n.ndv.clickFloatingNode('Execute a SQL query1');
await expect(sqlEditor).toHaveText('SELECT * FROM `secondTable`');
await n8n.ndv.clickFloatingNode('Execute a SQL query');
await expect(sqlEditor).toHaveText('SELECT * FROM `firstTable`');
});
});
test.describe('HTML Editor', () => {
const TEST_ELEMENT_H1 = '<h1>Test</h1>';
const TEST_ELEMENT_P = '<p>Test</p>';
test('should preserve changes when opening-closing HTML node', async ({ n8n }) => {
await n8n.canvas.addNode('HTML', { action: 'Generate HTML template' });
const htmlEditor = n8n.ndv.getParameterEditor('html');
await htmlEditor.click();
await htmlEditor.press('ControlOrMeta+A');
await htmlEditor.fill(TEST_ELEMENT_H1);
await n8n.page.keyboard.press('Escape');
await n8n.ndv.close();
await n8n.canvas.openNode('HTML');
await htmlEditor.click();
await htmlEditor.press('End');
await htmlEditor.pressSequentially(TEST_ELEMENT_P);
await n8n.page.keyboard.press('Escape');
await n8n.ndv.close();
await n8n.canvas.openNode('HTML');
await expect(htmlEditor).toContainText(TEST_ELEMENT_H1);
await expect(htmlEditor).toContainText(TEST_ELEMENT_P);
});
test('should not trigger dirty flag if nothing is changed', async ({ n8n }) => {
await n8n.canvas.addNode('HTML', { action: 'Generate HTML template' });
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.canvas.openNode('HTML');
await n8n.ndv.close();
await expect(n8n.canvas.waitForSaveWorkflowCompleted()).rejects.toThrow();
});
test('should trigger dirty flag if query is updated', async ({ n8n }) => {
await n8n.canvas.addNode('HTML', { action: 'Generate HTML template' });
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.canvas.openNode('HTML');
const htmlEditor = n8n.ndv.getParameterEditor('html');
await htmlEditor.click();
await htmlEditor.press('ControlOrMeta+A');
await htmlEditor.fill(TEST_ELEMENT_H1);
await n8n.page.keyboard.press('Escape');
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
});
test('should allow switching between HTML editors in connected nodes', async ({ n8n }) => {
await n8n.canvas.addNode('HTML', { action: 'Generate HTML template' });
const htmlEditor = n8n.ndv.getParameterEditor('html');
await htmlEditor.click();
await htmlEditor.press('ControlOrMeta+A');
await n8n.clipboard.paste('<div>First</div>');
await n8n.ndv.close();
await n8n.canvas.addNode('HTML', { action: 'Generate HTML template' });
await htmlEditor.click();
await htmlEditor.press('ControlOrMeta+A');
await n8n.clipboard.paste('<div>Second</div>');
await n8n.ndv.close();
await n8n.canvas.openNode('HTML');
await n8n.ndv.clickFloatingNode('HTML1');
await expect(htmlEditor).toHaveText('<div>Second</div>');
await n8n.ndv.clickFloatingNode('HTML');
await expect(htmlEditor).toHaveText('<div>First</div>');
});
});
});
@@ -0,0 +1,41 @@
import { test, expect } from '../../../../fixtures/base';
test.describe('Editor zoom should work after route changes', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('debugInEditor');
await n8n.api.enableFeature('workflowHistory');
await n8n.workflowComposer.createWorkflowFromJsonFile(
'Lots_of_nodes.json',
'Lots of nodes test',
);
});
test('should maintain zoom functionality after switching between Editor and Workflow history and Workflow list', async ({
n8n,
}) => {
const initialNodeCount = await n8n.canvas.getCanvasNodes().count();
expect(initialNodeCount).toBeGreaterThan(0);
await n8n.canvasComposer.switchBetweenEditorAndHistory();
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndHistory();
await n8n.canvasComposer.switchBetweenEditorAndHistory();
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndHistory();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
});
});
@@ -0,0 +1,122 @@
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
// Example of using helper functions inside a test
test.describe(
'Debug mode',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
// Constants to avoid magic strings
const URLS = {
FAILING: 'https://foo.bar',
SUCCESS: 'https://postman-echo.com/get?foo1=bar1&foo2=bar2',
};
const NOTIFICATIONS = {
EXECUTION_IMPORTED: 'Execution data imported',
PROBLEM_IN_NODE: 'Problem in node',
SUCCESSFUL: 'Successful',
DATA_NOT_IMPORTED: "Some execution data wasn't imported",
};
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('debugInEditor');
await n8n.goHome();
});
// Helper function to create basic workflow
async function createBasicWorkflow(n8n: n8nPage, url = URLS.FAILING) {
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', url);
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.ndv.close();
}
// Helper function to import execution for debugging
async function importExecutionForDebugging(n8n: n8nPage) {
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickDebugInEditorButton();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.EXECUTION_IMPORTED);
}
test('should enter debug mode for failed executions', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.FAILING);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
NOTIFICATIONS.PROBLEM_IN_NODE,
);
await importExecutionForDebugging(n8n);
expect(n8n.page.url()).toContain('/debug');
});
test('should exit debug mode after successful execution', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.FAILING);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
NOTIFICATIONS.PROBLEM_IN_NODE,
);
await importExecutionForDebugging(n8n);
await n8n.canvas.openNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', URLS.SUCCESS);
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.ndv.close();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
expect(n8n.page.url()).not.toContain('/debug');
});
test('should handle pinned data conflicts during execution import', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.SUCCESS);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
await n8n.canvasComposer.pinNodeData('HTTP Request');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
// Go to executions and try to copy execution to editor
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();
// Test CANCEL dialog
await n8n.executions.handlePinnedNodesConfirmation('Cancel');
// Try again and CONFIRM
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();
await n8n.executions.handlePinnedNodesConfirmation('Unpin');
expect(n8n.page.url()).toContain('/debug');
// Verify pinned status
const pinnedNodeNames = await n8n.canvas.getPinnedNodeNames();
expect(pinnedNodeNames).not.toContain('HTTP Request');
expect(pinnedNodeNames).toContain('When clicking Execute workflow');
});
test.fixme('should show error for pinned data mismatch', async ({ n8n }) => {
// Create workflow, execute, and pin data
await createBasicWorkflow(n8n, URLS.SUCCESS);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
await n8n.canvasComposer.pinNodeData('HTTP Request');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
// Delete node to create mismatch
await n8n.canvas.deleteNodeByName('HTTP Request');
// Try to copy execution and verify error
await attemptCopyToEditor(n8n);
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.DATA_NOT_IMPORTED);
expect(n8n.page.url()).toContain('/debug');
});
async function attemptCopyToEditor(n8n: n8nPage) {
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();
}
},
);
@@ -0,0 +1,368 @@
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
const NODE_NAMES = {
PROCESS_THE_DATA: 'Process The Data',
START_ON_SCHEDULE: 'Start on Schedule',
EDIT_FIELDS: 'Edit Fields',
IF: 'If',
NO_OP_2: 'NoOp2',
WEBHOOK: 'Webhook',
TEST_EXPRESSION: 'Test Expression',
};
const NOTIFICATIONS = {
WORKFLOW_EXECUTED_SUCCESSFULLY: 'Workflow executed successfully',
EXECUTION_STOPPED: 'Execution stopped',
EXECUTION_DELETED: 'Execution deleted',
};
const TIMEOUTS = {
NODE_SUCCESS_WAIT: 5000,
};
/**
* Helper function to assert node execution states (success/running indicators)
*/
async function assertNodeExecutionStates(
n8n: n8nPage,
checks: Array<{
nodeName: string;
success?: 'visible' | 'hidden';
running?: 'visible' | 'hidden';
}>,
) {
for (const check of checks) {
if (check.success !== undefined) {
const assertion = check.success === 'visible' ? 'toBeVisible' : 'toBeHidden';
await expect(n8n.canvas.getNodeSuccessStatusIndicator(check.nodeName))[assertion]();
}
if (check.running !== undefined) {
const assertion = check.running === 'visible' ? 'toBeVisible' : 'toBeHidden';
await expect(n8n.canvas.getNodeRunningStatusIndicator(check.nodeName))[assertion]();
}
}
}
test.describe(
'Execution',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test('should test manual workflow', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Manual_wait_set.json');
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeVisible();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionWaitingForWebhookButton()).toBeHidden();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButtonSpinner()).toBeVisible();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionButton()).toBeVisible();
await expect(n8n.canvas.stopExecutionWaitingForWebhookButton()).toBeHidden();
await assertNodeExecutionStates(n8n, [
{ nodeName: 'Manual', success: 'visible' },
{ nodeName: 'Wait', success: 'hidden', running: 'visible' },
{ nodeName: 'Set', success: 'hidden' },
]);
await assertNodeExecutionStates(n8n, [
{ nodeName: 'Manual', success: 'visible' },
{ nodeName: 'Wait', success: 'visible' },
{ nodeName: 'Set', success: 'visible' },
]);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Wait')).toBeVisible({
timeout: TIMEOUTS.NODE_SUCCESS_WAIT,
});
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await expect(n8n.canvas.clearExecutionDataButton()).toBeVisible();
await n8n.canvas.clearExecutionData();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
});
// Failing/flaky in multi-main
test.fixme('should test manual workflow stop', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Manual_wait_set.json');
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeVisible();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionWaitingForWebhookButton()).toBeHidden();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButtonSpinner()).toBeVisible();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionButton()).toBeVisible();
await expect(n8n.canvas.stopExecutionWaitingForWebhookButton()).toBeHidden();
await assertNodeExecutionStates(n8n, [
{ nodeName: 'Manual', success: 'visible' },
{ nodeName: 'Wait', running: 'visible' },
]);
await n8n.canvas.stopExecutionButton().click();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.EXECUTION_STOPPED);
await assertNodeExecutionStates(n8n, [
{ nodeName: 'Manual', success: 'visible' },
{ nodeName: 'Wait', running: 'hidden' },
{ nodeName: 'Set', success: 'hidden' },
]);
await expect(n8n.canvas.clearExecutionDataButton()).toBeVisible();
await n8n.canvas.clearExecutionData();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
});
test('should test webhook workflow', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Webhook_wait_set.json');
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeVisible();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionWaitingForWebhookButton()).toBeHidden();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButtonSpinner()).toBeVisible();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.stopExecutionWaitingForWebhookButton()).toBeVisible();
await n8n.canvas.openNode('Webhook');
await n8n.clipboard.grant();
await n8n.page.getByTestId('copy-input').click();
await n8n.ndv.clickBackToCanvasButton();
const webhookUrl = await n8n.clipboard.readText();
const response = await n8n.page.request.get(webhookUrl);
expect(response.status()).toBe(200);
await assertNodeExecutionStates(n8n, [
{ nodeName: 'Webhook', success: 'visible' },
{ nodeName: 'Wait', success: 'hidden', running: 'visible' },
{ nodeName: 'Set', success: 'hidden' },
]);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Wait')).toBeVisible({
timeout: TIMEOUTS.NODE_SUCCESS_WAIT,
});
await assertNodeExecutionStates(n8n, [
{ nodeName: 'Webhook', success: 'visible' },
{ nodeName: 'Wait', success: 'visible' },
{ nodeName: 'Set', success: 'visible' },
]);
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await expect(n8n.canvas.clearExecutionDataButton()).toBeVisible();
await n8n.canvas.clearExecutionData();
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
});
test('should execute workflow from specific trigger nodes independently', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Two_schedule_triggers.json');
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getExecuteWorkflowButton('Trigger A')).toHaveCSS('opacity', '0');
await expect(n8n.canvas.getExecuteWorkflowButton('Trigger B')).toHaveCSS('opacity', '0');
await n8n.canvas.nodeByName('Trigger A').hover();
await expect(n8n.canvas.getExecuteWorkflowButton('Trigger A')).toHaveCSS('opacity', '1');
await expect(n8n.canvas.getExecuteWorkflowButton('Trigger B')).toHaveCSS('opacity', '0');
await n8n.canvas.clickExecuteWorkflowButton('Trigger A');
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await n8n.canvas.openNode('Edit Fields');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('Trigger A');
await n8n.ndv.clickBackToCanvasButton();
await expect(n8n.ndv.getContainer()).toBeHidden();
await n8n.canvas.nodeByName('Trigger B').hover();
await expect(n8n.canvas.getExecuteWorkflowButton('Trigger A')).toHaveCSS('opacity', '0');
await expect(n8n.canvas.getExecuteWorkflowButton('Trigger B')).toHaveCSS('opacity', '1');
await n8n.canvas.clickExecuteWorkflowButton('Trigger B');
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await n8n.canvas.openNode('Edit Fields');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('Trigger B');
});
test.describe('execution preview', () => {
test('when deleting the last execution, it should show empty state', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addInitialNodeToCanvas('Manual Trigger');
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.notifications.waitForNotification(NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY);
await n8n.canvas.openExecutions();
await n8n.executions.deleteExecutionInPreview();
await expect(n8n.executions.getSuccessfulExecutionItems()).toHaveCount(0);
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.EXECUTION_DELETED);
});
});
/**
* @TODO New Canvas: Different classes for pinned states on edges and nodes
*/
test.describe('connections should be colored differently for pinned data', () => {
test.fixme();
test('when executing the workflow', async () => {
// Not yet migrated - waiting for New Canvas implementation
});
test('when executing a node', async () => {
// Not yet migrated - waiting for New Canvas implementation
});
test('when connecting pinned node by output drag and drop', async () => {
// Not yet migrated - waiting for New Canvas implementation
});
test('when connecting pinned node after adding an unconnected node', async () => {
// Not yet migrated - waiting for New Canvas implementation
});
});
test('should send proper payload for node rerun', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Multiple_trigger_node_rerun.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.clearExecutionDataButton()).toBeVisible();
const payload = await n8n.executionsComposer.executeNodeAndCapturePayload(
NODE_NAMES.PROCESS_THE_DATA,
);
expect(payload).toHaveProperty('runData');
expect(payload.runData).toBeInstanceOf(Object);
expect(payload.runData).toEqual({
[NODE_NAMES.START_ON_SCHEDULE]: expect.any(Array),
[NODE_NAMES.EDIT_FIELDS]: expect.any(Array),
[NODE_NAMES.PROCESS_THE_DATA]: expect.any(Array),
});
});
test('should send proper payload for manual node run', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Check_manual_node_run_for_pinned_and_rundata.json');
await n8n.canvas.clickZoomToFitButton();
const firstPayload = await n8n.executionsComposer.executeNodeAndCapturePayload(NODE_NAMES.IF);
expect(firstPayload).not.toHaveProperty('runData');
expect(firstPayload).toHaveProperty('workflowId');
expect(firstPayload).not.toHaveProperty('workflowData');
await expect(n8n.canvas.clearExecutionDataButton()).toBeVisible();
const secondPayload = await n8n.executionsComposer.executeNodeAndCapturePayload(
NODE_NAMES.NO_OP_2,
);
expect(secondPayload).toHaveProperty('runData');
expect(secondPayload.runData).toBeInstanceOf(Object);
expect(secondPayload).toHaveProperty('workflowId');
expect(secondPayload).not.toHaveProperty('workflowData');
expect(secondPayload.runData).toEqual({
[NODE_NAMES.IF]: expect.any(Array),
[NODE_NAMES.WEBHOOK]: expect.any(Array),
});
});
test('should successfully execute partial executions with nodes attached to the second output', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('Test_Workflow_pairedItem_incomplete_manual_bug.json');
await n8n.canvas.clickZoomToFitButton();
const workflowRunPromise = n8n.page.waitForRequest(
(request) =>
request.url().includes('/rest/workflows/') &&
request.url().includes('/run') &&
request.method() === 'POST',
);
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.canvas.executeNode(NODE_NAMES.TEST_EXPRESSION);
await workflowRunPromise;
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
test('should execute workflow partially up to the node that has issues', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow(
'Test_workflow_partial_execution_with_missing_credentials.json',
);
const workflowRunPromise = n8n.page.waitForRequest(
(request) =>
request.url().includes('/rest/workflows/') &&
request.url().includes('/run') &&
request.method() === 'POST',
);
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await workflowRunPromise;
await assertNodeExecutionStates(n8n, [
{ nodeName: 'DebugHelper', success: 'visible' },
{ nodeName: 'Filter', success: 'visible' },
]);
await expect(n8n.notifications.getErrorNotifications()).toContainText(
/Problem in node.*Telegram/,
);
});
test('Paired items should be correctly mapped after passed through the merge node with more than two inputs', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('merge_node_inputs_paired_items.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Edit Fields')).toBeVisible();
await n8n.canvas.openNode('Edit Fields');
await n8n.ndv.outputPanel.switchDisplayMode('json');
await expect(n8n.ndv.outputPanel.get()).toContainText('Branch 1 Value');
await expect(n8n.ndv.outputPanel.get()).toContainText('Branch 2 Value');
await expect(n8n.ndv.outputPanel.get()).toContainText('Branch 3 Value');
});
},
);
@@ -0,0 +1,63 @@
import { EDIT_FIELDS_SET_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
const NOTIFICATIONS = {
WORKFLOW_EXECUTED_SUCCESSFULLY: 'Workflow executed successfully',
};
test.describe('Inject previous execution', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('can map keys from previous execution', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('NDV-debug-generate-data.json');
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeVisible();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await n8n.page.reload();
await n8n.canvas.clickNodePlusEndpoint('DebugHelper');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
await n8n.canvas.fillNodeCreatorSearchBar(EDIT_FIELDS_SET_NODE_NAME);
await n8n.canvas.clickNodeCreatorItemName(EDIT_FIELDS_SET_NODE_NAME);
await n8n.page.keyboard.press('Escape');
await n8n.canvas.openNode('Edit Fields');
expect(await n8n.ndv.getInputPanel().innerText()).toContain(
'The fields below come from the last successful execution.',
);
await expect(n8n.ndv.inputPanel.getSchemaItemText('id')).toBeVisible();
await expect(n8n.ndv.inputPanel.getSchemaItemText('firstName')).toBeVisible();
});
test('can pin data from previous execution', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('NDV-debug-generate-data.json');
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeVisible();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.notifications.waitForNotificationAndClose(
NOTIFICATIONS.WORKFLOW_EXECUTED_SUCCESSFULLY,
);
await n8n.page.reload();
await n8n.canvas.openNode('DebugHelper');
await n8n.ndv.getEditPinnedDataButton().click();
const editor = n8n.ndv.outputPanel.get().locator('[contenteditable="true"]');
await expect(editor).toContainText('"password":');
await expect(editor).toContainText('"uid":');
});
});
@@ -0,0 +1,316 @@
import { test, expect } from '../../../../../fixtures/base';
// Node name constants
const NODES = {
MANUAL_TRIGGER: 'When clicking Execute workflow',
CODE: 'Code',
LOOP_OVER_ITEMS: 'Loop Over Items',
WAIT: 'Wait',
CODE1: 'Code1',
SCHEDULE_TRIGGER: 'Schedule Trigger',
EDIT_FIELDS: 'Edit Fields',
IF: 'If',
WAIT_NODE: 'Wait node',
};
test.describe(
'Logs',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
});
test('should populate logs as manual execution progresses', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements({ workflow: 'Workflow_loop.json' });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(0);
await n8n.canvas.clickExecuteWorkflowButton();
await expect(
n8n.canvas.logsPanel.getOverviewStatus().filter({ hasText: 'Running' }),
).toBeVisible();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(4);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(NODES.MANUAL_TRIGGER);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.CODE);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(2)).toContainText(
NODES.LOOP_OVER_ITEMS,
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(3)).toContainText(NODES.WAIT);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(6);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(4)).toContainText(
NODES.LOOP_OVER_ITEMS,
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(5)).toContainText(NODES.WAIT);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(8);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(6)).toContainText(
NODES.LOOP_OVER_ITEMS,
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(7)).toContainText(NODES.WAIT);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(10);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(8)).toContainText(
NODES.LOOP_OVER_ITEMS,
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(9)).toContainText(NODES.CODE1);
await expect(
n8n.canvas.logsPanel.getOverviewStatus().filter({ hasText: /Error in [\d.]+s/ }),
).toBeVisible();
await expect(n8n.canvas.logsPanel.getSelectedLogEntry()).toContainText(NODES.CODE1); // Errored node is automatically selected
await expect(n8n.canvas.logsPanel.outputPanel.getNodeErrorMessageHeader()).toContainText(
'test!!! [line 1]',
);
await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).toBeVisible();
await n8n.canvas.logsPanel.getClearExecutionButton().click();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(0);
await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).toBeHidden();
});
test('should allow to trigger partial execution', async ({ n8n, setupRequirements }) => {
await setupRequirements({ workflow: 'Workflow_if.json' });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(6);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(
NODES.SCHEDULE_TRIGGER,
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.CODE);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(2)).toContainText(NODES.EDIT_FIELDS);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(3)).toContainText(NODES.IF);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(4)).toContainText(NODES.EDIT_FIELDS);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(5)).toContainText(NODES.EDIT_FIELDS);
await n8n.canvas.logsPanel.clickTriggerPartialExecutionAtRow(3);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(3);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(
NODES.SCHEDULE_TRIGGER,
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.CODE);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(2)).toContainText(NODES.IF);
});
// TODO: make it possible to test workflows with AI model end-to-end
test.fixme(
'should show input and output data in the selected display mode',
async ({ n8n, setupRequirements }) => {
await setupRequirements({ workflow: 'Workflow_ai_agent.json' });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
await n8n.canvas.logsPanel.sendManualChatMessage('Hi!');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
await expect(n8n.canvas.logsPanel.getManualChatMessages().nth(0)).toContainText('Hi!');
await expect(n8n.canvas.logsPanel.getManualChatMessages().nth(1)).toContainText(
'Hello from e2e model!!!',
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(2)).toHaveText('E2E Chat Model');
await n8n.canvas.logsPanel.getLogEntries().nth(2).click();
await expect(n8n.canvas.logsPanel.outputPanel.get()).toContainText(
'Hello from e2e model!!!',
);
await n8n.canvas.logsPanel.outputPanel.switchDisplayMode('table');
await expect(n8n.canvas.logsPanel.outputPanel.getTbodyCell(0, 0)).toContainText(
'text:Hello from **e2e** model!!!',
);
await expect(n8n.canvas.logsPanel.outputPanel.getTbodyCell(0, 1)).toContainText(
'completionTokens:20',
);
await n8n.canvas.logsPanel.outputPanel.switchDisplayMode('schema');
await expect(n8n.canvas.logsPanel.outputPanel.get()).toContainText('generations[0]');
await expect(n8n.canvas.logsPanel.outputPanel.get()).toContainText(
'Hello from **e2e** model!!!',
);
await n8n.canvas.logsPanel.outputPanel.switchDisplayMode('json');
await expect(n8n.canvas.logsPanel.outputPanel.get()).toContainText(
'[{"response": {"generations": [',
);
await n8n.canvas.logsPanel.toggleInputPanel();
await expect(n8n.canvas.logsPanel.inputPanel.get()).toContainText('Human: Hi!');
await n8n.canvas.logsPanel.inputPanel.switchDisplayMode('table');
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(0, 0)).toContainText(
'0:Human: Hi!',
);
await n8n.canvas.logsPanel.inputPanel.switchDisplayMode('schema');
await expect(n8n.canvas.logsPanel.inputPanel.get()).toContainText('messages[0]');
await expect(n8n.canvas.logsPanel.inputPanel.get()).toContainText('Human: Hi!');
await n8n.canvas.logsPanel.inputPanel.switchDisplayMode('json');
await expect(n8n.canvas.logsPanel.inputPanel.get()).toContainText(
'[{"messages": ["Human: Hi!"],',
);
},
);
test('should show input and output data of correct run index and branch', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements({ workflow: 'Workflow_if.json' });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.canvas.logsPanel.clickLogEntryAtRow(2); // Run #1 of 'Edit Fields' node; input is 'Code' node
await n8n.canvas.logsPanel.toggleInputPanel();
await n8n.canvas.logsPanel.inputPanel.get().hover();
await n8n.canvas.logsPanel.inputPanel.switchDisplayMode('table');
await expect(n8n.canvas.logsPanel.inputPanel.getTableRows()).toHaveCount(11);
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(0, 0)).toContainText('0');
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(9, 0)).toContainText('9');
await n8n.canvas.logsPanel.clickOpenNdvAtRow(2);
await n8n.ndv.inputPanel.switchDisplayMode('table');
await expect(n8n.ndv.getInputSelect()).toHaveValue(`${NODES.CODE} `);
await expect(n8n.ndv.inputPanel.getTableRows()).toHaveCount(11);
await expect(n8n.ndv.inputPanel.getTbodyCell(0, 0)).toContainText('0');
await expect(n8n.ndv.inputPanel.getTbodyCell(9, 0)).toContainText('9');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue('1 of 3 (10 items)');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.logsPanel.clickLogEntryAtRow(4); // Run #2 of 'Edit Fields' node; input is false branch of 'If' node
await expect(n8n.canvas.logsPanel.inputPanel.getTableRows()).toHaveCount(6);
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(0, 0)).toContainText('5');
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(4, 0)).toContainText('9');
await n8n.canvas.logsPanel.clickOpenNdvAtRow(4);
await expect(n8n.ndv.getInputSelect()).toHaveValue(`${NODES.IF} `);
await expect(n8n.ndv.inputPanel.getTableRows()).toHaveCount(6);
await expect(n8n.ndv.inputPanel.getTbodyCell(0, 0)).toContainText('5');
await expect(n8n.ndv.inputPanel.getTbodyCell(4, 0)).toContainText('9');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue('2 of 3 (5 items)');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.logsPanel.clickLogEntryAtRow(5); // Run #3 of 'Edit Fields' node; input is true branch of 'If' node
await expect(n8n.canvas.logsPanel.inputPanel.getTableRows()).toHaveCount(6);
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(0, 0)).toContainText('0');
await expect(n8n.canvas.logsPanel.inputPanel.getTbodyCell(4, 0)).toContainText('4');
await n8n.canvas.logsPanel.clickOpenNdvAtRow(5);
await expect(n8n.ndv.getInputSelect()).toHaveValue(`${NODES.IF} `);
await expect(n8n.ndv.inputPanel.getTableRows()).toHaveCount(6);
await expect(n8n.ndv.inputPanel.getTbodyCell(0, 0)).toContainText('0');
await expect(n8n.ndv.inputPanel.getTbodyCell(4, 0)).toContainText('4');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue('3 of 3 (5 items)');
});
test('should keep populated logs unchanged when workflow get edits after the execution', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements({ workflow: 'Workflow_if.json' });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(6);
await n8n.canvas.nodeDisableButton(NODES.EDIT_FIELDS).click();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(6);
await n8n.canvas.deleteNodeByName(NODES.IF);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(6);
});
// TODO: make it possible to test workflows with AI model end-to-end
test.fixme('should show logs for a past execution', async ({ n8n, setupRequirements }) => {
await setupRequirements({ workflow: 'Workflow_ai_agent.json' });
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
await n8n.canvas.logsPanel.sendManualChatMessage('Hi!');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
await n8n.canvas.openExecutions();
await n8n.executions.getAutoRefreshButton().click();
await expect(n8n.executions.logsPanel.getManualChatMessages().nth(0)).toContainText('Hi!');
await expect(n8n.executions.logsPanel.getManualChatMessages().nth(1)).toContainText(
'Hello from e2e model!!!',
);
await expect(
n8n.executions.logsPanel.getOverviewStatus().filter({ hasText: /Success in [\d.]+m?s/ }),
).toBeVisible();
await expect(n8n.executions.logsPanel.getLogEntries()).toHaveCount(3);
await expect(n8n.executions.logsPanel.getLogEntries().nth(0)).toContainText(
'When chat message received',
);
await expect(n8n.executions.logsPanel.getLogEntries().nth(1)).toContainText('AI Agent');
await expect(n8n.executions.logsPanel.getLogEntries().nth(2)).toContainText('E2E Chat Model');
});
test('should show logs for a workflow with a node that waits for webhook', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Workflow_wait_for_webhook.json');
await n8n.canvas.deselectAll();
await n8n.canvas.logsPanel.open();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getWaitingNodes()).toContainText(NODES.WAIT_NODE);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText('Waiting');
await n8n.canvas.openNode(NODES.WAIT_NODE);
const webhookUrl = await n8n.ndv.outputPanel
.getDataContainer()
.locator('a')
.getAttribute('href');
await n8n.ndv.clickBackToCanvasButton();
// [CAT-1454] Assert that no duplicate logs added at this point
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2);
// Trigger the webhook
const response = await n8n.page.request.get(webhookUrl!);
expect(response.status()).toBe(200);
await expect(n8n.canvas.getWaitingNodes()).toBeHidden();
await expect(
n8n.canvas.logsPanel.getOverviewStatus().filter({ hasText: /Success in [\d.]+m?s/ }),
).toBeVisible();
await n8n.canvas.logsPanel.getLogEntries().nth(1).click(); // click selected row to deselect
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText('Success');
});
test('should allow to cancel a workflow with a node that waits for webhook', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('Workflow_wait_for_webhook.json');
await n8n.canvas.deselectAll();
await n8n.canvas.logsPanel.open();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getWaitingNodes()).toContainText(NODES.WAIT_NODE);
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(
'When clicking Test workflow',
);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE);
await n8n.canvas.stopExecutionButton().click();
await expect(n8n.canvas.stopExecutionButton()).toBeHidden();
await expect(n8n.canvas.logsPanel.getOverviewStatus()).toContainText('Canceled in');
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(1);
await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText(
'When clicking Test workflow',
);
});
},
);
@@ -0,0 +1,49 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('Manual partial execution', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('should not execute parent nodes with no run data', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('manual-partial-execution.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.openNode('Edit Fields');
await n8n.ndv.clickExecuteStep();
await n8n.ndv.close();
await n8n.canvas.openNode('Webhook1');
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeHidden();
await expect(n8n.ndv.getNodeRunTooltipIndicator()).toBeHidden();
await expect(n8n.ndv.outputPanel.getRunSelector()).toBeHidden();
});
test.describe('partial execution v2', () => {
test('should execute from the first dirty node up to the current node', async ({ n8n }) => {
const nodeNames = ['A', 'B', 'C'];
await n8n.navigate.toWorkflow('new');
await n8n.partialExecutionComposer.enablePartialExecutionV2();
await n8n.start.fromImportedWorkflow('Test_workflow_partial_execution_v2.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.partialExecutionComposer.executeFullWorkflowAndVerifySuccess(nodeNames);
const beforeText = await n8n.partialExecutionComposer.captureNodeOutputData('A');
await n8n.partialExecutionComposer.modifyNodeToTriggerStaleState('B');
await n8n.partialExecutionComposer.verifyNodeStatesAfterChange(['A', 'C'], ['B']);
await n8n.partialExecutionComposer.performPartialExecutionAndVerifySuccess('C', nodeNames);
await n8n.partialExecutionComposer.openNodeForDataVerification('A');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toHaveText(beforeText);
});
});
});
@@ -0,0 +1,48 @@
import { test, expect } from '../../../../../fixtures/base';
// Flaky in multi-main mode: "execute previous nodes" also executes the current node
test.describe(
'Execute previous nodes',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.fixme();
test('should execute only previous nodes and not the current node', async ({ n8n }) => {
// Import workflow with Manual Trigger -> Code1 -> Code2
await n8n.start.fromImportedWorkflow('execute-previous-nodes.json');
// Open the second Code node (Code2)
await n8n.canvas.openNode('Code2');
// Click "Execute previous nodes" - this should execute Manual Trigger and Code1, but NOT Code2
await n8n.ndv.executePrevious();
// Wait for execution to complete by checking that input panel has data
await expect(n8n.ndv.inputPanel.getDataContainer()).toBeVisible({ timeout: 15000 });
// Verify that the input panel has data from Code1 (which was executed)
await expect(n8n.ndv.inputPanel.get()).toContainText('myNewField');
// Verify Code2 (current node) was NOT executed.
// The output panel should show the placeholder text, not execution results
await expect(n8n.ndv.outputPanel.get()).toContainText('Execute step or set mock data');
// Close the NDV
await n8n.ndv.close();
// Open Code1 to verify it WAS executed
await n8n.canvas.openNode('Code1');
// Verify Code1 has execution success indicator and output data
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeVisible();
await expect(n8n.ndv.outputPanel.getItemsCount()).toBeVisible();
// Close and verify Manual Trigger was also executed
await n8n.ndv.close();
await n8n.canvas.openNode('Manual Trigger');
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeVisible();
});
},
);
@@ -0,0 +1,166 @@
import {
EDIT_FIELDS_SET_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
NO_OPERATION_NODE_NAME,
HACKER_NEWS_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
const SCHEDULE_PARAMETER_NAME = 'daysInterval';
const HACKER_NEWS_ACTION = 'Get many items';
const HACKER_NEWS_PARAMETER_NAME = 'limit';
test.describe(
'Inline expression editor',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test.describe('Basic UI functionality', () => {
test('should open and close inline expression preview', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.activateParameterExpressionEditor(SCHEDULE_PARAMETER_NAME);
await n8n.ndv.getInlineExpressionEditorInput(SCHEDULE_PARAMETER_NAME).click();
await n8n.ndv.clearExpressionEditor(SCHEDULE_PARAMETER_NAME);
await n8n.ndv.typeInExpressionEditor('{{ 123', SCHEDULE_PARAMETER_NAME);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('123');
// Click outside to close
await n8n.ndv.outputPanel.get().click();
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toBeHidden();
});
test.fixme('should switch between expression and fixed using keyboard', async ({ n8n }) => {
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME);
// Should switch to expression with =
await n8n.ndv.getAssignmentCollectionAdd('assignments').click();
await n8n.ndv.fillParameterInputByName('value', '=');
// Should complete {{ --> {{ | }}
await n8n.ndv.getInlineExpressionEditorInput().click();
await n8n.ndv.typeInExpressionEditor('{{');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText('{{ }}');
// Should switch back to fixed with backspace on empty expression
await n8n.ndv.clearExpressionEditor('value');
await expect(n8n.ndv.getParameterInputHint()).toContainText('empty');
const parameterInput = n8n.ndv.getParameterInput('value').getByRole('textbox');
await parameterInput.click();
await parameterInput.focus();
await parameterInput.press('Backspace');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toBeHidden();
});
});
test.describe('Static data', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.activateParameterExpressionEditor(SCHEDULE_PARAMETER_NAME);
});
test('should resolve primitive resolvables', async ({ n8n }) => {
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ 1 + 2');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('3');
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ "ab" + "cd"');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('abcd');
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ true && false');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('false');
});
test('should resolve object resolvables', async ({ n8n }) => {
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ { a: 1 }');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText(
/^\[Object: \{"a": 1\}\]$/,
);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ { a: 1 }.a');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('1');
});
test('should resolve array resolvables', async ({ n8n }) => {
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ [1, 2, 3]');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText(
/^\[Array: \[1,2,3\]\]$/,
);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ [1, 2, 3][0]');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('1');
});
});
test.describe('Dynamic data', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.setPinnedData([{ myStr: 'Monday' }]);
await n8n.ndv.close();
await n8n.canvas.addNode(NO_OPERATION_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(HACKER_NEWS_NODE_NAME, { action: HACKER_NEWS_ACTION });
await n8n.ndv.activateParameterExpressionEditor(HACKER_NEWS_PARAMETER_NAME);
});
test('should resolve $parameter[]', async ({ n8n }) => {
await n8n.ndv.clearExpressionEditor();
// Resolving $parameter is slow, especially on CI runner
await n8n.ndv.typeInExpressionEditor('{{ $parameter["operation"]');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('getAll');
});
test('should resolve input: $json,$input,$(nodeName)', async ({ n8n }) => {
// Previous nodes have not run, input is empty
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ $json.myStr');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText(
'[Execute previous nodes for preview]',
);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ $input.item.json.myStr');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText(
'[Execute previous nodes for preview]',
);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor("{{ $('No Operation, do nothing').item.json.myStr");
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText(
'[Execute previous nodes for preview]',
);
// Run workflow
await n8n.ndv.close();
await n8n.canvas.executeNode(NO_OPERATION_NODE_NAME);
await n8n.canvas.openNode(HACKER_NEWS_ACTION);
await n8n.ndv.activateParameterExpressionEditor(HACKER_NEWS_PARAMETER_NAME);
// Previous nodes have run, input can be resolved
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ $json.myStr');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('Monday');
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ $input.item.json.myStr');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('Monday');
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor("{{ $('No Operation, do nothing').item.json.myStr");
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('Monday');
});
});
},
);
@@ -0,0 +1,385 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe(
'Data Mapping',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.describe
.serial('Expression Preview', () => {
test('maps expressions from table json, and resolves value based on hover', async ({
n8n,
}) => {
// This test is marked as serial because hover/tooltips are unreliable when running in parallel against a single server due to resource contention.
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await expect(n8n.ndv.inputPanel.getTable()).toBeVisible();
await expect(n8n.ndv.getParameterInputField('name')).toHaveValue('other');
await expect(n8n.ndv.getParameterInputField('value')).toHaveValue('');
const countCell = n8n.ndv.inputPanel.getTableCellSpan(0, 0, 'count');
await expect(countCell).toBeVisible();
const valueParameter = n8n.ndv.getParameterInput('value');
await n8n.interactions.precisionDragToTarget(countCell, valueParameter, 'bottom');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input[0].count }}',
);
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('0');
await n8n.ndv.inputPanel.getTbodyCell(0, 0).hover();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('0');
await n8n.ndv.inputPanel.getTbodyCell(1, 0).hover();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('1');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.getTable()).toBeVisible();
await n8n.ndv.outputPanel.getTbodyCell(0, 0).hover();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('0');
await n8n.ndv.outputPanel.getTbodyCell(1, 0).hover();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('1');
});
});
test('maps expressions from json view', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.inputPanel.switchDisplayMode('json');
const expectedJsonText =
'[ { "input": [ { "count": 0, "with space": "!!", "with.dot": "!!", "with"quotes": "!!" } ] }, { "input": [ { "count": 1 } ] }]';
await expect(async () => {
await expect(n8n.ndv.inputPanel.get().getByText(expectedJsonText)).toBeVisible();
}).toPass({ timeout: 1000 });
await expect(n8n.ndv.inputPanel.getJsonDataContainer()).toBeVisible();
const inputSpan = n8n.ndv.inputPanel.getJsonProperty('input');
await expect(inputSpan).toBeVisible();
const valueParameterInput = n8n.ndv.getParameterInput('value');
await expect(valueParameterInput).toBeVisible();
await inputSpan.dragTo(valueParameterInput);
const expressionEditor = n8n.ndv.getInlineExpressionEditorInput();
await expect(expressionEditor).toBeVisible();
await expect(expressionEditor).toHaveText('{{ $json.input }}');
await n8n.page.keyboard.press('Escape');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('Array:');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('"count": 0');
const countSpan = n8n.ndv.inputPanel.getJsonPropertyContaining('count');
await expect(countSpan).toBeVisible();
await n8n.interactions.precisionDragToTarget(
countSpan,
n8n.ndv.getInlineExpressionEditorInput(),
'bottom',
);
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input }}{{ $json.input[0].count }}',
);
await n8n.page.keyboard.press('Escape');
const previewElement = n8n.ndv.getParameterExpressionPreviewValue();
await expect(previewElement).toBeVisible();
});
test('maps expressions from previous nodes', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set1');
await n8n.ndv.executePrevious();
const scheduleNode = n8n.ndv.inputPanel.get().getByText('Schedule Trigger');
await expect(scheduleNode).toBeVisible();
await scheduleNode.click();
const schemaItem = n8n.ndv.inputPanel.getSchemaItemText('count');
await expect(schemaItem).toBeVisible();
const valueParameterInput = n8n.ndv.getParameterInput('value');
await expect(valueParameterInput).toBeVisible();
await n8n.interactions.precisionDragToTarget(
schemaItem.locator('span'),
valueParameterInput,
'top',
);
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
"{{ $('Schedule Trigger').item.json.input[0].count }}",
);
await n8n.page.keyboard.press('Escape');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.selectInputNode('Schedule Trigger');
const headerElement = n8n.ndv.inputPanel.getTableHeader(0);
await expect(headerElement).toBeVisible();
await n8n.interactions.precisionDragToTarget(
headerElement,
n8n.ndv.getInlineExpressionEditorInput(),
'top',
);
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
"{{ $('Schedule Trigger').item.json.input }}{{ $('Schedule Trigger').item.json.input[0].count }}",
);
await n8n.ndv.selectInputNode('Set');
});
test('maps expressions from table header', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow-actions_paste-data.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.executePrevious();
await n8n.ndv.inputPanel.switchDisplayMode('table');
await expect(n8n.ndv.inputPanel.getTable()).toBeVisible();
const addValueButton = n8n.ndv.getAddValueButton();
await expect(addValueButton).toBeVisible();
await addValueButton.click();
await n8n.page.getByRole('option', { name: 'String' }).click();
await expect(n8n.ndv.getParameterInputField('name')).toHaveValue('propertyName');
await expect(n8n.ndv.getParameterInputField('value')).toHaveValue('');
const firstHeader = n8n.ndv.inputPanel.getTableHeader(0);
await expect(firstHeader).toBeVisible();
const valueParameter = n8n.ndv.getParameterInput('value');
await n8n.interactions.precisionDragToTarget(firstHeader, valueParameter, 'center');
// Wait for expression editor to appear after drop
await expect(n8n.ndv.getInlineExpressionEditorInput()).toBeVisible({ timeout: 15000 });
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText('{{ $json.timestamp }}');
await n8n.page.keyboard.press('Escape');
const currentYear = new Date().getFullYear().toString();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText(currentYear);
const secondHeader = n8n.ndv.inputPanel.getTableHeader(1);
await expect(secondHeader).toBeVisible();
await n8n.interactions.precisionDragToTarget(
secondHeader,
n8n.ndv.getInlineExpressionEditorInput(),
'top',
);
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
"{{ $json['Readable date'] }}{{ $json.timestamp }}",
);
});
test('maps expressions from schema view', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.getParameterInputField('value').clear();
await n8n.page.keyboard.press('Escape');
const countSchemaItem = n8n.ndv.inputPanel.getSchemaItemText('count');
await expect(countSchemaItem).toBeVisible();
const valueParameter = n8n.ndv.getParameterInput('value');
await n8n.interactions.precisionDragToTarget(countSchemaItem, valueParameter, 'bottom');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input[0].count }}',
);
await n8n.page.keyboard.press('Escape');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('0');
const inputSchemaItem = n8n.ndv.inputPanel.getSchemaItemText('input');
await expect(inputSchemaItem).toBeVisible();
await n8n.interactions.precisionDragToTarget(inputSchemaItem, valueParameter, 'top');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input }}{{ $json.input[0].count }}',
);
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('[object Object]0');
});
test('maps keys to path', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.openNode('When clicking Execute workflow');
await n8n.ndv.setPinnedData([
{
input: [
{
'hello.world': {
'my count': 0,
},
},
],
},
{
input: [
{
'hello.world': {
'my count': 1,
},
},
],
},
]);
await n8n.ndv.close();
await n8n.canvas.addNode('Sort');
const addFieldButton = n8n.ndv.getAddFieldToSortByButton();
await addFieldButton.click();
const myCountSpan = n8n.ndv.inputPanel.getSchemaItemText('my count');
await expect(myCountSpan).toBeVisible();
const fieldNameParameter = n8n.ndv.getParameterInput('fieldName');
await n8n.interactions.precisionDragToTarget(myCountSpan, fieldNameParameter, 'bottom');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toBeHidden();
await expect(n8n.ndv.getParameterInputField('fieldName')).toHaveValue(
"input[0]['hello.world']['my count']",
);
});
test('maps expressions to updated fields correctly @fixme', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.fillParameterInputByName('value', 'delete me');
await n8n.ndv.fillParameterInputByName('name', 'test');
await n8n.ndv.getParameterInputField('name').blur();
await n8n.ndv.fillParameterInputByName('value', 'fun');
await n8n.ndv.getParameterInputField('value').clear();
const countSchemaItem = n8n.ndv.inputPanel.getSchemaItemText('count');
await expect(countSchemaItem).toBeVisible();
const valueParameter = n8n.ndv.getParameterInput('value');
await n8n.interactions.precisionDragToTarget(countSchemaItem, valueParameter, 'bottom');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input[0].count }}',
);
await n8n.page.keyboard.press('Escape');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('0');
const inputSchemaItem = n8n.ndv.inputPanel.getSchemaItemText('input');
await expect(inputSchemaItem).toBeVisible();
await n8n.interactions.precisionDragToTarget(inputSchemaItem, valueParameter, 'top');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input }}{{ $json.input[0].count }}',
);
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('[object Object]0');
});
test('renders expression preview when a previous node is selected', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.fillParameterInputByName('value', 'test_value');
await n8n.ndv.fillParameterInputByName('name', 'test_name');
await n8n.ndv.close();
await n8n.canvas.openNode('Set1');
await n8n.ndv.executePrevious();
await n8n.ndv.inputPanel.switchDisplayMode('table');
const firstHeader = n8n.ndv.inputPanel.getTableHeader(0);
await expect(firstHeader).toBeVisible();
const valueParameter = n8n.ndv.getParameterInput('value');
await n8n.interactions.precisionDragToTarget(firstHeader, valueParameter, 'bottom');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('test_value');
await n8n.ndv.selectInputNode('Schedule Trigger');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('test_value');
});
test('shows you can drop to inputs, including booleans', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await expect(n8n.ndv.getParameterSwitch('includeOtherFields')).toBeVisible();
await expect(n8n.ndv.getParameterTextInput('includeOtherFields')).toBeHidden();
const countSpan = n8n.ndv.inputPanel.getSchemaItemText('count');
await expect(countSpan).toBeVisible();
await countSpan.hover();
await n8n.page.mouse.down();
await n8n.page.mouse.move(100, 100);
await expect(n8n.ndv.getParameterSwitch('includeOtherFields')).toBeHidden();
await expect(n8n.ndv.getParameterTextInput('includeOtherFields')).toBeVisible();
// Check droppable state on parameter inputs
const includeOtherFieldsInput = n8n.ndv.getParameterInput('includeOtherFields');
await expect(includeOtherFieldsInput).toHaveClass(/droppable/);
const valueInput = n8n.ndv.getParameterInput('value');
await expect(valueInput).toHaveClass(/droppable/);
await n8n.page.mouse.up();
});
test('maps expressions to a specific location in the editor', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set');
await n8n.ndv.fillParameterInputByName('value', '=');
await n8n.ndv.getInlineExpressionEditorContent().fill('hello world\n\nnewline');
await n8n.page.keyboard.press('Escape');
const countSchemaItem = n8n.ndv.inputPanel.getSchemaItemText('count');
await expect(countSchemaItem).toBeVisible();
const valueParameter = n8n.ndv.getParameterInput('value');
await n8n.interactions.precisionDragToTarget(countSchemaItem, valueParameter, 'top');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input[0].count }}hello worldnewline',
);
await n8n.page.keyboard.press('Escape');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText(
'0hello world\n\nnewline',
);
const inputSchemaItem = n8n.ndv.inputPanel.getSchemaItemText('input');
await expect(inputSchemaItem).toBeVisible();
await n8n.interactions.precisionDragToTarget(inputSchemaItem, valueParameter, 'center');
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveText(
'{{ $json.input[0].count }}hello world{{ $json.input }}newline',
);
});
},
);
@@ -0,0 +1,121 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('Expression editor modal', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addInitialNodeToCanvas('Schedule Trigger');
await n8n.ndv.close();
});
test.describe('Keybinds', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.canvas.addNode('Hacker News', { action: 'Get many items' });
await n8n.ndv.openExpressionEditorModal('limit');
});
test('should save the workflow with save keybind', async ({ n8n }) => {
const input = n8n.ndv.getExpressionEditorModalInput();
await n8n.ndv.fillExpressionEditorModalInput('{{ "hello"');
await expect(n8n.ndv.getExpressionEditorModalOutput()).toContainText('hello');
await input.press('ControlOrMeta+s');
await n8n.notifications.waitForNotificationAndClose('Saved successfully');
});
});
test.describe('Static data', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.canvas.addNode('Hacker News', { action: 'Get many items' });
await n8n.ndv.openExpressionEditorModal('limit');
});
test('should resolve primitive resolvables', async ({ n8n }) => {
const output = n8n.ndv.getExpressionEditorModalOutput();
// Test number addition
await n8n.ndv.fillExpressionEditorModalInput('{{ 1 + 2 }}');
await expect(output).toContainText(/^3$/);
// Test string concatenation
await n8n.ndv.fillExpressionEditorModalInput('{{ "ab" + "cd" }}');
await expect(output).toContainText(/^abcd$/);
// Test boolean logic
await n8n.ndv.fillExpressionEditorModalInput('{{ true && false }}');
await expect(output).toContainText(/^false$/);
});
test('should resolve object resolvables', async ({ n8n }) => {
const output = n8n.ndv.getExpressionEditorModalOutput();
// Test object creation
await n8n.ndv.fillExpressionEditorModalInput('{{ { a : 1 } }}');
await expect(output).toContainText(/^\[Object: \{"a": 1\}\]$/);
// Test object property access
await n8n.ndv.fillExpressionEditorModalInput('{{ { a : 1 }.a }}');
await expect(output).toContainText(/^1$/);
});
test('should resolve array resolvables', async ({ n8n }) => {
const output = n8n.ndv.getExpressionEditorModalOutput();
// Test array creation
await n8n.ndv.fillExpressionEditorModalInput('{{ [1, 2, 3] }}');
await expect(output).toContainText(/^\[Array: \[1,2,3\]\]$/);
// Test array element access
await n8n.ndv.fillExpressionEditorModalInput('{{ [1, 2, 3][0] }}');
await expect(output).toContainText(/^1$/);
});
});
test.describe('Dynamic data', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.canvas.openNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'Monday' }]);
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.addNode('No Operation, do nothing', { closeNDV: true });
await n8n.canvas.addNode('Hacker News', { action: 'Get many items' });
await n8n.ndv.openExpressionEditorModal('limit');
});
test('should resolve $parameter[]', async ({ n8n }) => {
const output = n8n.ndv.getExpressionEditorModalOutput();
await n8n.ndv.fillExpressionEditorModalInput('{{ $parameter["operation"] }}');
await expect(output).toHaveText('getAll');
});
test('should resolve input: $json,$input,$(nodeName)', async ({ n8n }) => {
const output = n8n.ndv.getExpressionEditorModalOutput();
// Previous nodes have not run, input is empty
await n8n.ndv.fillExpressionEditorModalInput('{{ $json.myStr }}');
await expect(output).toHaveText('[Execute previous nodes for preview]');
await n8n.ndv.fillExpressionEditorModalInput('{{ $input.item.json.myStr }}');
await expect(output).toHaveText('[Execute previous nodes for preview]');
await n8n.ndv.fillExpressionEditorModalInput("{{ $('Schedule Trigger').item.json.myStr }}");
await expect(output).toHaveText('[Execute previous nodes for preview]');
// Run workflow
await output.click();
await n8n.page.keyboard.press('Escape');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.executeNode('No Operation, do nothing');
await n8n.canvas.openNode('Get many items');
await n8n.ndv.openExpressionEditorModal('limit');
// Previous nodes have run, input can be resolved
await n8n.ndv.fillExpressionEditorModalInput('{{ $json.myStr }}');
await expect(output).toHaveText('Monday');
await n8n.ndv.fillExpressionEditorModalInput('{{ $input.item.json.myStr }}');
await expect(output).toHaveText('Monday');
await n8n.ndv.fillExpressionEditorModalInput("{{ $('Schedule Trigger').item.json.myStr }}");
await expect(output).toHaveText('Monday');
});
});
});
@@ -0,0 +1,138 @@
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
test.describe('Data transformation expressions', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
async function addEditFields(n8n: n8nPage): Promise<void> {
await n8n.canvas.addNode('Edit Fields (Set)');
await n8n.ndv.getAssignmentCollectionAdd('assignments').click();
// Switch assignment value to Expression mode
const assignmentValue = n8n.ndv.getAssignmentValue('assignments');
await assignmentValue.locator('text=Expression').click();
}
test('$json + native string methods', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'Monday' }]);
await n8n.ndv.close();
await addEditFields(n8n);
const input = '{{$json.myStr.toLowerCase() + " is " + "today".toUpperCase()}}';
const output = 'monday is TODAY';
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(input);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText(output);
// Execute and verify output
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputDataContainer()).toBeVisible();
await expect(n8n.ndv.getOutputDataContainer()).toContainText(output);
});
test('$json + n8n string methods', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
await n8n.ndv.close();
await addEditFields(n8n);
const input = '{{$json.myStr.extractEmail() + " " + $json.myStr.isEmpty()}}';
const output = 'hello@n8n.io false';
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(input);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText(output);
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputDataContainer()).toBeVisible();
await expect(n8n.ndv.getOutputDataContainer()).toContainText(output);
});
test('$json + native numeric methods', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myNum: 9.123 }]);
await n8n.ndv.close();
await addEditFields(n8n);
const input = '{{$json.myNum.toPrecision(3)}}';
const output = '9.12';
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(input);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText(output);
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputDataContainer()).toBeVisible();
await expect(n8n.ndv.getOutputDataContainer()).toContainText(output);
});
test('$json + n8n numeric methods', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
await n8n.ndv.close();
await addEditFields(n8n);
const input = '{{$json.myStr.extractEmail() + " " + $json.myStr.isEmpty()}}';
const output = 'hello@n8n.io false';
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(input);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText(output);
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputDataContainer()).toBeVisible();
await expect(n8n.ndv.getOutputDataContainer()).toContainText(output);
});
test('$json + native array access', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myArr: [1, 2, 3] }]);
await n8n.ndv.close();
await addEditFields(n8n);
const input = '{{$json.myArr.includes(1) + " " + $json.myArr[2]}}';
const output = 'true 3';
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(input);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText(output);
await n8n.ndv.execute();
const valueElements = n8n.ndv.getOutputDataContainer().locator('[class*=value_]');
await expect(valueElements).toBeVisible();
await expect(valueElements).toContainText(output);
});
test('$json + n8n array methods', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.setPinnedData([{ myArr: [1, 2, 3] }]);
await n8n.ndv.close();
await addEditFields(n8n);
const input = '{{$json.myArr.first() + " " + $json.myArr.last()}}';
const output = '1 3';
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(input);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText(output);
await n8n.ndv.execute();
const valueElements = n8n.ndv.getOutputDataContainer().locator('[class*=value_]');
await expect(valueElements).toBeVisible();
await expect(valueElements).toContainText(output);
});
});
@@ -0,0 +1,129 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('Node IO Filter', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Node_IO_filter.json');
await n8n.canvas.clickExecuteWorkflowButton();
});
test('should filter pinned data', async ({ n8n }) => {
const canvasNodes = n8n.canvas.getCanvasNodes();
await canvasNodes.first().dblclick();
await n8n.ndv.close();
await canvasNodes.first().dblclick();
await expect(n8n.ndv.outputPanel.getDataContainer()).toBeVisible();
const searchContainer = n8n.ndv.outputPanel.getSearchContainer();
const searchInput = n8n.ndv.outputPanel.getSearchInput();
await expect(searchContainer).toBeVisible();
await n8n.page.keyboard.press('/');
await expect(searchInput).toBeFocused();
// 35 items with page size 25 = 2 pages
const pagination = n8n.ndv.getOutputPagination();
await expect(pagination.locator('li')).toHaveCount(2);
await expect(n8n.ndv.outputPanel.getDataContainer().locator('mark')).toHaveCount(0);
// Search for 'zzz' - filters to 0 items, so no pagination
await searchInput.fill('zzz');
await expect(pagination).toBeHidden();
// Search for 'Reese' - filters to 1 item, no pagination, highlights visible
await searchInput.fill('Reese');
await expect(pagination).toBeHidden();
await expect(n8n.ndv.outputPanel.getDataContainer().locator('mark').first()).toBeVisible();
// Clear search - back to 35 items with pagination
await searchInput.clear();
await expect(pagination.locator('li')).toHaveCount(2);
});
test('should filter input/output data separately', async ({ n8n }) => {
const canvasNodes = n8n.canvas.getCanvasNodes();
await canvasNodes.nth(1).dblclick();
await expect(n8n.ndv.outputPanel.getDataContainer()).toBeVisible();
await expect(n8n.ndv.inputPanel.getDataContainer()).toBeVisible();
await n8n.ndv.inputPanel.switchDisplayMode('table');
await expect(n8n.ndv.outputPanel.getSearchContainer()).toBeVisible();
await n8n.page.keyboard.press('/');
await expect(n8n.ndv.outputPanel.getSearchInput()).not.toBeFocused();
const inputSearchInput = n8n.ndv.inputPanel.getSearchInput();
await expect(inputSearchInput).toBeFocused();
const getInputPagination = () => n8n.ndv.inputPanel.get().getByTestId('ndv-data-pagination');
const getInputCounter = () => n8n.ndv.inputPanel.getItemsCount();
const getOutputPagination = () => n8n.ndv.outputPanel.get().getByTestId('ndv-data-pagination');
const getOutputCounter = () => n8n.ndv.outputPanel.getItemsCount();
// 35 items with page size 25 = 2 pages
await expect(getInputPagination().locator('li')).toHaveCount(2);
await expect(getInputCounter()).toContainText('35 items');
await expect(getOutputPagination().locator('li')).toHaveCount(2);
await expect(getOutputCounter()).toContainText('35 items');
// Search for 'Reese' - filters to 1 item (< 25, so no pagination)
await inputSearchInput.fill('Reese');
await expect(getInputPagination()).toBeHidden();
await expect(getInputCounter()).toContainText('of 35 items');
await expect(getOutputPagination().locator('li')).toHaveCount(2);
await expect(getOutputCounter()).toContainText('35 items');
// Search for 'zzz' - filters to 0 items, no pagination
await inputSearchInput.fill('zzz');
await expect(getInputPagination()).toBeHidden();
await expect(getInputCounter()).toContainText('of 35 items');
await expect(getOutputPagination().locator('li')).toHaveCount(2);
await expect(getOutputCounter()).toContainText('35 items');
await inputSearchInput.clear();
await expect(getInputPagination().locator('li')).toHaveCount(2);
await expect(getInputCounter()).toContainText('35 items');
await expect(getOutputPagination().locator('li')).toHaveCount(2);
await expect(getOutputCounter()).toContainText('35 items');
await n8n.ndv.outputPanel.getDataContainer().click();
await n8n.page.keyboard.press('/');
await expect(n8n.ndv.inputPanel.getSearchInput()).not.toBeFocused();
const outputSearchInput = n8n.ndv.outputPanel.getSearchInput();
await expect(outputSearchInput).toBeFocused();
await expect(getInputPagination().locator('li')).toHaveCount(2);
await expect(getInputCounter()).toContainText('35 items');
await expect(getOutputPagination().locator('li')).toHaveCount(2);
await expect(getOutputCounter()).toContainText('35 items');
// Search for 'Reese' - filters to 1 item (< 25, so no pagination)
await outputSearchInput.fill('Reese');
await expect(getInputPagination().locator('li')).toHaveCount(2);
await expect(getInputCounter()).toContainText('35 items');
await expect(getOutputPagination()).toBeHidden();
await expect(getOutputCounter()).toContainText('of 35 items');
// Search for 'zzz' - filters to 0 items, no pagination
await outputSearchInput.fill('zzz');
await expect(getInputPagination().locator('li')).toHaveCount(2);
await expect(getInputCounter()).toContainText('35 items');
await expect(getOutputPagination()).toBeHidden();
await expect(getOutputCounter()).toContainText('of 35 items');
await outputSearchInput.clear();
await expect(getInputPagination().locator('li')).toHaveCount(2);
await expect(getInputCounter()).toContainText('35 items');
await expect(getOutputPagination().locator('li')).toHaveCount(2);
await expect(getOutputCounter()).toContainText('35 items');
});
});
@@ -0,0 +1,246 @@
import {
CODE_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe('NDV', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should show up when double clicked on a node and close when Back to canvas clicked', async ({
n8n,
}) => {
await n8n.canvas.addNode('Manual Trigger');
const canvasNodes = n8n.canvas.getCanvasNodes();
await canvasNodes.first().dblclick();
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.clickBackToCanvasButton();
await expect(n8n.ndv.getContainer()).toBeHidden();
});
test('should show input panel when node is not connected', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.deselectAll();
await n8n.canvas.addNode('Edit Fields (Set)', { closeNDV: true });
const canvasNodes = n8n.canvas.getCanvasNodes();
await canvasNodes.last().dblclick();
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.inputPanel.get()).toContainText('No input connected');
});
test('should change input and go back to canvas', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('NDV-test-select-input.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.getCanvasNodes().last().dblclick();
await n8n.ndv.execute();
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.inputPanel.getNodeInputOptions().last().click();
await expect(n8n.ndv.inputPanel.get()).toContainText('start');
await n8n.ndv.clickBackToCanvasButton();
await expect(n8n.ndv.getContainer()).toBeHidden();
});
test('should show correct validation state for resource locator params', async ({ n8n }) => {
await n8n.canvas.addNode('Typeform Trigger', { closeNDV: false });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.openNode('Typeform Trigger');
await expect(n8n.canvas.getNodeIssuesByName('Typeform Trigger')).toBeVisible();
});
test('should show validation errors only after blur or re-opening of NDV', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Airtable', { closeNDV: false, action: 'Search records' });
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.canvas.getNodeIssuesByName('Airtable')).toBeHidden();
await n8n.ndv.getParameterInputField('table').nth(1).focus();
await n8n.ndv.getParameterInputField('table').nth(1).blur();
await n8n.ndv.getParameterInputField('base').nth(1).focus();
await n8n.ndv.getParameterInputField('base').nth(1).blur();
await expect(n8n.ndv.getParameterInput('base')).toHaveClass(/has-issues|error|invalid/);
await expect(n8n.ndv.getParameterInput('table')).toHaveClass(/has-issues|error|invalid/);
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.openNode('Search records');
await expect(n8n.canvas.getNodeIssuesByName('Search records')).toBeVisible();
});
test('should show all validation errors when opening pasted node', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_ndv_errors.json');
const canvasNodes = n8n.canvas.getCanvasNodes();
await expect(canvasNodes).toHaveCount(1);
await n8n.canvas.openNode('Airtable');
await expect(n8n.canvas.getNodeIssuesByName('Airtable')).toBeVisible();
});
test('should render run errors correctly', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_ndv_run_error.json');
await n8n.canvas.openNode('Error');
await n8n.ndv.execute();
await expect(n8n.ndv.getNodeRunErrorMessage()).toHaveText(
"Paired item data for item from node 'Break pairedItem chain' is unavailable. Ensure 'Break pairedItem chain' is providing the required output. [item 0]",
);
await expect(n8n.ndv.getNodeRunErrorDescription()).toContainText(
"An expression here won't work because it uses .item and n8n can't figure out the matching item.",
);
await expect(n8n.ndv.getNodeRunErrorMessage()).toBeVisible();
await expect(n8n.ndv.getNodeRunErrorDescription()).toBeVisible();
});
test('webhook should fallback to webhookId if path is empty', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook', { closeNDV: false });
await expect(n8n.canvas.getNodeIssuesByName('Webhook')).toBeHidden();
await expect(n8n.ndv.getExecuteNodeButton()).toBeEnabled();
await expect(n8n.ndv.getTriggerPanelExecuteButton()).toBeVisible();
await n8n.ndv.getParameterInputField('path').clear();
const webhookUrlsContainer = n8n.ndv.getContainer().getByText('Webhook URLs').locator('..');
const urlText = await webhookUrlsContainer.textContent();
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
expect(urlText).toMatch(uuidRegex);
await n8n.ndv.close();
await n8n.canvas.openNode('Webhook');
await n8n.ndv.fillParameterInput('path', 'test-path');
const updatedUrlText = await webhookUrlsContainer.textContent();
expect(updatedUrlText).toContain('test-path');
expect(updatedUrlText).not.toMatch(uuidRegex);
});
test('should properly show node execution indicator', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Code', { action: 'Code in JavaScript', closeNDV: false });
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeHidden();
await expect(n8n.ndv.getNodeRunErrorIndicator()).toBeHidden();
await expect(n8n.ndv.getNodeRunTooltipIndicator()).toBeHidden();
await n8n.ndv.execute();
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeVisible();
await expect(n8n.ndv.getNodeRunTooltipIndicator()).toBeVisible();
});
test('should show node name and version in settings', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_ndv_version.json');
await n8n.canvas.openNode('Edit Fields (old)');
await n8n.ndv.openSettings();
await expect(n8n.ndv.getNodeVersion()).toContainText('Set node version 2');
await expect(n8n.ndv.getNodeVersion()).toContainText('Latest version: 3.4');
await n8n.ndv.close();
await n8n.canvas.openNode('Edit Fields (latest)');
await n8n.ndv.openSettings();
await expect(n8n.ndv.getNodeVersion()).toContainText('Edit Fields (Set) node version 3.4');
await expect(n8n.ndv.getNodeVersion()).toContainText('Latest');
await n8n.ndv.close();
await n8n.canvas.openNode('Function');
await n8n.ndv.openSettings();
await expect(n8n.ndv.getNodeVersion()).toContainText('Function node version 1');
await expect(n8n.ndv.getNodeVersion()).toContainText('Deprecated');
await n8n.ndv.close();
});
test('should not push NDV header out with a lot of code in Code node editor', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Code', { action: 'Code in JavaScript', closeNDV: false });
const codeEditor = n8n.ndv.getParameterInput('jsCode').locator('.cm-content');
await codeEditor.click();
await n8n.page.keyboard.press('ControlOrMeta+a');
await n8n.page.keyboard.press('Delete');
const dummyCode = Array(50)
.fill(
'console.log("This is a very long line of dummy JavaScript code that should not push the NDV header out of view");',
)
.join('\n');
await codeEditor.fill(dummyCode);
await expect(n8n.ndv.getExecuteNodeButton()).toBeVisible();
});
test('should allow editing code in fullscreen in the code editors', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Code', { action: 'Code in JavaScript', closeNDV: false });
await n8n.ndv.openCodeEditorFullscreen();
const fullscreenEditor = n8n.ndv.getCodeEditorFullscreen();
await fullscreenEditor.click();
await n8n.page.keyboard.press('ControlOrMeta+a');
await fullscreenEditor.fill('foo()');
await expect(fullscreenEditor).toContainText('foo()');
await n8n.ndv.closeCodeEditorDialog();
await expect(n8n.ndv.getParameterInput('jsCode').locator('.cm-content')).toContainText('foo()');
});
test.describe('Complex Edge Cases', () => {
test('ADO-2931 - should handle multiple branches of the same input with the first branch empty correctly', async ({
n8n,
}) => {
await n8n.canvas.importWorkflow(
'Test_ndv_two_branches_of_same_parent_false_populated.json',
'Multiple Branches Test',
);
await n8n.canvas.openNode('DebugHelper');
await expect(n8n.ndv.inputPanel.get()).toBeVisible();
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
await n8n.ndv.execute();
await expect(n8n.ndv.inputPanel.getSchemaItem('a1')).toBeVisible();
});
});
test.describe('Execution Indicators - Multi-Node', () => {
test('should properly show node execution indicator for multiple nodes', async ({ n8n }) => {
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
await n8n.ndv.clickBackToCanvasButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeVisible();
await expect(n8n.ndv.getNodeRunTooltipIndicator()).toBeVisible();
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.openNode(CODE_NODE_DISPLAY_NAME);
await expect(n8n.ndv.getNodeRunSuccessIndicator()).toBeVisible();
});
});
});
@@ -0,0 +1,260 @@
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
test.describe('NDV Data Display', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test.describe('Schema View', () => {
const schemaKeys = [
'id',
'name',
'email',
'notes',
'country',
'created',
'objectValue',
'prop1',
'prop2',
];
const setupSchemaWorkflow = async (n8n: n8nPage) => {
await n8n.start.fromImportedWorkflow('Test_workflow_schema_test.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.openNode('Set');
await n8n.ndv.execute();
};
test('should switch to output schema view and validate it', async ({ n8n }) => {
await setupSchemaWorkflow(n8n);
await n8n.ndv.outputPanel.switchDisplayMode('schema');
for (const key of schemaKeys) {
await expect(n8n.ndv.outputPanel.getSchemaItem(key)).toBeVisible();
}
});
test('should preserve schema view after execution', async ({ n8n }) => {
await setupSchemaWorkflow(n8n);
await n8n.ndv.outputPanel.switchDisplayMode('schema');
await n8n.ndv.execute();
for (const key of schemaKeys) {
await expect(n8n.ndv.outputPanel.getSchemaItem(key)).toBeVisible();
}
});
test('should collapse and expand nested schema object', async ({ n8n }) => {
await setupSchemaWorkflow(n8n);
const expandedObjectProps = ['prop1', 'prop2'];
await n8n.ndv.outputPanel.switchDisplayMode('schema');
for (const key of expandedObjectProps) {
await expect(n8n.ndv.outputPanel.getSchemaItem(key)).toBeVisible();
}
const objectValueItem = n8n.ndv.outputPanel.getSchemaItem('objectValue');
await objectValueItem.locator('.toggle').click();
for (const key of expandedObjectProps) {
await expect(n8n.ndv.outputPanel.getSchemaItem(key)).not.toBeInViewport();
}
});
test('should not display pagination for schema', async ({ n8n }) => {
await setupSchemaWorkflow(n8n);
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.deselectAll();
await n8n.canvas.nodeByName('Set').click();
await n8n.canvas.addNode('Customer Datastore (n8n training)');
await n8n.canvas.openNode('Customer Datastore (n8n training)');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.get().getByText('5 items')).toBeVisible();
await n8n.ndv.outputPanel.switchDisplayMode('schema');
const schemaItemsCount = await n8n.ndv.outputPanel.getSchemaItems().count();
expect(schemaItemsCount).toBeGreaterThan(0);
await n8n.ndv.outputPanel.switchDisplayMode('json');
});
test('should display large schema', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_schema_test_pinned_data.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.openNode('Set');
// 26 items with page size 25 = 2 pages, so pagination is visible
await expect(n8n.ndv.outputPanel.get().getByText('26 items')).toBeVisible();
await expect(n8n.ndv.getOutputPagination()).toBeVisible();
await n8n.ndv.outputPanel.switchDisplayMode('schema');
await expect(n8n.ndv.getOutputPagination()).toBeHidden();
});
});
test.describe('Search and Rendering', () => {
test('should keep search expanded after Execute step node run', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_ndv_search.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Edit Fields');
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
await n8n.ndv.searchOutputData('US');
await expect(n8n.ndv.outputPanel.getTableRow(1).locator('mark')).toContainText('US');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.getSearchInput()).toBeVisible();
await expect(n8n.ndv.outputPanel.getSearchInput()).toHaveValue('US');
});
test('Should render xml and html tags as strings and can search', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_xml_output.json');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Edit Fields');
await expect(n8n.ndv.outputPanel.get().locator('[class*="active"]')).toContainText('Table');
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText(
'<?xml version="1.0" encoding="UTF-8"?> <library>',
);
await n8n.page.keyboard.press('/');
const searchInput = n8n.ndv.outputPanel.getSearchInput();
await expect(searchInput).toBeFocused();
await searchInput.fill('<lib');
await expect(n8n.ndv.outputPanel.getTableRow(1).locator('mark')).toContainText('<lib');
await n8n.ndv.outputPanel.switchDisplayMode('json');
await expect(n8n.ndv.outputPanel.getDataContainer().locator('.json-data')).toBeVisible();
});
});
test.describe('Run Data & Selectors', () => {
test('can link and unlink run selectors between input and output', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_5.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Set3');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.outputPanel.switchDisplayMode('table');
await n8n.ndv.ensureOutputRunLinking(true);
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click();
expect(await n8n.ndv.getInputRunSelectorValue()).toContain('2 of 2 (6 items)');
expect(await n8n.ndv.getOutputRunSelectorValue()).toContain('2 of 2 (6 items)');
await n8n.ndv.changeOutputRunSelector('1 of 2 (6 items)');
expect(await n8n.ndv.getInputRunSelectorValue()).toContain('1 of 2 (6 items)');
await expect(n8n.ndv.inputPanel.getTbodyCell(0, 0)).toHaveText('1111');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toHaveText('1111');
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click();
await n8n.ndv.changeInputRunSelector('2 of 2 (6 items)');
expect(await n8n.ndv.getOutputRunSelectorValue()).toContain('2 of 2 (6 items)');
await n8n.ndv.outputPanel.getLinkRun().click();
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click();
await n8n.ndv.changeOutputRunSelector('1 of 2 (6 items)');
expect(await n8n.ndv.getInputRunSelectorValue()).toContain('2 of 2 (6 items)');
await n8n.ndv.outputPanel.getLinkRun().click();
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click();
expect(await n8n.ndv.getInputRunSelectorValue()).toContain('1 of 2 (6 items)');
await n8n.ndv.inputPanel.toggleInputRunLinking();
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click();
await n8n.ndv.changeInputRunSelector('2 of 2 (6 items)');
expect(await n8n.ndv.getOutputRunSelectorValue()).toContain('1 of 2 (6 items)');
await n8n.ndv.inputPanel.toggleInputRunLinking();
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click();
expect(await n8n.ndv.getOutputRunSelectorValue()).toContain('2 of 2 (6 items)');
});
});
test.describe('Schema & Data Views', () => {
test('should show data from the correct output in schema view', async ({ n8n }) => {
await n8n.canvas.importWorkflow('Test_workflow_multiple_outputs.json', 'Multiple outputs');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Only Item 1');
await expect(n8n.ndv.inputPanel.get()).toBeVisible();
await n8n.ndv.inputPanel.switchDisplayMode('schema');
await expect(n8n.ndv.inputPanel.getSchemaItem('onlyOnItem1')).toBeVisible();
await n8n.ndv.close();
await n8n.canvas.openNode('Only Item 2');
await expect(n8n.ndv.inputPanel.get()).toBeVisible();
await n8n.ndv.inputPanel.switchDisplayMode('schema');
await expect(n8n.ndv.inputPanel.getSchemaItem('onlyOnItem2')).toBeVisible();
await n8n.ndv.close();
await n8n.canvas.openNode('Only Item 3');
await expect(n8n.ndv.inputPanel.get()).toBeVisible();
await n8n.ndv.inputPanel.switchDisplayMode('schema');
await expect(n8n.ndv.inputPanel.getSchemaItem('onlyOnItem3')).toBeVisible();
await n8n.ndv.close();
});
});
test.describe('Search Functionality - Advanced', () => {
test('should not show items count when searching in schema view', async ({ n8n }) => {
await n8n.canvas.importWorkflow('Test_ndv_search.json', 'NDV Search Test');
await n8n.canvas.openNode('Edit Fields');
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
await n8n.ndv.execute();
await n8n.ndv.outputPanel.switchDisplayMode('schema');
await n8n.ndv.searchOutputData('US');
await expect(n8n.ndv.outputPanel.getItemsCount()).toBeHidden();
});
test('should show additional tooltip when searching in schema view if no matches', async ({
n8n,
}) => {
await n8n.canvas.importWorkflow('Test_ndv_search.json', 'NDV Search Test');
await n8n.canvas.openNode('Edit Fields');
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
await n8n.ndv.execute();
await n8n.ndv.outputPanel.switchDisplayMode('schema');
await n8n.ndv.searchOutputData('foo');
await expect(
n8n.ndv.outputPanel
.get()
.getByText('To search field values, switch to table or JSON view.'),
).toBeVisible();
});
});
});
@@ -0,0 +1,117 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('NDV Floating Nodes Navigation', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should traverse floating nodes with mouse', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Floating_Nodes.json');
await n8n.canvas.getCanvasNodes().first().dblclick();
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeHidden();
await expect(n8n.ndv.getFloatingNodeByPosition('outputMain')).toBeVisible();
for (let i = 0; i < 4; i++) {
await n8n.ndv.clickFloatingNodeByPosition('outputMain');
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeVisible();
await expect(n8n.ndv.getFloatingNodeByPosition('outputMain')).toBeVisible();
await n8n.ndv.close();
await expect(n8n.canvas.getSelectedNodes()).toHaveCount(1);
await n8n.canvas.getSelectedNodes().first().dblclick();
await expect(n8n.ndv.getContainer()).toBeVisible();
}
await n8n.ndv.clickFloatingNodeByPosition('outputMain');
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeVisible();
for (let i = 0; i < 4; i++) {
await n8n.ndv.clickFloatingNodeByPosition('inputMain');
await expect(n8n.ndv.getFloatingNodeByPosition('outputMain')).toBeVisible();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeVisible();
}
await n8n.ndv.clickFloatingNodeByPosition('inputMain');
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeHidden();
await expect(n8n.ndv.getFloatingNodeByPosition('inputSub')).toBeHidden();
await expect(n8n.ndv.getFloatingNodeByPosition('outputSub')).toBeHidden();
await n8n.ndv.close();
await expect(n8n.canvas.getSelectedNodes()).toHaveCount(1);
});
test('should traverse floating nodes with keyboard', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Floating_Nodes.json');
await n8n.canvas.getCanvasNodes().first().dblclick();
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeHidden();
await expect(n8n.ndv.getFloatingNodeByPosition('outputMain')).toBeVisible();
for (let i = 0; i < 4; i++) {
await n8n.ndv.navigateToNextFloatingNodeWithKeyboard();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeVisible();
await expect(n8n.ndv.getFloatingNodeByPosition('outputMain')).toBeVisible();
await n8n.ndv.close();
await expect(n8n.canvas.getSelectedNodes()).toHaveCount(1);
await n8n.canvas.getSelectedNodes().first().dblclick();
await expect(n8n.ndv.getContainer()).toBeVisible();
}
await n8n.ndv.navigateToNextFloatingNodeWithKeyboard();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeVisible();
for (let i = 0; i < 4; i++) {
await n8n.ndv.navigateToPreviousFloatingNodeWithKeyboard();
await expect(n8n.ndv.getFloatingNodeByPosition('outputMain')).toBeVisible();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeVisible();
}
await n8n.ndv.navigateToPreviousFloatingNodeWithKeyboard();
await expect(n8n.ndv.getFloatingNodeByPosition('inputMain')).toBeHidden();
await expect(n8n.ndv.getFloatingNodeByPosition('inputSub')).toBeHidden();
await expect(n8n.ndv.getFloatingNodeByPosition('outputSub')).toBeHidden();
await n8n.ndv.close();
await expect(n8n.canvas.getSelectedNodes()).toHaveCount(1);
});
test('should connect floating sub-nodes', async ({ n8n }) => {
await n8n.canvas.addNode('AI Agent', { closeNDV: false });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.connectAISubNode('ai_languageModel', 'Anthropic Chat Model');
await n8n.ndv.connectAISubNode('ai_memory', 'Redis Chat Memory');
await n8n.ndv.connectAISubNode('ai_tool', 'HTTP Request Tool');
await expect(n8n.ndv.getNodesWithIssues()).toHaveCount(3);
});
test('should have the floating nodes in correct order', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Floating_Nodes.json');
await n8n.canvas.openNode('Merge');
await expect(n8n.ndv.getContainer()).toBeVisible();
expect(await n8n.ndv.getFloatingNodeCount('inputMain')).toBe(2);
await n8n.ndv.verifyFloatingNodeName('inputMain', 'Edit Fields1', 0);
await n8n.ndv.verifyFloatingNodeName('inputMain', 'Edit Fields0', 1);
await n8n.ndv.close();
await n8n.canvas.openNode('Merge1');
await expect(n8n.ndv.getContainer()).toBeVisible();
expect(await n8n.ndv.getFloatingNodeCount('inputMain')).toBe(2);
await n8n.ndv.verifyFloatingNodeName('inputMain', 'Edit Fields0', 0);
await n8n.ndv.verifyFloatingNodeName('inputMain', 'Edit Fields1', 1);
});
});
@@ -0,0 +1,197 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('NDV Parameters', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test.describe('Parameter Hints', () => {
test('should display parameter hints correctly', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_3.json');
await n8n.canvas.openNode('Set1');
await n8n.ndv.getParameterInputField('value').clear();
await n8n.ndv.getParameterInputField('value').fill('=');
await n8n.ndv.getInlineExpressionEditorContent().fill('hello');
await n8n.ndv.getParameterInputField('name').click();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('hello');
await n8n.ndv.getInlineExpressionEditorContent().fill('');
await n8n.ndv.getParameterInputField('name').click();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('[empty]');
await n8n.ndv.getInlineExpressionEditorContent().fill(' test');
await n8n.ndv.getParameterInputField('name').click();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText(' test');
await n8n.ndv.getInlineExpressionEditorContent().fill(' ');
await n8n.ndv.getParameterInputField('name').click();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText(' ');
await n8n.ndv.getInlineExpressionEditorContent().fill('<div></div>');
await n8n.ndv.getParameterInputField('name').click();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('<div></div>');
});
});
test.describe('Remote Options & Network', () => {
test('should not retrieve remote options when a parameter value changes', async ({ n8n }) => {
let fetchParameterOptionsCallCount = 0;
await n8n.page.route('**/rest/dynamic-node-parameters/options', async (route) => {
fetchParameterOptionsCallCount++;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [] }),
});
});
await n8n.canvas.addNode('E2E Test', { action: 'Remote Options' });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.fillFirstAvailableTextParameterMultipleTimes(['test1', 'test2', 'test3']);
expect(fetchParameterOptionsCallCount).toBe(1);
});
test('Should show a notice when remote options cannot be fetched because of missing credentials', async ({
n8n,
}) => {
await n8n.page.route('**/rest/dynamic-node-parameters/options', async (route) => {
await route.fulfill({ status: 403 });
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Update a database page', closeNDV: false });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.addItemToFixedCollection('propertiesUi');
await expect(
n8n.ndv.getParameterInputWithIssues('propertiesUi.propertyValues[0].key'),
).toBeVisible();
});
test('Should show error state when remote options cannot be fetched', async ({ n8n }) => {
await n8n.page.route('**/rest/dynamic-node-parameters/options', async (route) => {
await route.fulfill({ status: 500 });
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Update a database page', closeNDV: false });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.credentialsComposer.createFromNdv({
apiKey: 'sk_test_123',
});
await n8n.ndv.addItemToFixedCollection('propertiesUi');
await expect(
n8n.ndv.getParameterInputWithIssues('propertiesUi.propertyValues[0].key'),
).toBeVisible();
});
});
test.describe('Parameter Management - Advanced', () => {
test('Should clear mismatched collection parameters', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Create a database page', closeNDV: false });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.addItemToFixedCollection('propertiesUi');
await n8n.ndv.changeNodeOperation('Update');
await expect(n8n.ndv.getParameterItemWithText('Currently no items exist')).toBeVisible();
});
test('Should keep RLC values after operation change', async ({ n8n }) => {
const TEST_DOC_ID = '1111';
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Google Sheets', { closeNDV: false, action: 'Append row in sheet' });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.ndv.setRLCValue('documentId', TEST_DOC_ID);
await n8n.ndv.changeNodeOperation('Append or Update Row');
const input = n8n.ndv.getResourceLocatorInput('documentId').locator('input');
await expect(input).toHaveValue(TEST_DOC_ID);
});
test('Should not clear resource/operation after credential change', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Discord', { closeNDV: false, action: 'Delete a message' });
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.credentialsComposer.createFromNdv({
botToken: 'sk_test_123',
});
const resourceInput = n8n.ndv.getParameterInputField('resource');
const operationInput = n8n.ndv.getParameterInputField('operation');
await expect(resourceInput).toHaveValue('Message');
await expect(operationInput).toHaveValue('Delete');
});
});
test.describe('Node Creator Integration', () => {
test('Should open appropriate node creator after clicking on connection hint link', async ({
n8n,
}) => {
const hintMapper = {
Memory: 'AI Nodes',
'Output Parser': 'AI Nodes',
'Token Splitter': 'Document Loaders',
Tool: 'AI Nodes',
Embeddings: 'Vector Stores',
'Vector Store': 'Retrievers',
};
await n8n.canvas.importWorkflow(
'open_node_creator_for_connection.json',
'open_node_creator_for_connection',
);
for (const [node, group] of Object.entries(hintMapper)) {
await n8n.canvas.openNode(node);
await n8n.ndv.clickNodeCreatorInsertOneButton();
await expect(n8n.canvas.getNodeCreatorHeader(group)).toBeVisible();
await n8n.page.keyboard.press('Escape');
}
});
});
test.describe('Expression Editor Features', () => {
test('should allow selecting item for expressions', async ({ n8n }) => {
await n8n.canvas.importWorkflow('Test_workflow_3.json', 'My test workflow 2');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Set');
await n8n.ndv.getAssignmentValue('assignments').getByText('Expression').click();
const expressionInput = n8n.ndv.getInlineExpressionEditorInput();
await expressionInput.click();
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ $json.input[0].count');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('0');
await n8n.ndv.expressionSelectNextItem();
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('1');
await expect(n8n.ndv.getInlineExpressionEditorItemInput()).toHaveValue('1');
await expect(n8n.ndv.getInlineExpressionEditorItemNextButton()).toBeDisabled();
await n8n.ndv.expressionSelectPrevItem();
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('0');
await expect(n8n.ndv.getInlineExpressionEditorItemInput()).toHaveValue('0');
});
});
});
@@ -0,0 +1,329 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('NDV Paired Items', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('maps paired input and output items', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_5.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Sort');
await expect(n8n.ndv.inputPanel.get()).toContainText('6 items');
await expect(n8n.ndv.outputPanel.get()).toContainText('6 items');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.outputPanel.switchDisplayMode('table');
// input to output
const inputTableRow1 = n8n.ndv.inputPanel.getTableRow(1);
await expect(inputTableRow1).toBeVisible();
await expect(inputTableRow1).toHaveAttribute('data-test-id', 'hovering-item');
// Move the cursor to simulate hover behavior
await inputTableRow1.hover();
await expect(n8n.ndv.outputPanel.getTableRow(4)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.inputPanel.getTableRow(2).hover();
await expect(n8n.ndv.outputPanel.getTableRow(2)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.inputPanel.getTableRow(3).hover();
await expect(n8n.ndv.outputPanel.getTableRow(6)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
// output to input
await n8n.ndv.outputPanel.getTableRow(1).hover();
await expect(n8n.ndv.inputPanel.getTableRow(4)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.outputPanel.getTableRow(4).hover();
await expect(n8n.ndv.inputPanel.getTableRow(1)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.outputPanel.getTableRow(2).hover();
await expect(n8n.ndv.inputPanel.getTableRow(2)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.outputPanel.getTableRow(6).hover();
await expect(n8n.ndv.inputPanel.getTableRow(3)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.outputPanel.getTableRow(1).hover();
await expect(n8n.ndv.inputPanel.getTableRow(4)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
});
test('maps paired input and output items based on selected input node', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_5.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Set2');
await expect(n8n.ndv.inputPanel.get()).toContainText('6 items');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue('2 of 2 (6 items)');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.outputPanel.switchDisplayMode('table');
// Default hover state should have first item from input node highlighted
const hoveringItem = n8n.page.locator('[data-test-id="hovering-item"]');
await expect(hoveringItem).toContainText('1111');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('1111');
// Select different input node and check that the hover state is updated
await n8n.ndv.inputPanel.getNodeInputOptions().click();
await n8n.page.getByRole('option', { name: 'Set1' }).click();
await expect(hoveringItem).toContainText('1000');
// Hover on input item and verify output hover state
await n8n.ndv.inputPanel.getTable().locator('text=1000').hover();
await expect(n8n.ndv.outputPanel.get().locator('[data-test-id="hovering-item"]')).toContainText(
'1000',
);
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('1000');
// Switch back to Sort input
await n8n.ndv.inputPanel.getNodeInputOptions().click();
await n8n.page.getByRole('option', { name: 'Sort' }).click();
await n8n.ndv.changeOutputRunSelector('1 of 2 (6 items)');
await expect(hoveringItem).toContainText('1111');
await n8n.ndv.inputPanel.getTable().locator('text=1111').hover();
await expect(n8n.ndv.outputPanel.get().locator('[data-test-id="hovering-item"]')).toContainText(
'1111',
);
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toContainText('1111');
});
test('maps paired input and output items based on selected run', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_5.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Set3');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.outputPanel.switchDisplayMode('table');
// Start from linked state
await n8n.ndv.ensureOutputRunLinking(true);
await n8n.ndv.inputPanel.getTbodyCell(0, 0).click(); // remove tooltip
await expect(n8n.ndv.inputPanel.getRunSelectorInput()).toHaveValue('2 of 2 (6 items)');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue('2 of 2 (6 items)');
await n8n.ndv.changeOutputRunSelector('1 of 2 (6 items)');
await expect(n8n.ndv.inputPanel.getRunSelectorInput()).toHaveValue('1 of 2 (6 items)');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue('1 of 2 (6 items)');
await expect(n8n.ndv.inputPanel.getTableRow(1)).toContainText('1111');
await expect(n8n.ndv.inputPanel.getTableRow(1)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText('1111');
await n8n.ndv.outputPanel.getTableRow(1).hover();
await expect(n8n.ndv.outputPanel.getTableRow(3)).toContainText('4444');
await n8n.ndv.outputPanel.getTableRow(3).hover();
await expect(n8n.ndv.inputPanel.getTableRow(3)).toContainText('4444');
await expect(n8n.ndv.inputPanel.getTableRow(3)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.changeOutputRunSelector('2 of 2 (6 items)');
await expect(n8n.ndv.inputPanel.getTableRow(1)).toContainText('1000');
await n8n.ndv.inputPanel.getTableRow(1).hover();
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText('1000');
await expect(n8n.ndv.outputPanel.getTableRow(1)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await expect(n8n.ndv.outputPanel.getTableRow(3)).toContainText('2000');
await n8n.ndv.outputPanel.getTableRow(3).hover();
await expect(n8n.ndv.inputPanel.getTableRow(3)).toContainText('2000');
await expect(n8n.ndv.inputPanel.getTableRow(3)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
});
test('can pair items between input and output across branches and runs', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_5.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('IF');
await n8n.ndv.inputPanel.switchDisplayMode('table');
await n8n.ndv.outputPanel.switchDisplayMode('table');
// Switch to False Branch
await n8n.ndv.outputPanel.get().getByText('False Branch (2 items)').click();
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText('8888');
await n8n.ndv.outputPanel.getTableRow(1).hover();
await expect(n8n.ndv.inputPanel.getTableRow(5)).toContainText('8888');
await expect(n8n.ndv.inputPanel.getTableRow(5)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await expect(n8n.ndv.outputPanel.getTableRow(2)).toContainText('9999');
await n8n.ndv.outputPanel.getTableRow(2).hover();
await expect(n8n.ndv.inputPanel.getTableRow(6)).toContainText('9999');
await expect(n8n.ndv.inputPanel.getTableRow(6)).toHaveAttribute(
'data-test-id',
'hovering-item',
);
await n8n.ndv.close();
await n8n.canvas.openNode('Set5');
// Switch to True Branch for input
await n8n.ndv.inputPanel.get().getByText('True Branch').click();
await n8n.ndv.changeOutputRunSelector('(2 items)');
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText('8888');
await n8n.ndv.outputPanel.getTableRow(1).hover();
// Should not have matching hover state when branches don't match
const hoveringItems = n8n.ndv.inputPanel.get().locator('[data-test-id="hovering-item"]');
await expect(hoveringItems).toHaveCount(0);
await expect(n8n.ndv.inputPanel.getTableRow(1)).toContainText('1111');
await n8n.ndv.inputPanel.getTableRow(1).hover();
const outputHoveringItems = n8n.ndv.outputPanel.get().locator('[data-test-id="hovering-item"]');
await expect(outputHoveringItems).toHaveCount(0);
// Switch to False Branch
await n8n.ndv.inputPanel.get().getByText('False Branch').click();
await expect(n8n.ndv.inputPanel.getTableRow(1)).toContainText('8888');
await n8n.ndv.inputPanel.getTableRow(1).hover();
await n8n.ndv.changeOutputRunSelector('(4 items)');
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText('1111');
await n8n.ndv.outputPanel.getTableRow(1).hover();
await n8n.ndv.changeOutputRunSelector('(2 items)');
await expect(n8n.ndv.inputPanel.getTableRow(1)).toContainText('8888');
await n8n.ndv.inputPanel.getTableRow(1).hover();
await expect(n8n.ndv.outputPanel.get().locator('[data-test-id="hovering-item"]')).toContainText(
'8888',
);
});
test('should auto-fix pairedItem when multiple inputs create single output', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_ndv_paired_item_single_output.json');
await n8n.canvas.openNode('Use paired item');
await n8n.ndv.execute();
await expect(n8n.ndv.getNodeRunErrorMessage()).toBeHidden();
await expect(n8n.ndv.outputPanel.get()).toContainText('Jay Gatsby');
});
test('can resolve expression with paired item in multi-input node', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('expression_with_paired_item_in_multi_input_node.json');
await n8n.canvas.clickZoomToFitButton();
const PINNED_DATA = [
{
id: 'abc',
historyId: 'def',
messages: [
{
id: 'abc',
},
],
},
{
id: 'abc',
historyId: 'def',
messages: [
{
id: 'abc',
},
{
id: 'abc',
},
{
id: 'abc',
},
],
},
{
id: 'abc',
historyId: 'def',
messages: [
{
id: 'abc',
},
],
},
];
await n8n.canvas.openNode('Get thread details1');
await n8n.ndv.setPinnedData(PINNED_DATA);
await n8n.ndv.close();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Switch1');
await n8n.ndv.execute();
await expect(n8n.ndv.getParameterExpressionPreviewOutput()).toContainText('1');
await n8n.ndv.getInlineExpressionEditorInput().click();
await expect(n8n.ndv.getInlineExpressionEditorPreview()).toContainText('1');
// Select next item
await n8n.ndv.expressionSelectNextItem();
await expect(n8n.ndv.getInlineExpressionEditorPreview()).toContainText('3');
// Select next item again
await n8n.ndv.expressionSelectNextItem();
await expect(n8n.ndv.getInlineExpressionEditorPreview()).toContainText('1');
// Next button should be disabled
await expect(n8n.ndv.getInlineExpressionEditorItemNextButton()).toBeDisabled();
});
});
@@ -0,0 +1,236 @@
import { test, expect } from '../../../../../fixtures/base';
import type { TestRequirements } from '../../../../../Types';
const NODES = {
MANUAL_TRIGGER: 'Manual Trigger',
SCHEDULE_TRIGGER: 'Schedule Trigger',
WEBHOOK: 'Webhook',
HTTP_REQUEST: 'HTTP Request',
PIPEDRIVE: 'Pipedrive',
EDIT_FIELDS: 'Edit Fields (Set)', // Use the full node name that appears in the Node List, although when it's added to the canvas it's called "Edit Fields"
CODE: 'Code',
END: 'End',
};
const webhookTestRequirements: TestRequirements = {
workflow: {
'Test_workflow_webhook_with_pin_data.json': 'Test',
},
};
const pinnedWebhookRequirements: TestRequirements = {
workflow: {
'Pinned_webhook_node.json': 'Test',
},
};
test.describe(
'Data pinning',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
const maxPinnedDataSize = 16384;
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test.describe('Pin data operations', () => {
test('should be able to pin node output', async ({ n8n }) => {
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
const prevValue = await n8n.ndv.outputPanel.getTbodyCell(0, 0).textContent();
await n8n.ndv.togglePinData();
await n8n.ndv.close();
// Execute workflow and verify pinned data persists
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.canvas.openNode(NODES.SCHEDULE_TRIGGER);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toHaveText(prevValue ?? '');
});
test('should be able to set custom pinned data', async ({ n8n }) => {
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible();
await expect(n8n.ndv.outputPanel.getPinDataButton()).toBeHidden();
await n8n.ndv.setPinnedData([{ test: 1 }]);
await expect(n8n.ndv.outputPanel.getTableRows()).toHaveCount(2);
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(2);
await expect(n8n.ndv.outputPanel.getTableHeaders().first()).toContainText('test');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('1');
await n8n.ndv.close();
await n8n.canvas.openNode(NODES.SCHEDULE_TRIGGER);
await expect(n8n.ndv.outputPanel.getTableHeaders().first()).toContainText('test');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('1');
});
test('should display pin data edit button for Webhook node', async ({ n8n }) => {
await n8n.canvas.addNode(NODES.WEBHOOK);
const runDataHeader = n8n.ndv.getRunDataPaneHeader();
const editButton = runDataHeader.getByRole('button', { name: 'Edit Output' });
await expect(editButton).toBeVisible();
});
test('should duplicate pinned data when duplicating node', async ({ n8n }) => {
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.close();
await n8n.canvas.addNode(NODES.EDIT_FIELDS);
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible();
await expect(n8n.ndv.outputPanel.getPinDataButton()).toBeHidden();
await n8n.ndv.setPinnedData([{ test: 1 }]);
await n8n.ndv.close();
await n8n.canvas.duplicateNode('Edit Fields');
await n8n.canvas.openNode('Edit Fields1');
await expect(n8n.ndv.outputPanel.getTableHeader(0)).toContainText('test');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('1');
});
});
test.describe('Error handling', () => {
test('should show error when maximum pin data size is exceeded', async ({ n8n }) => {
await n8n.page.evaluate((maxSize) => {
(window as { maxPinnedDataSize?: number }).maxPinnedDataSize = maxSize;
}, maxPinnedDataSize);
const actualMaxSize = await n8n.page.evaluate(() => {
return (window as { maxPinnedDataSize?: number }).maxPinnedDataSize;
});
expect(actualMaxSize).toBe(maxPinnedDataSize);
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.close();
await n8n.canvas.addNode(NODES.EDIT_FIELDS);
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible();
await expect(n8n.ndv.outputPanel.getPinDataButton()).toBeHidden();
const largeData = [{ test: '1'.repeat(maxPinnedDataSize + 1000) }];
await n8n.ndv.setPinnedData(largeData);
await expect(
n8n.notifications.getNotificationByContent(
'Workflow has reached the maximum allowed pinned data size',
),
).toBeVisible();
});
test('should show error when pin data JSON is invalid', async ({ n8n }) => {
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
await n8n.ndv.close();
await n8n.canvas.addNode(NODES.EDIT_FIELDS);
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible();
await expect(n8n.ndv.outputPanel.getPinDataButton()).toBeHidden();
await n8n.ndv.setPinnedData('[ { "name": "First item", "code": 2dsa }]');
await expect(
n8n.notifications.getNotificationByTitle('Unable to save due to invalid JSON'),
).toBeVisible();
});
});
test.describe('Advanced pinning scenarios', () => {
test('should be able to reference paired items in node before pinned data', async ({
n8n,
}) => {
await n8n.canvas.addNode(NODES.MANUAL_TRIGGER);
await n8n.canvas.addNode(NODES.HTTP_REQUEST);
await n8n.ndv.setPinnedData([{ http: 123 }]);
await n8n.ndv.close();
await n8n.canvas.addNode(NODES.PIPEDRIVE, { action: 'Create an activity' });
await n8n.ndv.setPinnedData(Array(3).fill({ pipedrive: 123 }));
await n8n.ndv.close();
await n8n.canvas.addNode(NODES.EDIT_FIELDS);
await n8n.ndv.execute();
await expect(n8n.ndv.getNodeParameters()).toBeVisible();
await expect(n8n.ndv.getAssignmentCollectionAdd('assignments')).toBeVisible();
await n8n.ndv.getAssignmentCollectionAdd('assignments').click();
await n8n.ndv.getAssignmentValue('assignments').getByText('Expression').click();
const expressionInput = n8n.ndv.getInlineExpressionEditorInput();
await expressionInput.click();
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(`{{ $('${NODES.HTTP_REQUEST}').item`);
await n8n.page.keyboard.press('Escape');
const expectedOutput = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]';
await expect(n8n.ndv.getParameterInputHint().getByText(expectedOutput)).toBeVisible();
});
test('should use pin data in manual webhook executions', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(webhookTestRequirements);
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText(
'Waiting for trigger event from Webhook',
);
const webhookPath = '/webhook-test/b0d79ddb-df2d-49b1-8555-9fa2b482608f';
const response = await n8n.ndv.makeWebhookRequest(webhookPath);
expect(response.status()).toBe(200);
await n8n.canvas.openNode(NODES.END);
await expect(n8n.ndv.outputPanel.getTableRow(1)).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableRow(1)).toContainText('pin-overwritten');
});
// Flaky in multi-main mode due to webhook registration timing issues
test.fixme(
'should not use pin data in production webhook executions',
async ({ n8n, setupRequirements }) => {
await setupRequirements(webhookTestRequirements);
await n8n.canvas.publishWorkflow();
const webhookUrl = '/webhook/b0d79ddb-df2d-49b1-8555-9fa2b482608f';
const response = await n8n.ndv.makeWebhookRequest(webhookUrl);
expect(response.status(), 'Webhook response is: ' + (await response.text())).toBe(200);
const responseBody = await response.json();
expect(responseBody).toEqual({ nodeData: 'pin' });
},
);
test('should not show pinned data tooltip', async ({ n8n, setupRequirements }) => {
await setupRequirements(pinnedWebhookRequirements);
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.canvas.getCanvasNodes().first().click();
const poppers = n8n.ndv.getVisiblePoppers();
await expect(poppers).toHaveCount(0);
});
});
},
);
@@ -0,0 +1,149 @@
import { E2E_TEST_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
const NO_CREDENTIALS_MESSAGE = 'Add your credential';
const INVALID_CREDENTIALS_MESSAGE = 'Check your credential';
const MODE_SELECTOR_LIST = 'From list';
test.describe(
'Resource Locator',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should render both RLC components in google sheets', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Google Sheets', { closeNDV: false, action: 'Update row in sheet' });
await expect(n8n.ndv.getResourceLocator('documentId')).toBeVisible();
await expect(n8n.ndv.getResourceLocator('sheetName')).toBeVisible();
await expect(n8n.ndv.getResourceLocatorModeSelectorInput('documentId')).toHaveValue(
MODE_SELECTOR_LIST,
);
await expect(n8n.ndv.getResourceLocatorModeSelectorInput('sheetName')).toHaveValue(
MODE_SELECTOR_LIST,
);
});
test('should show appropriate error when credentials are not set', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Google Sheets', { closeNDV: false, action: 'Update row in sheet' });
await expect(n8n.ndv.getResourceLocator('documentId')).toBeVisible();
await n8n.ndv.getResourceLocatorInput('documentId').click();
await expect(n8n.ndv.getResourceLocatorErrorMessage('documentId')).toContainText(
NO_CREDENTIALS_MESSAGE,
);
});
test('should show create credentials modal when clicking "add your credential"', async ({
n8n,
}) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Google Sheets', { closeNDV: false, action: 'Update row in sheet' });
await expect(n8n.ndv.getResourceLocator('documentId')).toBeVisible();
await n8n.ndv.getResourceLocatorInput('documentId').click();
await expect(n8n.ndv.getResourceLocatorErrorMessage('documentId')).toContainText(
NO_CREDENTIALS_MESSAGE,
);
await n8n.ndv.getResourceLocatorAddCredentials('documentId').click();
await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
});
test('should show appropriate error when credentials are not valid', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Google Sheets', { closeNDV: false, action: 'Update row in sheet' });
// Add OAuth2 credentials without connecting
await n8n.ndv.getNodeCredentialsSelect().click();
await n8n.ndv.credentialDropdownCreateNewCredential().click();
await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
await n8n.canvas.credentialModal.fillAllFields({
clientId: 'dummy-client-id',
clientSecret: 'dummy-client-secret',
});
// OAuth: Save button is hidden. Click Connect to trigger implicit save.
const credentialSaved = n8n.page.waitForResponse(
(resp) => resp.url().includes('/rest/credentials') && resp.request().method() === 'POST',
);
const popupPromise = n8n.page.context().waitForEvent('page');
await n8n.canvas.credentialModal.oauthConnectButton.click();
await credentialSaved;
const popup = await popupPromise;
await popup.close();
await n8n.canvas.credentialModal.close();
// Close warning modal about not connecting the OAuth credentials
const closeButton = n8n.page.locator('.el-message-box').locator('button:has-text("Close")');
await closeButton.click();
await n8n.ndv.getResourceLocatorInput('documentId').click();
await expect(n8n.ndv.getResourceLocatorErrorMessage('documentId')).toContainText(
INVALID_CREDENTIALS_MESSAGE,
);
});
test('should show appropriate errors when search filter is required', async ({ n8n }) => {
await n8n.canvas.addNode('GitHub', { closeNDV: false, trigger: 'On pull request' });
await expect(n8n.ndv.getResourceLocator('owner')).toBeVisible();
await n8n.ndv.getResourceLocatorInput('owner').click();
await expect(n8n.ndv.getResourceLocatorErrorMessage('owner')).toContainText(
NO_CREDENTIALS_MESSAGE,
);
});
test('should reset resource locator when dependent field is changed', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Google Sheets', { closeNDV: false, action: 'Update row in sheet' });
await n8n.ndv.setRLCValue('documentId', '123');
await n8n.ndv.setRLCValue('sheetName', '123', 1);
await n8n.ndv.setRLCValue('documentId', '321');
await expect(n8n.ndv.getResourceLocatorInput('sheetName').locator('input')).toHaveValue('');
});
// unlike RMC and remote options, RLC does not support loadOptionDependsOn
test('should retrieve list options when other params throw errors', async ({ n8n }) => {
await n8n.canvas.addNode(E2E_TEST_NODE_NAME, { closeNDV: false, action: 'Resource Locator' });
await n8n.ndv.getResourceLocatorInput('rlc').click();
await expect(n8n.page.getByTestId('rlc-item').first()).toBeVisible();
const visiblePopper = n8n.ndv.getVisiblePopper();
await expect(visiblePopper).toHaveCount(1);
await expect(visiblePopper.getByTestId('rlc-item')).toHaveCount(5);
await n8n.ndv.setInvalidExpression({ fieldName: 'fieldId' });
await n8n.ndv.getInputPanel().click(); // remove focus from input, hide expression preview
// wait for the expression to be evaluated and show the error
await expect(n8n.ndv.getParameterInputHint()).toContainText('ERROR');
await n8n.ndv.getResourceLocatorInput('rlc').click();
await expect(n8n.page.getByTestId('rlc-item').first()).toBeVisible();
const visiblePopperAfter = n8n.ndv.getVisiblePopper();
await expect(visiblePopperAfter).toHaveCount(1);
await expect(visiblePopperAfter.getByTestId('rlc-item')).toHaveCount(5);
});
},
);
@@ -0,0 +1,81 @@
import { E2E_TEST_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe('Resource Mapper', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(E2E_TEST_NODE_NAME, { action: 'Resource Mapping Component' });
});
test('should not retrieve list options when required params throw errors', async ({ n8n }) => {
const fieldsContainer = n8n.ndv.getResourceMapperFieldsContainer();
await expect(fieldsContainer).toBeVisible();
await expect(n8n.ndv.getResourceMapperParameterInputs()).toHaveCount(3);
await n8n.ndv.activateParameterExpressionEditor('fieldId');
await n8n.ndv.typeInExpressionEditor("{{ $('unknown')");
await expect(n8n.ndv.getInlineExpressionEditorPreview()).toContainText("node doesn't exist");
await n8n.ndv.refreshResourceMapperColumns();
await expect(n8n.ndv.getResourceMapperFieldsContainer()).toHaveCount(0);
});
test('should retrieve list options when optional params throw errors', async ({ n8n }) => {
await n8n.ndv.activateParameterExpressionEditor('otherField');
await n8n.ndv.typeInExpressionEditor("{{ $('unknown')");
await expect(n8n.ndv.getInlineExpressionEditorPreview()).toContainText("node doesn't exist");
await n8n.ndv.refreshResourceMapperColumns();
await expect(n8n.ndv.getResourceMapperFieldsContainer()).toBeVisible();
await expect(n8n.ndv.getResourceMapperParameterInputs()).toHaveCount(3);
});
test('should correctly delete single field', async ({ n8n }) => {
await n8n.ndv.fillParameterInputByName('id', '001');
await n8n.ndv.fillParameterInputByName('name', 'John');
await n8n.ndv.fillParameterInputByName('age', '30');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'id' })).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'name' })).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'age' })).toBeVisible();
await n8n.ndv.getResourceMapperRemoveFieldButton('name').click();
await n8n.ndv.execute();
await expect(n8n.ndv.getParameterInput('id')).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'id' })).toBeVisible();
await expect(n8n.ndv.getParameterInput('age')).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'age' })).toBeVisible();
await expect(n8n.ndv.getParameterInput('name')).toHaveCount(0);
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'name' })).toHaveCount(0);
});
test('should correctly delete all fields', async ({ n8n }) => {
await n8n.ndv.fillParameterInputByName('id', '001');
await n8n.ndv.fillParameterInputByName('name', 'John');
await n8n.ndv.fillParameterInputByName('age', '30');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'id' })).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'name' })).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'age' })).toBeVisible();
await n8n.ndv.getResourceMapperColumnsOptionsButton().click();
await n8n.ndv.getResourceMapperRemoveAllFieldsOption().click();
await n8n.ndv.execute();
await expect(n8n.ndv.getParameterInput('id')).toBeVisible();
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'id' })).toBeVisible();
await expect(n8n.ndv.getParameterInput('name')).toHaveCount(0);
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'name' })).toHaveCount(0);
await expect(n8n.ndv.getParameterInput('age')).toHaveCount(0);
await expect(n8n.ndv.outputPanel.getTableHeaders().filter({ hasText: 'age' })).toHaveCount(0);
});
});
@@ -0,0 +1,24 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe('Schema Preview', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should show schema preview for regular nodes but not triggers', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode('Gmail', { trigger: 'On message received' });
await n8n.ndv.close();
await n8n.canvas.addNode('Edit Fields (Set)');
await n8n.ndv.inputPanel.get().getByText('No input data').waitFor();
await n8n.ndv.close();
await n8n.canvas.addNode('Hacker News', { action: 'Get an article' });
await n8n.ndv.close();
await n8n.canvas.addNode('Edit Fields (Set)');
await expect(n8n.ndv.inputPanel.getSchemaItem('author')).toBeVisible();
});
});
@@ -0,0 +1,121 @@
import {
EDIT_FIELDS_SET_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../../../../config/constants';
import { test, expect } from '../../../../fixtures/base';
test.describe('Routing', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should ask to save unsaved changes before leaving route', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_1.json');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.sideBar.clickHomeButton();
await expect(n8n.page).toHaveURL(/workflow/);
await expect(n8n.canvas.saveChangesModal.getModal()).toBeVisible();
await n8n.canvas.saveChangesModal.clickCancel();
await expect(n8n.page).toHaveURL(/home\/workflows/);
});
test('should correct route after cancelling saveChangesModal', async ({ n8n }) => {
await n8n.goHome();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test Workflow');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
await n8n.page.goBack();
await expect(n8n.page).toHaveURL(/home\/workflows/);
await expect(n8n.canvas.saveChangesModal.getModal()).toBeVisible();
await n8n.canvas.saveChangesModal.clickClose();
await expect(n8n.page).toHaveURL(/workflow/);
});
test('should correct route when opening and closing NDV', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_1.json');
const baselineUrl = n8n.page.url();
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
expect(n8n.page.url()).not.toBe(baselineUrl);
await n8n.page.keyboard.press('Escape');
expect(n8n.page.url()).toBe(baselineUrl);
});
test('should open ndv via URL', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_1.json');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
const ndvUrl = n8n.page.url();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.ndv.getContainer()).toBeHidden();
await n8n.page.goto(ndvUrl);
await expect(n8n.ndv.getContainer()).toBeVisible();
});
test('should open show warning and drop nodeId from URL if it contained an unknown nodeId', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('Test_workflow_1.json');
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: false });
const ndvUrl = n8n.page.url();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.ndv.getContainer()).toBeHidden();
await n8n.page.goto(ndvUrl + 'thisMessesUpTheNodeId');
await expect(n8n.notifications.getWarningNotifications()).toBeVisible();
const urlWithoutNodeId = ndvUrl.split('/').slice(0, -1).join('/');
expect(n8n.page.url()).toBe(urlWithoutNodeId);
});
test('should load existing workflow when navigating with ?new=true', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
// Create and save a workflow with a node
const workflowName = 'Test Existing Workflow';
await n8n.canvas.setWorkflowName(workflowName);
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
// Get the workflow ID from the URL
const workflowId = n8n.canvas.getWorkflowIdFromUrl();
expect(workflowId).toBeTruthy();
// Navigate to the workflow with ?new=true query parameter
await n8n.page.goto(`/workflow/${workflowId}?new=true`);
// Wait for the canvas to load
await expect(n8n.canvas.getNodeViewLoader()).not.toBeAttached();
// Verify the existing workflow was loaded (not a blank canvas)
await expect(n8n.canvas.getWorkflowName()).toHaveAttribute('title', workflowName);
// Verify the previously added node is present
const scheduleNode = n8n.canvas.nodeByName(SCHEDULE_TRIGGER_NODE_NAME);
await expect(scheduleNode).toBeVisible();
});
});
@@ -0,0 +1,106 @@
import { test, expect } from '../../../../../fixtures/base';
const WORKFLOW_FILE = 'Subworkflow-debugging-execute-workflow.json';
test.describe('Subworkflow debugging', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow(WORKFLOW_FILE);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(11);
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.clickExecuteWorkflowButton();
});
test.describe('can inspect sub executed workflow', () => {
test('(Run once with all items/ Wait for Sub-workflow completion) (default behavior)', async ({
n8n,
}) => {
await n8n.canvas.openNode('Execute Workflow with param');
await expect(n8n.ndv.outputPanel.getItemsCount()).toContainText('2 items, 1 sub-execution');
await expect(n8n.ndv.outputPanel.getRelatedExecutionLink()).toContainText(
'View sub-execution',
);
await expect(n8n.ndv.outputPanel.getRelatedExecutionLink()).toHaveAttribute('href', /.+/);
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(2);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toHaveText('world Natalie Moore');
});
test('(Run once for each item/ Wait for Sub-workflow completion) param1', async ({ n8n }) => {
await n8n.canvas.openNode('Execute Workflow with param1');
await expect(n8n.ndv.outputPanel.getItemsCount()).toContainText('2 items, 2 sub-execution');
await expect(n8n.ndv.outputPanel.getRelatedExecutionLink()).not.toBeAttached();
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(3);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0).locator('a')).toHaveAttribute(
'href',
/.+/,
);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 1)).toHaveText('world Natalie Moore');
});
test('(Run once with all items/ Wait for Sub-workflow completion) param2', async ({ n8n }) => {
await n8n.canvas.openNode('Execute Workflow with param2');
await expect(n8n.ndv.outputPanel.getItemsCount()).not.toBeAttached();
await expect(n8n.ndv.outputPanel.getRelatedExecutionLink()).toContainText(
'View sub-execution',
);
await expect(n8n.ndv.outputPanel.getRelatedExecutionLink()).toHaveAttribute('href', /.+/);
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue(
'2 of 2 (3 items, 1 sub-execution)',
);
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(6);
await expect(n8n.ndv.outputPanel.getTableHeader(0)).toHaveText('uid');
await expect(n8n.ndv.outputPanel.getTableRows()).toHaveCount(4);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 1)).toContainText('Jon_Ebert@yahoo.com');
await n8n.ndv.changeOutputRunSelector('1 of 2 (2 items, 1 sub-execution)');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue(
'1 of 2 (2 items, 1 sub-execution)',
);
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(6);
await expect(n8n.ndv.outputPanel.getTableHeader(0)).toHaveText('uid');
await expect(n8n.ndv.outputPanel.getTableRows()).toHaveCount(3);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 1)).toContainText('Terry.Dach@hotmail.com');
});
test('(Run once for each item/ Wait for Sub-workflow completion) param3', async ({ n8n }) => {
await n8n.canvas.openNode('Execute Workflow with param3');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue(
'2 of 2 (3 items, 3 sub-executions)',
);
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(7);
await expect(n8n.ndv.outputPanel.getTableHeader(1)).toHaveText('uid');
await expect(n8n.ndv.outputPanel.getTableRows()).toHaveCount(4);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0).locator('a')).toHaveAttribute(
'href',
/.+/,
);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 2)).toContainText('Jon_Ebert@yahoo.com');
await n8n.ndv.changeOutputRunSelector('1 of 2 (2 items, 2 sub-executions)');
await expect(n8n.ndv.outputPanel.getRunSelectorInput()).toHaveValue(
'1 of 2 (2 items, 2 sub-executions)',
);
await expect(n8n.ndv.outputPanel.getTableHeaders()).toHaveCount(7);
await expect(n8n.ndv.outputPanel.getTableHeader(1)).toHaveText('uid');
await expect(n8n.ndv.outputPanel.getTableRows()).toHaveCount(3);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0).locator('a')).toHaveAttribute(
'href',
/.+/,
);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 2)).toContainText('Terry.Dach@hotmail.com');
});
});
});
@@ -0,0 +1,128 @@
import { test, expect } from '../../../../../fixtures/base';
const EDIT_FIELDS_NAMES = [
'Edit Fields0',
'Edit Fields1',
'Edit Fields2',
'Edit Fields3',
'Edit Fields4',
'Edit Fields5',
];
test.describe('Subworkflow Extraction', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Subworkflow-extraction-workflow.json');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(7);
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.deselectAll();
});
test.describe('can extract a valid selection and still execute the workflow', () => {
test('should extract a node and succeed execution, and then undo and succeed executions', async ({
n8n,
}) => {
for (const name of EDIT_FIELDS_NAMES) {
await n8n.canvas.rightClickNode(name);
await n8n.canvas.clickContextMenuAction('Convert node to sub-workflow');
await n8n.canvas.convertToSubworkflowModal.waitForModal();
await n8n.canvas.convertToSubworkflowModal.clickSubmitButton();
await n8n.canvas.convertToSubworkflowModal.waitForClose();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
}
for (let i = 0; i < EDIT_FIELDS_NAMES.length; i++) {
await n8n.canvas.hitUndo();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
}
});
test('should extract all nodes besides trigger and succeed execution', async ({ n8n }) => {
await n8n.canvas.nodeByName(EDIT_FIELDS_NAMES[0]).click();
await n8n.canvas.extendSelectionWithArrows('right');
await n8n.canvas.openCanvasContextMenu();
await n8n.canvas.clickContextMenuAction('Convert 6 nodes to sub-workflow');
await n8n.canvas.convertToSubworkflowModal.waitForModal();
await n8n.canvas.convertToSubworkflowModal.clickSubmitButton();
await n8n.canvas.convertToSubworkflowModal.waitForClose();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
});
});
test.describe('disconnected node extraction (ADO-4679)', () => {
test.beforeEach(async ({ n8n }) => {
// Load a workflow with a disconnected node
await n8n.start.fromImportedWorkflow('Subworkflow-extraction-disconnected.json');
// Verify we have the trigger, one connected node, and one disconnected node
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await n8n.canvas.clickZoomToFitButton();
});
test('should extract a disconnected node and connect it properly', async ({ n8n }) => {
// Test for ADO-4679: Extracting a single disconnected node should:
// 1. Create Start trigger connected to the node in sub-workflow
// 2. Allow the Execute Workflow node to be connected to the parent workflow
// Extract the disconnected node "Edit Fields Disconnected"
await n8n.canvas.rightClickNode('Edit Fields Disconnected');
await n8n.canvas.clickContextMenuAction('Convert node to sub-workflow');
await n8n.canvas.convertToSubworkflowModal.waitForModal();
await n8n.canvas.convertToSubworkflowModal.clickSubmitButton();
await n8n.canvas.convertToSubworkflowModal.waitForClose();
// Verify the Execute Workflow node was created
const executeWorkflowNode = n8n.canvas.getCanvasNodes().filter({
hasText: 'Call My Sub-workflow',
});
await expect(executeWorkflowNode).toHaveCount(1);
// Wait for the node to be fully rendered with its input handle
await n8n.canvas.nodeByName('Call My Sub-workflow').waitFor({ state: 'visible' });
// Now connect the Execute Workflow node to the trigger manually
await n8n.canvas.connectNodesByDrag(
"When clicking 'Execute workflow'",
'Call My Sub-workflow',
);
// Execute and verify the data transformation happened
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
// Open the Execute Workflow node to check the output
await n8n.canvas.openNode('Call My Sub-workflow');
// Edit Fields Disconnected has the expression: $json.x + 'al'
// Input 'l' should produce output 'lal'
// This proves the Start node was connected to Edit Fields Disconnected in the sub-workflow
// If the bug exists, the node wouldn't execute and there would be no output
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('lal');
await n8n.ndv.close();
});
});
});
@@ -0,0 +1,311 @@
import { readFileSync } from 'fs';
import type {
AssignmentCollectionValue,
INodeParameterResourceLocator,
IWorkflowBase,
NodeParameterValueType,
} from 'n8n-workflow';
import { test, expect } from '../../../../../fixtures/base';
import { resolveFromRoot } from '../../../../../utils/path-helper';
function isAssignmentCollectionValue(
param: NodeParameterValueType,
): param is AssignmentCollectionValue {
return (
typeof param === 'object' &&
param !== null &&
'assignments' in param &&
Array.isArray((param as AssignmentCollectionValue).assignments)
);
}
function isResourceLocator(param: NodeParameterValueType): param is INodeParameterResourceLocator {
return typeof param === 'object' && param !== null && '__rl' in param && 'value' in param;
}
function assertAssignmentCollectionValue(
param: NodeParameterValueType,
): asserts param is AssignmentCollectionValue {
if (!isAssignmentCollectionValue(param)) {
throw new Error('Expected AssignmentCollectionValue');
}
}
function assertResourceLocator(
param: NodeParameterValueType,
): asserts param is INodeParameterResourceLocator {
if (!isResourceLocator(param)) {
throw new Error('Expected INodeParameterResourceLocator');
}
}
function assertIsError(error: unknown): asserts error is Error {
if (!(error instanceof Error)) {
throw new Error('Expected Error instance');
}
}
test.describe('Sub-workflow Version Resolution', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('manual execution should use draft version of sub-workflow', async ({ api }) => {
const { workflowId: childWorkflowId, createdWorkflow: childWorkflow } =
await api.workflows.importWorkflowFromFile('subworkflow-version-child.json');
await api.workflows.activate(childWorkflowId, childWorkflow.versionId!);
const assignmentsParam = childWorkflow.nodes[1].parameters.assignments;
assertAssignmentCollectionValue(assignmentsParam);
assignmentsParam.assignments[0].value = 'draft-version';
await api.request.patch(`/rest/workflows/${childWorkflowId}`, {
data: {
versionId: childWorkflow.versionId,
name: childWorkflow.name,
nodes: childWorkflow.nodes,
connections: childWorkflow.connections,
},
});
const { workflowId: parentWorkflowId, createdWorkflow: parentWorkflow } =
await api.workflows.createWorkflowFromDefinition({
name: 'Parent Workflow - Manual',
nodes: [
{
parameters: {},
id: 'manual-trigger',
name: 'When clicking Test workflow',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
parameters: {
source: 'database',
workflowId: {
__rl: true,
value: childWorkflowId,
mode: 'id',
},
},
id: 'execute-workflow',
name: 'Execute Workflow',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.1,
position: [200, 0],
},
],
connections: {
'When clicking Test workflow': {
main: [
[
{
node: 'Execute Workflow',
type: 'main',
index: 0,
},
],
],
},
},
});
const runResponse = await api.request.post(`/rest/workflows/${parentWorkflowId}/run`, {
data: {
workflowData: parentWorkflow,
triggerToStartFrom: { name: 'When clicking Test workflow' },
},
});
expect(runResponse.ok()).toBe(true);
const execution = await api.workflows.waitForExecution(parentWorkflowId, 10000, 'manual');
expect(execution.status).toBe('success');
const executionDetails = await api.workflows.getExecution(execution.id);
expect(executionDetails.data).toContain('draft-version');
});
test('production execution should use published version of sub-workflow', async ({ api }) => {
const childFilePath = resolveFromRoot('workflows', 'subworkflow-version-child.json');
const childDefinition = JSON.parse(readFileSync(childFilePath, 'utf8')) as IWorkflowBase;
const childAssignmentsParam = childDefinition.nodes[1].parameters.assignments;
assertAssignmentCollectionValue(childAssignmentsParam);
childAssignmentsParam.assignments[0].value = 'published-version';
const { workflowId: childWorkflowId, createdWorkflow: childWorkflow } =
await api.workflows.createWorkflowFromDefinition(childDefinition);
await api.workflows.activate(childWorkflowId, childWorkflow.versionId!);
assertAssignmentCollectionValue(childAssignmentsParam);
childAssignmentsParam.assignments[0].value = 'draft-version';
await api.request.patch(`/rest/workflows/${childWorkflowId}`, {
data: {
versionId: childWorkflow.versionId,
name: childDefinition.name,
nodes: childDefinition.nodes,
connections: childDefinition.connections,
},
});
const parentFilePath = resolveFromRoot('workflows', 'subworkflow-version-parent.json');
const parentDefinition = JSON.parse(readFileSync(parentFilePath, 'utf8')) as IWorkflowBase;
const workflowIdParam = parentDefinition.nodes[1].parameters.workflowId;
assertResourceLocator(workflowIdParam);
workflowIdParam.value = childWorkflowId;
const {
webhookPath,
workflowId: parentWorkflowId,
createdWorkflow: parentWorkflow,
} = await api.workflows.importWorkflowFromDefinition(parentDefinition);
await api.workflows.activate(parentWorkflowId, parentWorkflow.versionId!);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
data: { test: 'data' },
});
expect(webhookResponse).toBeDefined();
expect(webhookResponse.ok()).toBe(true);
const responseData = await webhookResponse.json();
expect(responseData['wf-version']).toBe('published-version');
});
test('should prevent publishing parent workflow when sub-workflow is not published', async ({
api,
}) => {
const childWorkflowId = (
await api.workflows.importWorkflowFromFile('subworkflow-version-child.json')
).workflowId;
const { workflowId: parentWorkflowId, createdWorkflow: parentWorkflow } =
await api.workflows.createWorkflowFromDefinition({
name: 'Parent Workflow',
nodes: [
{
parameters: {},
id: 'manual-trigger',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
parameters: {
source: 'database',
workflowId: {
__rl: true,
value: childWorkflowId,
mode: 'id',
},
},
id: 'execute-workflow',
name: 'Execute Workflow',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.1,
position: [200, 0],
},
],
connections: {
'Manual Trigger': {
main: [
[
{
node: 'Execute Workflow',
type: 'main',
index: 0,
},
],
],
},
},
});
try {
await api.workflows.activate(parentWorkflowId, parentWorkflow.versionId!);
expect(true).toBe(false);
} catch (error: unknown) {
assertIsError(error);
expect(error.message).toContain('Failed to activate workflow');
expect(error.message).toContain('Workflow cannot be activated');
}
});
test('should allow self-referencing workflows', async ({ api }) => {
const { workflowId, createdWorkflow: workflow } =
await api.workflows.createWorkflowFromDefinition({
name: 'Self-Referencing Workflow',
nodes: [
{
parameters: {
path: 'self-ref-webhook',
responseMode: 'lastNode',
options: {},
},
id: 'webhook-trigger',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2.1,
position: [0, 0],
webhookId: 'self-ref-webhook-id',
},
{
parameters: {
source: 'database',
workflowId: {
__rl: true,
value: 'SELF',
mode: 'id',
},
},
id: 'execute-workflow',
name: 'Execute Workflow',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.1,
position: [200, 0],
},
],
connections: {
Webhook: {
main: [
[
{
node: 'Execute Workflow',
type: 'main',
index: 0,
},
],
],
},
},
});
workflow.nodes[1].parameters = {
...workflow.nodes[1].parameters,
workflowId: {
__rl: true,
value: workflowId,
mode: 'id',
},
};
const patchResponse = await api.request.patch(`/rest/workflows/${workflowId}`, {
data: {
versionId: workflow.versionId,
name: workflow.name,
nodes: workflow.nodes,
connections: workflow.connections,
},
});
const updatedWorkflowData = await patchResponse.json();
await api.workflows.activate(workflowId, updatedWorkflowData.data.versionId);
});
});
@@ -0,0 +1,227 @@
import flatted from 'flatted';
import { readFileSync } from 'fs';
import type { IWorkflowBase } from 'n8n-workflow';
import { test, expect } from '../../../../../fixtures/base';
import { resolveFromRoot } from '../../../../../utils/path-helper';
import { retryUntil } from '../../../../../utils/retry-utils';
test.describe('Parent that does not wait for sub-workflow', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('should not wait for the sub-workflow', async ({ api }) => {
const childWorkflowId = (
await api.workflows.importWorkflowFromFile('subworkflow-wait-child.json')
).workflowId;
const filePath = resolveFromRoot('workflows', 'subworkflow-parent-no-wait.json');
const fileContent = readFileSync(filePath, 'utf8');
const workflowDefinition = JSON.parse(fileContent) as IWorkflowBase;
expect(workflowDefinition?.nodes[0]?.parameters).toBeDefined();
// Replace the placeholder workflow ID with the actual child workflow ID
workflowDefinition.nodes[0].parameters.workflowId = {
value: childWorkflowId,
mode: 'list',
};
const { webhookPath, workflowId } =
await api.workflows.importWorkflowFromDefinition(workflowDefinition);
const response = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(response.ok()).toBe(true);
const execution = await api.workflows.waitForExecution(workflowId, 5000);
expect(execution.status).toBe('success');
// The child workflow should still be running or waiting, since it's configured to wait 120s.
const getExecutionsResponse = await api.workflows.getExecutions(childWorkflowId);
// TODO: figure out why the filtering in `getExecutions` isn't working.
const childExecutions = getExecutionsResponse.filter((e) => e.workflowId === childWorkflowId);
expect(childExecutions.length).toBe(1);
expect(childExecutions[0].status).toMatch(/running|waiting/);
});
test('CAT-1445 should not be restarted by the child workflow finishing', async ({ api }) => {
// The child is a no-op that returns immediately.
const childWorkflowId = (
await api.workflows.importWorkflowFromFile('subworkflow-noop-child.json')
).workflowId;
// This is a parent that does NOT wait for the child to finish, but it has its own separate Wait node.
// We want to verify that the parent is NOT restarted when the child finishes. This was fixed in CAT-1445.
const filePath = resolveFromRoot('workflows', 'subworkflow-waiting-parent-no-child-wait.json');
const fileContent = readFileSync(filePath, 'utf8');
const workflowDefinition = JSON.parse(fileContent) as IWorkflowBase;
expect(workflowDefinition?.nodes[1]?.parameters).toBeDefined();
// Replace the placeholder workflow ID with the actual child workflow ID
workflowDefinition.nodes[1].parameters.workflowId = {
value: childWorkflowId,
mode: 'list',
};
const { webhookPath, workflowId } =
await api.workflows.importWorkflowFromDefinition(workflowDefinition);
const response = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(response.ok()).toBe(true);
// First, wait for the child to finish (child runs in 'integrated' mode when called by Execute Workflow node)
const childExecution = await api.workflows.waitForExecution(
childWorkflowId,
10000,
'integrated',
);
expect(childExecution.status).toBe('success');
// Verify that the parent didn't get resumed. We might need to give it a moment to reach the waiting state.
await retryUntil(
async () => {
const getExecutionsResponse = await api.workflows.getExecutions(workflowId);
// TODO: figure out why the filtering in `getExecutions` isn't working.
const parentExecutions = getExecutionsResponse.filter((e) => e.workflowId === workflowId);
expect(parentExecutions.length).toBe(1);
expect(parentExecutions[0].status).toBe('waiting');
},
{ timeoutMs: 2000, intervalMs: 100 },
);
});
});
test.describe('CAT-1801: Parent receives correct data from child with wait node', () => {
test('should return child final output to parent after wait completes', async ({ api }) => {
// Import child workflow (has Wait node with webhook)
const { workflowId: childWorkflowId } =
await api.workflows.importWorkflowFromFile('cat-1801-child.json');
// Import parent workflow and link to child
const parentFilePath = resolveFromRoot('workflows', 'cat-1801-parent.json');
const parentContent = readFileSync(parentFilePath, 'utf8');
const parentDefinition = JSON.parse(parentContent) as IWorkflowBase;
// Update Execute Workflow node to reference the child
const executeWorkflowNode = parentDefinition.nodes.find(
(n) => n.type === 'n8n-nodes-base.executeWorkflow',
)!;
executeWorkflowNode.parameters.workflowId = { value: childWorkflowId, mode: 'list' };
const {
webhookPath,
workflowId: parentWorkflowId,
createdWorkflow: { versionId },
} = await api.workflows.importWorkflowFromDefinition(parentDefinition);
// Activate parent workflow so webhook works
await api.workflows.activate(parentWorkflowId, versionId!);
// Trigger parent workflow via webhook
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
// Wait for child execution to appear and enter waiting state
let childExecution;
await retryUntil(
async () => {
const childExecutions = await api.workflows.getExecutions(childWorkflowId);
childExecution = childExecutions.find((e) => e.status === 'waiting');
expect(childExecution).toBeDefined();
},
{ timeoutMs: 10000, intervalMs: 200 },
);
// Trigger the wait webhook to resume child using child execution ID
const waitWebhookResponse = await api.webhooks.trigger(
`/webhook-waiting/${childExecution!.id}`,
);
expect(waitWebhookResponse.ok()).toBe(true);
// Wait for parent to complete
const parentExecution = await api.workflows.waitForExecution(parentWorkflowId, 15000);
expect(parentExecution.status).toBe('success');
// Get full parent execution data
const fullParentExecution = await api.workflows.getExecution(parentExecution.id);
const executionData = flatted.parse(fullParentExecution.data);
// Verify Execute Workflow node received child's FINAL output (after wait)
// Should be 'child - after', NOT 'child - before' or parent input
const executeWorkflowOutput = executionData.resultData.runData['Execute Workflow'];
expect(executeWorkflowOutput).toBeDefined();
expect(executeWorkflowOutput[0].data.main[0][0].json.type).toBe('child - after');
});
});
test.describe('CAT-1929: Parent should not resume until child with multiple waits completes', () => {
test('should keep parent waiting after first wait node is resumed, only completing after second wait', async ({
api,
}) => {
// Import child workflow with two Wait nodes (auto-activated via "active": true in JSON)
const { workflowId: childWorkflowId } = await api.workflows.importWorkflowFromFile(
'cat-1929-child-two-waits.json',
);
// Import parent workflow and link to child
const parentFilePath = resolveFromRoot('workflows', 'cat-1929-parent.json');
const parentContent = readFileSync(parentFilePath, 'utf8');
const parentDefinition = JSON.parse(parentContent) as IWorkflowBase;
// Update Execute Workflow node to reference the child
const executeWorkflowNode = parentDefinition.nodes.find(
(n) => n.type === 'n8n-nodes-base.executeWorkflow',
)!;
executeWorkflowNode.parameters.workflowId = { value: childWorkflowId, mode: 'list' };
const {
webhookPath,
workflowId: parentWorkflowId,
createdWorkflow: { versionId },
} = await api.workflows.importWorkflowFromDefinition(parentDefinition);
// Activate parent workflow so webhook works
await api.workflows.activate(parentWorkflowId, versionId!);
// Trigger parent workflow via webhook
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
// Wait for child execution to appear and enter waiting state (first wait node)
const childExecution = await api.workflows.waitForWorkflowStatus(childWorkflowId, 'waiting');
// Verify parent is also waiting at this point
await api.workflows.waitForWorkflowStatus(parentWorkflowId, 'waiting');
// Resume first wait node
const firstWaitResponse = await api.webhooks.trigger(`/webhook-waiting/${childExecution.id}`);
expect(firstWaitResponse.ok()).toBe(true);
// Wait for child to reach the second wait node
await api.workflows.waitForWorkflowStatus(childWorkflowId, 'waiting');
// Verify parent is STILL waiting (not resumed after first wait completed)
const parentExecAfterFirstWait = await api.workflows.waitForWorkflowStatus(
parentWorkflowId,
'waiting',
);
expect(parentExecAfterFirstWait.status).toBe('waiting');
// Resume second wait node
const secondWaitResponse = await api.webhooks.trigger(`/webhook-waiting/${childExecution.id}`);
expect(secondWaitResponse.ok()).toBe(true);
// Now parent should complete
const parentExecution = await api.workflows.waitForExecution(parentWorkflowId, 15000);
expect(parentExecution.status).toBe('success');
// Verify child also completed
const finalChildExecution = await api.workflows.getExecution(childExecution.id);
expect(finalChildExecution.status).toBe('success');
// Verify Execute Workflow node received child's FINAL output (after both waits)
await retryUntil(async () => {
// Get full parent execution data and verify it received the final child output
const fullParentExecution = await api.workflows.getExecution(parentExecution.id);
const executionData = flatted.parse(fullParentExecution.data);
const executeWorkflowOutput = executionData.resultData.runData['Execute Workflow'];
expect(executeWorkflowOutput).toBeDefined();
expect(executeWorkflowOutput[0].data.main[0][0].json.stage).toBe('completed');
});
});
});
@@ -0,0 +1,100 @@
import { MANUAL_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Sub-workflow';
test.describe('Workflow Selector Parameter', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
const projectId = await n8n.start.fromNewProjectBlankCanvas();
const subWorkflows = [
{ file: 'Test_Subworkflow_Get_Weather.json', name: 'Get_Weather' },
{ file: 'Test_Subworkflow_Search_DB.json', name: 'Search_DB' },
];
for (const { file } of subWorkflows) {
// Create workflow with Execute Workflow Trigger node so it can be activated
const workflowData = {
name: file,
nodes: [
{
id: 'execute-workflow-trigger',
name: 'When Executed by Another Workflow',
type: 'n8n-nodes-base.executeWorkflowTrigger',
position: [0, 0] as [number, number],
parameters: {},
typeVersion: 1,
},
],
connections: {},
settings: {},
active: false,
};
// @ts-expect-error - projectId is not part of IWorkflowBase but is accepted by the API
workflowData.projectId = projectId;
const workflow = await n8n.api.workflows.createWorkflow(workflowData);
// Activate the workflow so it appears in the workflow selector
await n8n.api.workflows.activate(workflow.id, workflow.versionId);
}
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(EXECUTE_WORKFLOW_NODE_NAME, { action: 'Execute A Sub Workflow' });
});
test('should show required parameter warning', async ({ n8n }) => {
await n8n.ndv.openResourceLocator('workflowId');
await expect(n8n.ndv.getParameterInputIssues()).toBeVisible();
});
test('should filter sub-workflows list', async ({ n8n }) => {
await n8n.ndvComposer.filterWorkflowList('workflowId', 'Weather');
const items = n8n.ndv.getResourceLocatorItems();
await expect(items.filter({ hasText: 'Search DB' })).toHaveCount(0);
await n8n.ndvComposer.selectFirstFilteredWorkflow();
const inputField = n8n.ndv.getResourceLocatorInput('workflowId').locator('input');
await expect(inputField).toHaveValue(/Get_Weather/);
});
test('should render sub-workflow links correctly', async ({ n8n }) => {
await n8n.ndvComposer.selectWorkflowFromList('workflowId', 'Search_DB');
const link = n8n.ndv.getResourceLocatorInput('workflowId').locator('a');
await expect(link).toBeVisible();
await n8n.ndv.getExpressionModeToggle().click();
await expect(link).toBeHidden();
});
test('should switch to ID mode on expression', async ({ n8n }) => {
await n8n.ndvComposer.selectWorkflowFromList('workflowId', 'Search_DB');
const modeSelector = n8n.ndv.getResourceLocatorModeSelector('workflowId').locator('input');
await expect(modeSelector).toHaveValue('From list');
await n8n.ndvComposer.switchToExpressionMode('workflowId');
await expect(modeSelector).toHaveValue('By ID');
});
test('should render add resource option and redirect to the correct route when clicked', async ({
n8n,
}) => {
await n8n.ndv.openResourceLocator('workflowId');
const addResourceItem = n8n.ndv.getAddResourceItem();
await expect(addResourceItem).toHaveCount(1);
await expect(addResourceItem.getByText(/Create a/)).toBeVisible();
const secondPage = await n8n.start.fromNewPage(async () => {
await n8n.ndvComposer.createNewSubworkflow('workflowId');
});
await expect(secondPage.canvas.nodeByName('When Executed by Another Workflow')).toBeVisible();
await expect(secondPage.canvas.nodeByName('Replace me with your logic')).toBeVisible();
await expect(secondPage.page).toHaveURL(/\/workflow\/.+/);
});
});
@@ -0,0 +1,218 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../../fixtures/base';
// Tags tests must run serially since they share global tag state
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ api }) => {
await api.tags.deleteAll();
});
test.describe('Workflow tags - Tag creation', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should create and attach tags inline, then add more incrementally', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
const tag1 = `tag-${nanoid(6)}`;
const tag2 = `tag-${nanoid(6)}`;
const tag3 = `tag-${nanoid(6)}`;
await n8n.canvas.clickCreateTagButton();
await n8n.canvas.typeInTagInput(tag1);
await n8n.canvas.pressEnterToCreateTag();
await n8n.canvas.typeInTagInput(tag2);
await n8n.canvas.pressEnterToCreateTag();
await expect(n8n.canvas.getTagPills()).toHaveCount(2);
await n8n.canvas.clickNthTagPill(0);
await n8n.canvas.getVisibleDropdown().waitFor();
await n8n.canvas.typeInTagInput(tag3);
await n8n.canvas.pressEnterToCreateTag();
await n8n.canvas.clickOutsideModal();
// Wait for save to complete first - closing the dropdown triggers a save
// which re-renders the tags container
await n8n.canvas.waitForSaveWorkflowCompleted();
// After dropdown closes, tags are displayed via WorkflowTagsContainer (workflow-tags)
// not the dropdown container, so use getSavedWorkflowTagPills()
await expect(n8n.canvas.getSavedWorkflowTagPills()).toHaveCount(3);
// Pills should be rendered individually, not collapsed as "+3"
await expect(n8n.canvas.getWorkflowTagsElement()).not.toHaveText(/\+\d+/);
});
test('should create tags via modal without attaching them', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
const tag1 = `modal-${nanoid(6)}`;
const tag2 = `modal-${nanoid(6)}`;
await n8n.canvas.openTagManagerModal();
await n8n.canvas.tagsManagerModal.addTag();
await n8n.canvas.tagsManagerModal.getTagInputInModal().fill(tag1);
await n8n.canvas.pressEnterToCreateTag();
await n8n.canvas.tagsManagerModal.getTable().getByText(tag1).waitFor();
await n8n.canvas.tagsManagerModal.addTag();
await n8n.canvas.tagsManagerModal.getTagInputInModal().fill(tag2);
await n8n.canvas.pressEnterToCreateTag();
await n8n.canvas.tagsManagerModal.getTable().getByText(tag2).waitFor();
await n8n.canvas.tagsManagerModal.clickDoneButton();
await n8n.canvas.clickCreateTagButton();
await expect(n8n.canvas.getTagItemInDropdownByName(tag1)).toBeVisible();
await expect(n8n.canvas.getTagItemInDropdownByName(tag2)).toBeVisible();
await expect(n8n.canvas.getTagPills()).toHaveCount(0);
await n8n.canvas.getTagItemInDropdownByName(tag1).click();
await expect(n8n.canvas.getTagPills()).toHaveCount(1);
});
});
test.describe('Workflow tags - Tag operations', () => {
test('should delete all tags via modal with confirmation', async ({ n8n, api }) => {
const tags = await Promise.all([
api.tags.create(`del-${nanoid(6)}`),
api.tags.create(`del-${nanoid(6)}`),
api.tags.create(`del-${nanoid(6)}`),
api.tags.create(`del-${nanoid(6)}`),
api.tags.create(`del-${nanoid(6)}`),
]);
await n8n.start.fromBlankCanvas();
await n8n.canvas.openTagManagerModal();
for (let i = 0; i < 5; i++) {
await n8n.canvas.tagsManagerModal.getFirstTagRow().hover();
await n8n.canvas.tagsManagerModal.getDeleteTagButton().first().click();
await n8n.canvas.tagsManagerModal.getDeleteTagConfirmButton().click();
await expect(n8n.canvas.tagsManagerModal.getDeleteConfirmationMessage()).toBeHidden();
}
await n8n.canvas.tagsManagerModal.clickDoneButton();
await n8n.canvas.clickCreateTagButton();
for (const tag of tags) {
await expect(n8n.canvas.getTagItemInDropdownByName(tag.name)).not.toBeAttached();
}
await expect(n8n.canvas.getTagPills()).toHaveCount(0);
});
test('should detach tag by clicking X in dropdown', async ({ n8n, api }) => {
const tags = await Promise.all([
api.tags.create(`detach-x-${nanoid(6)}`),
api.tags.create(`detach-x-${nanoid(6)}`),
api.tags.create(`detach-x-${nanoid(6)}`),
api.tags.create(`detach-x-${nanoid(6)}`),
api.tags.create(`detach-x-${nanoid(6)}`),
]);
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickCreateTagButton();
for (const tag of tags) {
await n8n.canvas.getTagItemInDropdownByName(tag.name).click();
}
await expect(n8n.canvas.getTagPills()).toHaveCount(5);
await n8n.canvas.clickNthTagPill(0);
await n8n.canvas.getTagCloseButton().first().click();
await n8n.canvas.clickOutsideModal();
await expect(n8n.canvas.getWorkflowTagsDropdown()).not.toBeAttached();
await expect(n8n.canvas.getSavedWorkflowTagPills()).toHaveCount(4);
});
test('should detach tag by clicking selected item in dropdown', async ({ n8n, api }) => {
const tags = await Promise.all([
api.tags.create(`toggle-${nanoid(6)}`),
api.tags.create(`toggle-${nanoid(6)}`),
api.tags.create(`toggle-${nanoid(6)}`),
api.tags.create(`toggle-${nanoid(6)}`),
api.tags.create(`toggle-${nanoid(6)}`),
]);
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickCreateTagButton();
for (const tag of tags) {
await n8n.canvas.getTagItemInDropdownByName(tag.name).click();
}
await expect(n8n.canvas.getTagPills()).toHaveCount(5);
await n8n.canvas.clickWorkflowTagsContainer();
await n8n.canvas.getSelectedTagItems().first().click();
await n8n.canvas.clickOutsideModal();
await expect(n8n.canvas.getWorkflowTagsDropdown()).not.toBeAttached();
await expect(n8n.canvas.getSavedWorkflowTagPills()).toHaveCount(4);
});
test('should show correct tag count when reopening after save', async ({ n8n, api }) => {
const tags = await Promise.all([
api.tags.create(`reopen-${nanoid(6)}`),
api.tags.create(`reopen-${nanoid(6)}`),
api.tags.create(`reopen-${nanoid(6)}`),
]);
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickCreateTagButton();
for (const tag of tags) {
await n8n.canvas.getTagItemInDropdownByName(tag.name).click();
}
await expect(n8n.canvas.getTagPills()).toHaveCount(3);
await n8n.canvas.clickOutsideModal();
await expect(n8n.canvas.getWorkflowTagsDropdown()).not.toBeAttached();
await n8n.canvas.clickWorkflowTagsArea();
await expect(n8n.canvas.getWorkflowTagsDropdown()).toBeVisible();
await expect(n8n.canvas.getTagPills()).toHaveCount(3);
});
test('should not show non-existing tag as selectable option', async ({ n8n, api }) => {
const tags = await Promise.all([
api.tags.create(`exist-${nanoid(6)}`),
api.tags.create(`exist-${nanoid(6)}`),
api.tags.create(`exist-${nanoid(6)}`),
api.tags.create(`exist-${nanoid(6)}`),
api.tags.create(`exist-${nanoid(6)}`),
]);
const nonExisting = `nonexist-${nanoid(6)}`;
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickCreateTagButton();
for (const tag of tags) {
await n8n.canvas.getTagItemInDropdownByName(tag.name).click();
}
await expect(n8n.canvas.getTagPills()).toHaveCount(5);
await n8n.canvas.clickOutsideModal();
await n8n.canvas.clickWorkflowTagsArea();
await n8n.canvas.typeInTagInput(nonExisting);
const dropdownItems = n8n.canvas.getVisibleDropdown().locator('li');
await expect(dropdownItems).toHaveCount(2);
await expect(n8n.canvas.getTagItemsInDropdown()).toHaveCount(0);
});
});
@@ -0,0 +1,132 @@
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../../config/test-users';
import { test, expect } from '../../../../fixtures/base';
import type { n8nPage } from '../../../../pages/n8nPage';
test.use({ capability: { env: { TEST_ISOLATION: 'viewer-permissions' } } });
const MEMBER_EMAIL = INSTANCE_MEMBER_CREDENTIALS[0].email;
// Helper to set up a project with a workflow and sign in as member with specified role
async function setupProjectWithWorkflowAndSignInAsMember({
n8n,
roleSlug,
nodeName,
}: {
n8n: n8nPage;
roleSlug: string;
nodeName: string;
}): Promise<void> {
await n8n.navigate.toHome();
// Create project and add member with role
const { projectId, projectName: createdProjectName } = await n8n.projectComposer.createProject();
await n8n.api.projects.addUserToProjectByEmail(projectId, MEMBER_EMAIL, roleSlug);
// Create workflow with node
await n8n.sideBar.clickProjectMenuItem(createdProjectName);
await n8n.workflows.clickNewWorkflowButtonFromProject();
await n8n.canvas.addNode(nodeName, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
// Sign in as member and navigate to the workflow
await n8n.api.signin('member', 0);
await n8n.navigate.toHome();
await n8n.sideBar.clickProjectMenuItem(createdProjectName);
await n8n.workflows.cards.getWorkflows().first().click();
await expect(n8n.canvas.canvasPane()).toBeVisible();
await expect(n8n.canvas.getLoadingMask()).toBeHidden({ timeout: 30000 });
await expect(n8n.canvas.getLoadingMask()).not.toBeAttached();
}
test.describe('Workflow Viewer Permissions', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe.configure({ mode: 'serial' });
let readOnlyRole: { slug: string };
let editorRole: { slug: string };
test.beforeAll(async ({ api }) => {
await api.enableFeature('sharing');
await api.enableFeature('advancedPermissions');
await api.enableFeature('customRoles');
await api.setMaxTeamProjectsQuota(-1);
// Sign in as owner (admin) to create custom roles
await api.signin('owner');
// Create custom read-only role (no workflow:update scope)
readOnlyRole = await api.roles.createCustomRole(
['project:read', 'workflow:read', 'workflow:list'],
'Workflow Read Only',
);
// Create custom editor role (with workflow:update scope)
editorRole = await api.roles.createCustomRole(
['project:read', 'workflow:read', 'workflow:list', 'workflow:update', 'workflow:execute'],
'Workflow Custom Editor',
);
});
test('user without workflow:update scope cannot drag nodes @auth:owner', async ({ n8n }) => {
await setupProjectWithWorkflowAndSignInAsMember({
n8n,
roleSlug: readOnlyRole.slug,
nodeName: 'Edit Fields (Set)',
});
// Attempt to drag - node should not move (no workflow:update scope)
const node = n8n.canvas.nodeByName('Edit Fields');
const initialPosition = await node.boundingBox();
await n8n.canvas.dragNodeToRelativePosition('Edit Fields', 100, 50);
const finalPosition = await node.boundingBox();
// Position should remain unchanged
expect(finalPosition?.x).toBe(initialPosition?.x);
expect(finalPosition?.y).toBe(initialPosition?.y);
});
test('user without workflow:update can copy but cannot paste @auth:owner', async ({ n8n }) => {
await setupProjectWithWorkflowAndSignInAsMember({
n8n,
roleSlug: readOnlyRole.slug,
nodeName: 'Edit Fields (Set)',
});
// Copy SHOULD work (useful for copying to another workflow)
await n8n.canvasComposer.selectAllAndCopy();
// Paste should NOT work (requires workflow:update)
const nodeCountBefore = await n8n.canvas.getCanvasNodes().count();
await n8n.page.keyboard.press('ControlOrMeta+V');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(nodeCountBefore);
});
test('user with workflow:update scope can drag and paste @auth:owner', async ({ n8n }) => {
await setupProjectWithWorkflowAndSignInAsMember({
n8n,
roleSlug: editorRole.slug,
nodeName: 'Edit Fields (Set)',
});
// Drag should work
const node = n8n.canvas.nodeByName('Edit Fields');
const initialPosition = await node.boundingBox();
await n8n.canvas.dragNodeToRelativePosition('Edit Fields', 100, 50);
// Position SHOULD change
await expect.poll(async () => (await node.boundingBox())?.x).not.toBe(initialPosition?.x);
// Copy and paste should work
await n8n.canvasComposer.selectAllAndCopy();
const nodeCountBefore = await n8n.canvas.getCanvasNodes().count();
await n8n.page.keyboard.press('ControlOrMeta+V');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(nodeCountBefore + 1);
});
});
@@ -0,0 +1,197 @@
import { SCHEDULE_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
async function getWorkflowIdAfterSave(n8n: n8nPage): Promise<string> {
const saveResponse = await n8n.canvas.waitForSaveWorkflowCompleted();
const {
data: { id },
} = await saveResponse.json();
return id;
}
async function goToWorkflow(n8n: n8nPage, workflowId: string): Promise<void> {
const loadResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes(`/rest/workflows/${workflowId}`) &&
response.request().method() === 'GET' &&
response.status() === 200,
);
await n8n.page.goto(`/workflow/${workflowId}`);
await loadResponsePromise;
}
test.describe(
'Workflow Archive',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.fixme();
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should not be able to archive or delete unsaved workflow', async ({ n8n }) => {
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getDeleteMenuItem()).toBeHidden();
await expect(n8n.workflowSettingsModal.getArchiveMenuItem().locator('..')).toHaveClass(
/is-disabled/,
);
});
test('should archive nonactive workflow and then delete it', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await getWorkflowIdAfterSave(n8n);
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickDeleteMenuItem();
await n8n.workflowSettingsModal.confirmDeleteModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
});
// Flaky in multi-main mode
test.fixme('should archive published workflow and then delete it', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await getWorkflowIdAfterSave(n8n);
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await n8n.workflowSettingsModal.confirmArchiveModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickDeleteMenuItem();
await n8n.workflowSettingsModal.confirmDeleteModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
});
test('should archive nonactive workflow and then unarchive it', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await getWorkflowIdAfterSave(n8n);
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnarchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.canvas.getNodeCreatorPlusButton()).toBeVisible();
});
test('should not show unpublish menu item for non-published workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getUnpublishMenuItem()).not.toBeAttached();
});
// TODO: flaky test - 18 similar failures across 10 branches in last 14 days
test.fixme('should unpublish a published workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnpublishMenuItem();
await expect(n8n.workflowSettingsModal.getUnpublishModal()).toBeVisible();
await n8n.workflowSettingsModal.confirmUnpublishModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
});
// Flaky in multi-main mode
test.fixme('should unpublish published workflow on archive', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await getWorkflowIdAfterSave(n8n);
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await n8n.workflowSettingsModal.confirmArchiveModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await expect(n8n.canvas.getPublishButton()).toBeHidden();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnarchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeVisible();
});
},
);
@@ -0,0 +1,73 @@
import fs from 'fs';
import { CODE_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import { resolveFromRoot } from '../../../../../utils/path-helper';
test.describe(
'Workflow Copy Paste',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.fixme();
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should copy nodes', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeCreator.getRoot()).not.toBeAttached();
await n8n.clipboard.grant();
await n8n.canvas.selectAll();
await n8n.canvas.copyNodes();
await n8n.notifications.waitForNotificationAndClose('Copied to clipboard');
const clipboardText = await n8n.clipboard.readText();
const copiedWorkflow = JSON.parse(clipboardText);
expect(copiedWorkflow.nodes).toHaveLength(2);
});
test('should paste nodes (both current and old node versions)', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_paste-data.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
await expect(n8n.canvas.nodeConnections()).toHaveCount(5);
});
test('should allow importing nodes without names', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_import_nodes_empty_name.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
const nodes = n8n.canvas.getCanvasNodes();
const count = await nodes.count();
for (let i = 0; i < count; i++) {
await expect(nodes.nth(i)).toHaveAttribute('data-node-name');
}
});
},
);
@@ -0,0 +1,35 @@
import { nanoid } from 'nanoid';
import { MANUAL_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe(
'Workflow Duplicate',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.fixme();
const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
test('should duplicate unsaved workflow', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
const uniqueTag = `Duplicate-${nanoid(6)}`;
await n8n.workflowComposer.duplicateWorkflow(DUPLICATE_WORKFLOW_NAME, uniqueTag);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
test('should duplicate saved workflow', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.waitForSaveWorkflowCompleted();
const uniqueTag = `Duplicate-${nanoid(6)}`;
await n8n.workflowComposer.duplicateWorkflow(DUPLICATE_WORKFLOW_NAME, uniqueTag);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
},
);
@@ -0,0 +1,139 @@
import {
MANUAL_TRIGGER_NODE_NAME,
NOTION_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe('Workflow Publish', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should not be able to publish workflow without trigger node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeDisabled();
});
test('should be able to publish workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
});
test('should not be able to publish workflow when nodes have errors', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeDisabled();
});
test('should be able to publish workflow when nodes with errors are disabled', async ({
n8n,
}) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeDisabled();
const nodeName = await n8n.canvas.getCanvasNodes().last().getAttribute('data-node-name');
await n8n.canvas.toggleNodeEnabled(nodeName!);
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
});
test.describe('Webhook conflict validation', () => {
const cleanupWorkflowIds: string[] = [];
test.afterEach(async ({ api }) => {
while (cleanupWorkflowIds.length > 0) {
try {
// delete workflows created during tests. If a happy path test is flacky, it would fail on next execution
// due to webhook conflicts if the created workflow/webhooks is still active
const workflowId = cleanupWorkflowIds.pop();
await api.workflows.deactivate(workflowId!);
await api.workflows.delete(workflowId!);
} catch {
// ignore potential errors in the cleanup process
}
}
});
test('successfully publishes a workflow without webhook conflicts', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'webhook-origin-isolation.json',
);
cleanupWorkflowIds.push(workflowId);
await expect(
api.workflows.activate(workflowId, createdWorkflow.versionId!),
).resolves.not.toThrow();
});
test('successfully publishes a workflow containing wait node', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'webhook-publish-with-wait-node.json',
);
cleanupWorkflowIds.push(workflowId);
await expect(
api.workflows.activate(workflowId, createdWorkflow.versionId!),
).resolves.not.toThrow();
});
test('Rejects publishing a workflow containing webhook conflicts with published workflow', async ({
api,
}) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'webhook-publish-no-conflicts.json',
{ makeUnique: false },
);
cleanupWorkflowIds.push(workflowId);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { workflowId: workflowId2, createdWorkflow: createdWorkflow2 } =
await api.workflows.importWorkflowFromFile('webhook-publish-no-conflicts.json', {
makeUnique: false,
transform: ({ id, ...workflow }) => ({
...workflow,
id: Date.now().toString(),
}),
});
cleanupWorkflowIds.push(workflowId2);
await expect(
api.workflows.activate(workflowId2, createdWorkflow2.versionId!),
).rejects.toThrow('There is a conflict with one of the webhooks');
});
test('Rejects publishing a workflow containing local conflicts', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'webhook-publish-local-conflict.json',
{ makeUnique: false },
);
cleanupWorkflowIds.push(workflowId);
await expect(api.workflows.activate(workflowId, createdWorkflow.versionId!)).rejects.toThrow(
'There is a conflict with one of the webhooks',
);
});
});
});
@@ -0,0 +1,65 @@
import {
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
test.describe(
'Workflow Run',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.fixme();
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should keep endpoint click working when switching between execution and editor tab', async ({
n8n,
}) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.clickNodePlusEndpoint('Edit Fields');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickExecutionsTab();
await n8n.page.waitForURL(/\/executions/);
await n8n.canvas.clickEditorTab();
await n8n.canvas.clickNodePlusEndpoint('Edit Fields');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
});
test('should run workflow on button click', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickExecuteWorkflowButton();
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible();
});
test('should run workflow using keyboard shortcut', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.hitExecuteWorkflow();
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible();
});
test('should not run empty workflows', async ({ n8n }) => {
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await expect(n8n.canvas.getExecuteWorkflowButton()).not.toBeAttached();
await n8n.canvas.hitExecuteWorkflow();
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(0);
});
},
);
@@ -0,0 +1,78 @@
import { test, expect } from '../../../../../fixtures/base';
test.describe(
'Workflow Settings',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.fixme();
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should update workflow settings', async ({ n8n }) => {
await n8n.navigate.toHome();
const workflowsResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') && response.request().method() === 'GET',
);
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
const workflowsResponse = await workflowsResponsePromise;
const responseBody = await workflowsResponse.json();
const totalWorkflows = responseBody.count;
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getModal()).toBeVisible();
await n8n.workflowSettingsModal.getErrorWorkflowField().click();
const optionCount = await n8n.page.getByRole('option').count();
expect(optionCount).toBeGreaterThanOrEqual(totalWorkflows + 2);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getTimezoneField().click();
await expect(n8n.page.getByRole('option').first()).toBeVisible();
await n8n.page.getByRole('option').nth(1).click();
await n8n.workflowSettingsModal.getSaveFailedExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveSuccessExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveManualExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveExecutionProgressField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getTimeoutSwitch().click();
await n8n.workflowSettingsModal.getTimeoutInput().fill('1');
await n8n.workflowSettingsModal.clickSave();
await expect(n8n.workflowSettingsModal.getModal()).toBeHidden();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
});
test.describe('Menu entry Push To Git', () => {
test('should not show up in the menu for members @auth:member', async ({ n8n }) => {
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getPushToGitMenuItem()).not.toBeAttached();
});
test('should show up for owners @auth:owner', async ({ n8n }) => {
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getPushToGitMenuItem()).toBeVisible();
});
});
},
);