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,171 @@
import { test, expect } from '../../../../fixtures/base';
const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
test.describe(
'Workflow Production Checklist',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should show suggested actions automatically when workflow is first published', async ({
n8n,
}) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await expect(n8n.canvas.getProductionChecklistButton()).toBeHidden();
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(n8n.canvas.getProductionChecklistButton()).toBeVisible();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await expect(n8n.canvas.getErrorActionItem()).toBeVisible();
await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible();
});
test('should display evaluations action when AI node exists and feature is enabled', async ({
n8n,
}) => {
await n8n.api.enableFeature('evaluation');
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode('OpenAI', { action: 'Message a model', closeNDV: true });
await n8n.canvas.nodeDisableButton('Message a model').click();
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await expect(n8n.canvas.getEvaluationsActionItem()).toBeVisible();
await n8n.canvas.getEvaluationsActionItem().click();
await expect(n8n.page).toHaveURL(/\/evaluation/);
});
test('should open workflow settings modal when error workflow action is clicked', async ({
n8n,
}) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
const errorAction = n8n.canvas.getErrorActionItem();
await expect(errorAction).toBeVisible();
await errorAction.click();
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
await expect(n8n.page.getByTestId('workflow-settings-error-workflow')).toBeVisible();
});
test('should open workflow settings modal when time saved action is clicked', async ({
n8n,
}) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
const timeAction = n8n.canvas.getTimeSavedActionItem();
await expect(timeAction).toBeVisible();
await timeAction.click();
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeVisible();
});
test('should allow ignoring individual actions', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await expect(n8n.canvas.getProductionChecklistActionItem().first()).toContainText('error');
await n8n.canvas.getProductionChecklistActionItem().first().getByTitle('Ignore').click();
await expect(n8n.canvas.getErrorActionItem()).toBeHidden();
await n8n.page.locator('body').click({ position: { x: 0, y: 0 } });
await n8n.canvas.clickProductionChecklistButton();
await expect(n8n.canvas.getErrorActionItem()).toBeHidden();
await expect(n8n.canvas.getTimeSavedActionItem()).toBeVisible();
});
// Flaky in multi-main mode
test.fixme('should show completed state for configured actions', async ({ n8n, api }) => {
const errorWorkflow = await api.workflows.createWorkflow({
name: 'Error Handler',
nodes: [
{
id: 'error-trigger',
name: 'Error Trigger',
type: 'n8n-nodes-base.errorTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
settings: {},
active: false,
});
await api.workflows.activate(errorWorkflow.id, errorWorkflow.versionId);
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getModal()).toBeVisible();
await n8n.workflowSettingsModal.selectErrorWorkflow('Error Handler');
await n8n.workflowSettingsModal.clickSave();
await expect(n8n.page.getByTestId('workflow-settings-dialog')).toBeHidden();
await n8n.canvas.clickProductionChecklistButton();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await expect(
n8n.canvas
.getProductionChecklistActionItem()
.first()
.locator('svg[data-icon="circle-check"]'),
).toBeVisible();
});
test('should allow ignoring all actions with confirmation', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await expect(n8n.workflowActivationModal.getModal()).toBeVisible();
await n8n.workflowActivationModal.close();
await expect(n8n.canvas.getProductionChecklistPopover()).toBeVisible();
await n8n.canvas.clickProductionChecklistIgnoreAll();
await expect(n8n.page.locator('.el-message-box')).toBeVisible();
await n8n.page
.locator('.el-message-box__btns button')
.filter({ hasText: /ignore for all workflows/i })
.click();
await expect(n8n.canvas.getProductionChecklistButton()).toBeHidden();
});
},
);
@@ -0,0 +1,289 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const requirements: TestRequirements = {
config: {
settings: {
previewMode: true,
},
},
};
// Simple workflow structures for testing
const oldWorkflow = {
id: 'old-workflow-id',
name: 'Test Workflow - Before',
nodes: [
{
parameters: {},
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [250, 300],
},
{
parameters: {},
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [450, 300],
},
],
connections: {
'Manual Trigger': {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
},
};
const newWorkflow = {
id: 'new-workflow-id',
name: 'Test Workflow - After',
nodes: [
{
parameters: {},
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [250, 300],
},
{
parameters: {
options: {
keepOnlySet: true,
},
},
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [450, 300],
},
{
parameters: {},
id: '3',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [650, 300],
},
],
connections: {
'Manual Trigger': {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
Set: {
main: [
[
{
node: 'Code',
type: 'main',
index: 0,
},
],
],
},
},
};
test.describe('Workflow Diff Demo', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ setupRequirements }) => {
await setupRequirements(requirements);
});
test('shows waiting state initially', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Verify the waiting state message is shown
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
});
test('renders diff view when receiving workflow data via postMessage', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Verify the waiting state is initially shown
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
// Send postMessage with workflow data
await n8n.page.evaluate(
({ oldWf, newWf }) => {
window.postMessage(
JSON.stringify({
command: 'openDiff',
oldWorkflow: oldWf,
newWorkflow: newWf,
}),
'*',
);
},
{ oldWf: oldWorkflow, newWf: newWorkflow },
);
// Wait for the diff view to appear - the waiting message should disappear
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The workflow name should appear in the header
await expect(
n8n.page.getByRole('heading', { name: /Test Workflow/, exact: false }),
).toBeVisible();
// The Changes button should be visible (part of the diff view header)
await expect(n8n.page.getByRole('button', { name: /Changes/ })).toBeVisible();
});
test('renders diff view with only new workflow (creation scenario)', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Wait for the page to be ready (component mounted with event listener active)
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
// Send postMessage with only newWorkflow (simulating workflow creation)
await n8n.page.evaluate(
({ newWf }) => {
window.postMessage(
JSON.stringify({
command: 'openDiff',
newWorkflow: newWf,
}),
'*',
);
},
{ newWf: newWorkflow },
);
// Wait for the diff view to appear
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The workflow name from newWorkflow should appear
await expect(n8n.page.getByRole('heading', { name: 'Test Workflow - After' })).toBeVisible();
});
test('renders diff view with only old workflow (deletion scenario)', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Wait for the page to be ready (component mounted with event listener active)
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
// Send postMessage with only oldWorkflow (simulating workflow deletion)
await n8n.page.evaluate(
({ oldWf }) => {
window.postMessage(
JSON.stringify({
command: 'openDiff',
oldWorkflow: oldWf,
}),
'*',
);
},
{ oldWf: oldWorkflow },
);
// Wait for the diff view to appear
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The workflow name from oldWorkflow should appear
await expect(n8n.page.getByRole('heading', { name: 'Test Workflow - Before' })).toBeVisible();
});
test('applies tidy up when tidyUp option is true', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Wait for the page to be ready (component mounted with event listener active)
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
// Send postMessage with tidyUp: true
await n8n.page.evaluate(
({ oldWf, newWf }) => {
window.postMessage(
JSON.stringify({
command: 'openDiff',
oldWorkflow: oldWf,
newWorkflow: newWf,
tidyUp: true,
}),
'*',
);
},
{ oldWf: oldWorkflow, newWf: newWorkflow },
);
// Wait for the diff view to appear
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeHidden();
// The diff view should render (tidyUp affects node positioning but view should still render)
await expect(
n8n.page.getByRole('heading', { name: /Test Workflow/, exact: false }),
).toBeVisible();
// The Changes button should be visible (part of the diff view header)
await expect(n8n.page.getByRole('button', { name: /Changes/ })).toBeVisible();
});
test('ignores malformed postMessage data', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Verify initial waiting state
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
// Send malformed message
await n8n.page.evaluate(() => {
window.postMessage('not valid json {{{', '*');
});
// Waiting state should still be shown
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
});
test('ignores non-openDiff commands', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff');
// Verify initial waiting state
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
// Send a different command
await n8n.page.evaluate(() => {
window.postMessage(
JSON.stringify({
command: 'someOtherCommand',
data: {},
}),
'*',
);
});
// Waiting state should still be shown
await expect(n8n.page.getByText('Waiting for workflow data...')).toBeVisible();
});
test('can override theme to dark', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff?theme=dark');
await expect(n8n.page.locator('body')).toHaveAttribute('data-theme', 'dark');
});
test('can override theme to light', async ({ n8n }) => {
await n8n.page.goto('/workflows/demo/diff?theme=light');
await expect(n8n.page.locator('body')).toHaveAttribute('data-theme', 'light');
});
});
@@ -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();
});
});
},
);
@@ -0,0 +1,66 @@
import { test, expect } from '../../../../fixtures/base';
test.describe(
'Executions Filter',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_4_executions_view.json');
// Create mix of successful and failed executions
await n8n.executionsComposer.createExecutions(2);
await n8n.canvas.toggleNodeEnabled('Error');
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.executionsComposer.createExecutions(2);
const executionsResponsePromise = n8n.page.waitForResponse((response) =>
response.url().includes('/rest/executions?filter='),
);
await n8n.canvas.clickExecutionsTab();
await executionsResponsePromise;
await expect(n8n.executions.getExecutionItems().first()).toBeVisible();
});
test('should filter executions by status and show filter badge', async ({ n8n }) => {
await n8n.executions.openFilter();
await expect(n8n.executions.getFilterForm()).toBeVisible();
// No badge before filtering
await expect(n8n.executions.getFilterBadge()).toBeHidden();
const filterRequestPromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/executions?filter=') && response.url().includes('error'),
);
await n8n.executions.selectFilterStatus('Error');
await filterRequestPromise;
// Badge shows active filter count
await expect(n8n.executions.getFilterBadge()).toBeVisible();
// Only failed executions visible
await expect(n8n.executions.getFailedExecutionItems()).toHaveCount(2);
await expect(n8n.executions.getSuccessfulExecutionItems()).toHaveCount(0);
});
test('should reset filter and remove badge', async ({ n8n }) => {
await n8n.executions.openFilter();
await n8n.executions.selectFilterStatus('Success');
await expect(n8n.executions.getFilterBadge()).toBeVisible();
const resetRequestPromise = n8n.page.waitForResponse((response) =>
response.url().includes('/rest/executions?filter='),
);
await n8n.executions.resetFilter();
await resetRequestPromise;
await expect(n8n.executions.getFilterBadge()).toBeHidden();
await expect(n8n.executions.getExecutionItems()).toHaveCount(4);
});
},
);
@@ -0,0 +1,286 @@
import { test, expect } from '../../../../fixtures/base';
import executionOutOfMemoryResponse from '../../../../fixtures/execution-out-of-memory-server-response.json';
test.describe(
'Executions Filter',
{
annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_4_executions_view.json');
});
// There is flakiness in this test. When the dropdown is opened, at times it appears to automatically close even without user interaction.
// Manual timeouts have been added to try to mitigate this, but it still happens.
test.fixme(
'should keep popover open when selecting from dropdown inside it',
async ({ n8n }) => {
// Regression test: Element Plus dropdowns are teleported to body, causing
// Reka UI's DismissableLayer to detect clicks as "outside" and close the popover.
// This test verifies the popover stays open during and after dropdown selection.
// Create some executions first
await n8n.executionsComposer.createExecutions(2);
// Go to executions tab
await n8n.canvas.clickExecutionsTab();
await expect(n8n.executions.getExecutionItems().first()).toBeVisible();
// Open filter popover
await n8n.executions.openFilter();
const filterForm = n8n.executions.getFilterForm();
await n8n.page.waitForTimeout(1500);
await expect(filterForm).toBeVisible();
// Click to open the status dropdown
await n8n.executions.getStatusSelect().click();
// Verify popover is still open while dropdown is open
await expect(filterForm).toBeVisible();
// Set up listener for the filtered executions request
const filterRequestPromise = n8n.page.waitForRequest(
(request) =>
request.url().includes('/rest/executions?filter=') && request.url().includes('success'),
);
await n8n.page.waitForTimeout(500);
// Select an option from the dropdown
await n8n.page.getByRole('option', { name: 'Success' }).click();
// Verify the filter request was sent to the backend (confirms selection worked)
const filterRequest = await filterRequestPromise;
expect(filterRequest.url()).toContain('status');
expect(filterRequest.url()).toContain('success');
// KEY ASSERTION: Verify the popover did NOT close after selecting from dropdown
await expect(filterForm).toBeVisible();
},
);
},
);
const ERROR_MESSAGES = {
OUT_OF_MEMORY: 'Workflow did not finish, possible out-of-memory issue',
} as const;
const TIMEOUTS = {
EXECUTIONS_REFRESH_INTERVAL: 4000,
} as const;
test.describe('Workflow Executions', () => {
test.describe('when workflow is saved', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_4_executions_view.json');
});
test('should render executions tab correctly', async ({ n8n }) => {
await n8n.executionsComposer.createExecutions(15);
await n8n.canvas.toggleNodeEnabled('Error');
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.executionsComposer.createExecutions(2);
await n8n.canvas.toggleNodeEnabled('Error');
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.executionsComposer.createExecutions(15);
const executionsResponsePromise = n8n.page.waitForResponse((response) =>
response.url().includes('/rest/executions?filter='),
);
await n8n.canvas.clickExecutionsTab();
await executionsResponsePromise;
await expect(n8n.executions.getExecutionItems().first()).toBeVisible();
const loadMoreResponsePromise = n8n.page.waitForResponse((response) =>
response.url().includes('/rest/executions?filter='),
);
await n8n.executions.scrollExecutionsListToBottom();
await loadMoreResponsePromise;
await expect(n8n.executions.getExecutionItems()).toHaveCount(30, { timeout: 15000 });
await expect(n8n.executions.getSuccessfulExecutionItems()).toHaveCount(28);
await expect(n8n.executions.getFailedExecutionItems()).toHaveCount(2);
await expect(n8n.executions.getFirstExecutionItem()).toHaveClass(/_active_/);
});
test('should not redirect back to execution tab when request is not done before leaving the page', async ({
n8n,
}) => {
await n8n.page.route('**/rest/executions?filter=*', (route) => route.continue());
await n8n.page.route('**/rest/executions/active?filter=*', (route) => route.continue());
await n8n.canvas.clickExecutionsTab();
await n8n.canvas.clickEditorTab();
await expect(n8n.page).toHaveURL(/\/workflow\/[^/]+$/, {
timeout: TIMEOUTS.EXECUTIONS_REFRESH_INTERVAL,
});
for (let i = 0; i < 3; i++) {
await n8n.canvas.clickExecutionsTab();
await n8n.canvas.clickEditorTab();
}
await expect(n8n.page).toHaveURL(/\/workflow\/[^/]+$/, {
timeout: TIMEOUTS.EXECUTIONS_REFRESH_INTERVAL,
});
await n8n.canvas.clickExecutionsTab();
// eslint-disable-next-line playwright/no-wait-for-timeout
await n8n.page.waitForTimeout(1000);
await n8n.canvas.clickEditorTab();
await expect(n8n.page).toHaveURL(/\/workflow\/[^/]+$/, {
timeout: TIMEOUTS.EXECUTIONS_REFRESH_INTERVAL,
});
});
test('should not redirect back to execution tab when slow request is not done before leaving the page', async ({
n8n,
}) => {
await n8n.page.route('**/rest/executions?filter=*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await n8n.page.route('**/rest/executions/active?filter=*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await n8n.canvas.clickExecutionsTab();
await n8n.page.waitForURL(/\/executions/);
await n8n.canvas.clickEditorTab();
await n8n.page.waitForURL(/\/workflow\/[^/]+$/);
await expect(n8n.page).toHaveURL(/\/workflow\/[^/]+$/, {
timeout: TIMEOUTS.EXECUTIONS_REFRESH_INTERVAL,
});
});
test('should error toast when server error message returned without stack trace', async ({
n8n,
}) => {
const responsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/run') &&
response.request().method() === 'POST',
);
await n8n.canvas.clickExecuteWorkflowButton();
await responsePromise;
await n8n.page.route('**/rest/executions/*', async (route) => {
if (route.request().url().includes('?filter=')) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(executionOutOfMemoryResponse),
});
});
const executionDetailPromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/executions/') && !response.url().includes('?filter='),
);
await n8n.canvas.clickExecutionsTab();
await executionDetailPromise;
const iframe = n8n.executions.getPreviewIframe();
await expect(iframe.locator('body')).not.toBeEmpty();
await n8n.executions.getErrorNotificationsInPreview().first().waitFor({ timeout: 5000 });
const errorNotification = n8n.executions
.getErrorNotificationsInPreview()
.filter({ hasText: ERROR_MESSAGES.OUT_OF_MEMORY });
await expect(errorNotification).toBeVisible();
});
test.fixme(
'should show workflow data in executions tab after hard reload and modify name and tags',
async () => {
// TODO: Migrate from Cypress
},
);
test.fixme('should load items and auto scroll after filter change', async () => {
// TODO: This should be a component test
});
test('should redirect back to editor after seeing a couple of execution using browser back button', async ({
n8n,
}) => {
await n8n.executionsComposer.createExecutions(15);
await n8n.canvas.toggleNodeEnabled('Error');
await expect(n8n.canvas.disabledNodes()).toHaveCount(0);
await n8n.executionsComposer.createExecutions(2);
await n8n.canvas.toggleNodeEnabled('Error');
await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
await n8n.executionsComposer.createExecutions(15);
const executionsResponsePromise = n8n.page.waitForResponse((response) =>
response.url().includes('/rest/executions?filter='),
);
await n8n.canvas.clickExecutionsTab();
await executionsResponsePromise;
const iframe = n8n.executions.getPreviewIframe();
await expect(iframe.locator('body')).toBeAttached();
await n8n.executions.getExecutionItems().nth(2).click();
await expect(iframe.locator('body')).toBeAttached();
await n8n.executions.getExecutionItems().nth(4).click();
await expect(iframe.locator('body')).toBeAttached();
await n8n.executions.getExecutionItems().nth(6).click();
await expect(iframe.locator('body')).toBeAttached();
await n8n.page.goBack();
await expect(iframe.locator('body')).toBeAttached();
await n8n.page.goBack();
await expect(iframe.locator('body')).toBeAttached();
await n8n.page.goBack();
await expect(iframe.locator('body')).toBeAttached();
await n8n.page.goBack();
await expect(n8n.page).not.toHaveURL(/\/executions/);
await expect(n8n.page).toHaveURL(/\/workflow\//);
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
test.describe('when new workflow is not saved', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should open executions tab', async ({ n8n }) => {
await n8n.canvas.clickExecutionsTab();
await expect(n8n.executions.getExecutionsSidebar()).toBeVisible();
await expect(n8n.executions.getExecutionsEmptyList()).toBeVisible();
await expect(n8n.page.getByTestId('workflow-execution-no-trigger-content')).toBeVisible();
await n8n.page.getByRole('button', { name: 'Add first step' }).click();
await n8n.canvas.nodeCreatorItemByName('Trigger manually').click();
await n8n.canvas.clickExecutionsTab();
await expect(n8n.executions.getExecutionsSidebar()).toBeVisible();
await expect(n8n.executions.getExecutionsEmptyList()).toBeVisible();
await expect(n8n.page.getByTestId('workflow-execution-no-content')).toBeVisible();
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.page.waitForURL(/\/workflow\/.+\/executions$/);
});
});
});
@@ -0,0 +1,104 @@
import { test, expect } from '../../../../fixtures/base';
import onboardingWorkflow from '../../../../workflows/Onboarding_workflow.json';
test.describe('Import workflow', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.describe('From URL', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.page.route('**/rest/workflows/from-url*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: onboardingWorkflow }),
});
});
});
test('should import workflow', async ({ n8n }) => {
await n8n.navigate.toWorkflow('new');
await n8n.page.waitForLoadState('load');
await n8n.canvas.clickWorkflowMenu();
await n8n.canvas.clickImportFromURL();
await expect(n8n.canvas.getImportURLInput()).toBeVisible();
await n8n.canvas.fillImportURLInput('https://fakepage.com/workflow.json');
await n8n.canvas.clickConfirmImportURL();
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(4);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(0);
});
test('clicking outside modal should not show error toast', async ({ n8n }) => {
await n8n.navigate.toWorkflow('new');
await n8n.page.waitForLoadState('load');
await n8n.canvas.clickWorkflowMenu();
await n8n.canvas.clickImportFromURL();
await n8n.canvas.clickOutsideModal();
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
test('canceling modal should not show error toast', async ({ n8n }) => {
await n8n.navigate.toWorkflow('new');
await n8n.page.waitForLoadState('load');
await n8n.canvas.clickWorkflowMenu();
await n8n.canvas.clickImportFromURL();
await n8n.canvas.clickCancelImportURL();
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
test('should import workflow from URL without .json extension', async ({ n8n }) => {
await n8n.navigate.toWorkflow('new');
await n8n.page.waitForLoadState('load');
await n8n.canvas.clickWorkflowMenu();
await n8n.canvas.clickImportFromURL();
await expect(n8n.canvas.getImportURLInput()).toBeVisible();
await n8n.canvas.fillImportURLInput('https://example.com/api/workflow');
await n8n.canvas.clickConfirmImportURL();
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(4);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(0);
});
});
test.describe('From File', () => {
test('should import workflow', async ({ n8n }) => {
await n8n.navigate.toWorkflow('new');
await n8n.page.waitForLoadState('load');
await n8n.canvas.importWorkflow(
'Test_workflow-actions_paste-data.json',
'Import Test Workflow',
);
await n8n.page.waitForLoadState('load');
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
await expect(n8n.canvas.nodeConnections()).toHaveCount(5);
});
});
});
@@ -0,0 +1,198 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../../fixtures/base';
const NOTIFICATIONS = {
ARCHIVED: 'archived',
UNARCHIVED: 'unarchived',
DELETED: 'deleted',
UNPUBLISHED: 'unpublished',
};
test.describe('Workflows', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
});
test('should create a new workflow using empty state button', async ({ n8n }) => {
const { projectId } = await n8n.projectComposer.createProject();
await n8n.page.goto(`projects/${projectId}/workflows`);
await n8n.workflows.clickNewWorkflowButtonFromProject();
// New workflows redirect to /workflow/<id>?new=true
await expect(n8n.page).toHaveURL(/workflow\/[a-zA-Z0-9_-]+\?.*new=true/);
});
test('should create a new workflow using add workflow button and save successfully', async ({
n8n,
}) => {
const { projectId } = await n8n.projectComposer.createProject();
await n8n.page.goto(`projects/${projectId}/workflows`);
await n8n.workflows.addResource.workflow();
const uniqueIdForCreate = nanoid(8);
const workflowName = `Test Workflow ${uniqueIdForCreate}`;
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.waitForSaveWorkflowCompleted();
});
test('should search for workflows', async ({ n8n }) => {
const uniqueId = nanoid(8);
const specificName = `Specific Test ${uniqueId}`;
const genericName = `Generic Test ${uniqueId}`;
await n8n.workflowComposer.createWorkflowFromSidebar(specificName);
await n8n.goHome();
await n8n.workflowComposer.createWorkflow(genericName);
await n8n.goHome();
// Search for specific workflow
await n8n.workflows.search(specificName);
await expect(n8n.workflows.cards.getWorkflow(specificName)).toBeVisible();
// Search with partial term
await n8n.workflows.clearSearch();
await n8n.workflows.search(uniqueId);
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(2);
// Search for non-existent
await n8n.workflows.clearSearch();
await n8n.workflows.search('NonExistentWorkflow123');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
await expect(n8n.workflows.getNoWorkflowsFoundMessage()).toBeVisible();
});
test('should archive and unarchive a workflow', async ({ n8n }) => {
const uniqueIdForArchive = nanoid(8);
const workflowName = `Archive Test ${uniqueIdForArchive}`;
await n8n.workflowComposer.createWorkflowFromSidebar(workflowName);
await n8n.goHome();
// Create a second workflow so we can still see filters
await n8n.workflowComposer.createWorkflow();
await n8n.goHome();
const workflow = n8n.workflows.cards.getWorkflow(workflowName);
await n8n.workflows.archiveWorkflow(workflow);
await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.ARCHIVED)).toBeVisible();
await expect(workflow).toBeHidden();
await n8n.workflows.toggleShowArchived();
await expect(workflow).toBeVisible();
await n8n.workflows.unarchiveWorkflow(workflow);
await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.UNARCHIVED)).toBeVisible();
});
test('should delete an archived workflow', async ({ n8n }) => {
const uniqueIdForDelete = nanoid(8);
const workflowName = `Delete Test ${uniqueIdForDelete}`;
await n8n.workflowComposer.createWorkflowFromSidebar(workflowName);
await n8n.goHome();
await n8n.workflowComposer.createWorkflow();
await n8n.goHome();
const workflow = n8n.workflows.cards.getWorkflow(workflowName);
await n8n.workflows.archiveWorkflow(workflow);
await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.ARCHIVED)).toBeVisible();
await n8n.workflows.toggleShowArchived();
await n8n.workflows.deleteWorkflow(workflow);
await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.DELETED)).toBeVisible();
await expect(workflow).toBeHidden();
});
test('should unpublish a published workflow from workflow list', async ({ n8n }) => {
const uniqueIdForUnpublish = nanoid(8);
const workflowName = `Unpublish Test ${uniqueIdForUnpublish}`;
// Create and publish a workflow
await n8n.workflowComposer.createWorkflow(workflowName);
// Add a webhook trigger to make workflow publishable
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.nodeCreator.searchFor('webhook');
await n8n.canvas.nodeCreator.selectItem('Webhook');
await n8n.page.keyboard.press('Escape');
// Publish the workflow
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
// Go back to workflows list
await n8n.goHome();
// Find the published workflow and unpublish it
const workflow = n8n.workflows.cards.getWorkflow(workflowName);
await expect(workflow).toBeVisible();
// Unpublish the workflow from the workflow card actions menu
await n8n.workflows.unpublishWorkflow(workflow);
// Verify unpublish notification appears
await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.UNPUBLISHED)).toBeVisible();
// Verify workflow still exists but is no longer published
await expect(workflow).toBeVisible();
// Open workflow to verify it's no longer published
await workflow.click();
await expect(n8n.canvas.getPublishedIndicator()).toBeHidden();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeVisible();
});
test('should filter workflows by tag', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const taggedWorkflow = await n8n.api.workflows.createInProject(projectId);
const tag = await n8n.api.tags.create(`test-tag-${nanoid(8)}`);
await n8n.api.workflows.setTags(taggedWorkflow.id, [tag.id]);
await n8n.api.workflows.createInProject(projectId);
await n8n.page.goto(`projects/${projectId}/workflows`);
await n8n.workflows.filterByTag(tag.name);
await expect(n8n.workflows.cards.getWorkflow(taggedWorkflow.name)).toBeVisible();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
});
test('should preserve search and filters in URL', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const workflowName = `My Tagged Workflow ${nanoid(8)}`;
const taggedWorkflow = await n8n.api.workflows.createInProject(projectId, {
name: workflowName,
});
const tag1 = await n8n.api.tags.create(`test-tag-${nanoid(8)}`);
const tag2 = await n8n.api.tags.create(`test-tag-${nanoid(8)}`);
await n8n.api.workflows.setTags(taggedWorkflow.id, [tag1.id, tag2.id]);
await n8n.page.goto(`projects/${projectId}/workflows`);
await n8n.workflows.search('Tagged');
await n8n.workflows.filterByTag(tag1.name);
await expect(n8n.page).toHaveURL(/search=Tagged/);
await n8n.page.reload();
await expect(n8n.workflows.getSearchBar()).toHaveValue('Tagged');
await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeVisible();
});
test('should share a workflow', async ({ n8n }) => {
const uniqueIdForShare = nanoid(8);
const workflowName = `Share Test ${uniqueIdForShare}`;
await n8n.workflowComposer.createWorkflow(workflowName);
await n8n.goHome();
await n8n.workflows.shareWorkflow(workflowName);
await expect(n8n.workflowSharingModal.getModal()).toBeVisible();
});
});
@@ -0,0 +1,281 @@
import { readFileSync } from 'fs';
import { test, expect } from '../../../../fixtures/base';
import type { TestRequirements } from '../../../../Types';
import { resolveFromRoot } from '../../../../utils/path-helper';
test.use({ capability: { env: { TEST_ISOLATION: 'template-credentials-setup' } } });
const TEMPLATE_HOST = 'https://api.n8n.io/api/';
const TEMPLATE_ID = 1205;
const TEMPLATE_WITHOUT_CREDS_ID = 1344;
const COLLECTION_ID = 1;
const testTemplate = JSON.parse(
readFileSync(resolveFromRoot('workflows', 'Test_Template_1.json'), 'utf8'),
);
const templateWithoutCredentials = JSON.parse(
readFileSync(resolveFromRoot('workflows', 'Test_Template_2.json'), 'utf8'),
);
const ecommerceCollection = JSON.parse(
readFileSync(
resolveFromRoot('workflows', 'Ecommerce_starter_pack_template_collection.json'),
'utf8',
),
);
function createTemplateRequirements(): TestRequirements {
return {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({
'055_template_setup_experience': 'control',
'069_setup_panel': 'control',
}),
},
config: {
settings: {
templates: {
enabled: true,
host: TEMPLATE_HOST,
},
},
},
intercepts: {
getTemplatePreview: {
url: `${TEMPLATE_HOST}templates/workflows/${TEMPLATE_ID}`,
response: testTemplate,
},
getTemplate: {
url: `${TEMPLATE_HOST}workflows/templates/${TEMPLATE_ID}`,
response: testTemplate.workflow,
},
getTemplateCollection: {
url: `${TEMPLATE_HOST}templates/collections/${COLLECTION_ID}`,
response: ecommerceCollection,
},
},
};
}
test.describe(
'Template credentials setup @db:reset',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.beforeEach(async ({ setupRequirements }) => {
await setupRequirements(createTemplateRequirements());
});
test('can be opened from template collection page', async ({ n8n }) => {
await n8n.navigate.toTemplateCollection(COLLECTION_ID);
await n8n.templates.clickUseWorkflowButton('Promote new Shopify products');
await expect(
n8n.templateCredentialSetup.getTitle("Set up 'Promote new Shopify products' template"),
).toBeVisible();
});
test('has all the elements on page', async ({ n8n }) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await expect(
n8n.templateCredentialSetup.getTitle("Set up 'Promote new Shopify products' template"),
).toBeVisible();
await expect(n8n.templateCredentialSetup.getInfoCallout()).toContainText(
'You need 1x Shopify, 1x X (Formerly Twitter) and 1x Telegram account to setup this template',
);
const expectedAppNames = ['1. Shopify', '2. X (Formerly Twitter)', '3. Telegram'];
const expectedAppDescriptions = [
'The credential you select will be used in the product created node of the workflow template.',
'The credential you select will be used in the Twitter node of the workflow template.',
'The credential you select will be used in the Telegram node of the workflow template.',
];
const formSteps = n8n.templateCredentialSetup.getFormSteps();
const count = await formSteps.count();
for (let i = 0; i < count; i++) {
const step = formSteps.nth(i);
await expect(n8n.templateCredentialSetup.getStepHeading(step)).toHaveText(
expectedAppNames[i],
);
await expect(n8n.templateCredentialSetup.getStepDescription(step)).toHaveText(
expectedAppDescriptions[i],
);
}
});
test('can skip template creation', async ({ n8n }) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await n8n.templateCredentialSetup.getSkipLink().click();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
test('can create credentials and workflow from the template', async ({ n8n }) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await expect(n8n.templateCredentialSetup.getContinueButton()).toBeDisabled();
await n8n.templatesComposer.fillDummyCredentialForApp('Shopify', {
fields: { shopSubdomain: 'test-shop', apiKey: 'test-token', password: 'test-key' },
});
await expect(n8n.templateCredentialSetup.getContinueButton()).toBeEnabled();
await n8n.templatesComposer.fillDummyCredentialForOAuthApp('X (Formerly Twitter)', {
fields: { consumerKey: 'consumer-key', consumerSecret: 'consumer-secret' },
});
await n8n.templatesComposer.fillDummyCredentialForApp('Telegram', {
fields: { accessToken: 'bot-token' },
});
await n8n.notifications.quickCloseAll();
const workflowCreatePromise = n8n.page.waitForResponse('**/rest/workflows');
await n8n.templateCredentialSetup.getContinueButton().click();
await workflowCreatePromise;
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
const workflow = await n8n.canvasComposer.getWorkflowFromClipboard();
expect(workflow.meta).toHaveProperty('templateId', TEMPLATE_ID.toString());
expect(workflow.meta).not.toHaveProperty('templateCredsSetupCompleted');
workflow.nodes.forEach((node: { credentials?: Record<string, unknown> }) => {
expect(Object.keys(node.credentials ?? {})).toHaveLength(1);
});
});
test('should work with a template that has no credentials (ADO-1603)', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements({
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({
'055_template_setup_experience': 'control',
'069_setup_panel': 'control',
}),
},
config: {
settings: {
templates: {
enabled: true,
host: TEMPLATE_HOST,
},
},
},
intercepts: {
getTemplatePreview: {
url: `${TEMPLATE_HOST}templates/workflows/${TEMPLATE_WITHOUT_CREDS_ID}`,
response: templateWithoutCredentials,
},
getTemplate: {
url: `${TEMPLATE_HOST}workflows/templates/${TEMPLATE_WITHOUT_CREDS_ID}`,
response: templateWithoutCredentials.workflow,
},
},
});
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_WITHOUT_CREDS_ID);
const expectedAppNames = ['1. Email (IMAP)', '2. Nextcloud'];
const expectedAppDescriptions = [
'The credential you select will be used in the IMAP Email node of the workflow template.',
'The credential you select will be used in the Nextcloud node of the workflow template.',
];
const formSteps = n8n.templateCredentialSetup.getFormSteps();
const count = await formSteps.count();
for (let i = 0; i < count; i++) {
const step = formSteps.nth(i);
await expect(n8n.templateCredentialSetup.getStepHeading(step)).toHaveText(
expectedAppNames[i],
);
await expect(n8n.templateCredentialSetup.getStepDescription(step)).toHaveText(
expectedAppDescriptions[i],
);
}
await expect(n8n.templateCredentialSetup.getContinueButton()).toBeDisabled();
await n8n.templatesComposer.fillDummyCredentialForApp('Email (IMAP)');
await n8n.templatesComposer.fillDummyCredentialForApp('Nextcloud');
await n8n.notifications.quickCloseAll();
const workflowCreatePromise = n8n.page.waitForResponse('**/rest/workflows');
await n8n.templateCredentialSetup.getContinueButton().click();
await workflowCreatePromise;
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
test.describe('Credential setup from workflow editor', () => {
test('should allow credential setup from workflow editor if user skips it during template setup', async ({
n8n,
}) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await n8n.templateCredentialSetup.getSkipLink().click();
await expect(n8n.canvas.getSetupWorkflowCredentialsButton()).toBeVisible();
});
test('should allow credential setup from workflow editor if user fills in credentials partially during template setup', async ({
n8n,
}) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await n8n.templatesComposer.fillDummyCredentialForApp('Shopify', {
fields: { shopSubdomain: 'test-shop', apiKey: 'test-token', password: 'test-key' },
});
await n8n.notifications.quickCloseAll();
const workflowCreatePromise = n8n.page.waitForResponse('**/rest/workflows');
await n8n.templateCredentialSetup.getContinueButton().click();
await workflowCreatePromise;
await expect(n8n.canvas.getSetupWorkflowCredentialsButton()).toBeVisible();
});
test('should fill credentials from workflow editor', async ({ n8n }) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await n8n.templateCredentialSetup.getSkipLink().click();
await n8n.canvas.getSetupWorkflowCredentialsButton().click();
await expect(n8n.workflowCredentialSetupModal.getModal()).toBeVisible();
await n8n.templatesComposer.fillDummyCredentialForApp('Shopify', {
fields: { shopSubdomain: 'test-shop', apiKey: 'test-token', password: 'test-key' },
});
await n8n.templatesComposer.fillDummyCredentialForOAuthApp('X (Formerly Twitter)', {
fields: { consumerKey: 'consumer-key', consumerSecret: 'consumer-secret' },
});
await n8n.templatesComposer.fillDummyCredentialForApp('Telegram', {
fields: { accessToken: 'bot-token' },
});
await n8n.notifications.quickCloseAll();
await n8n.workflowCredentialSetupModal.clickContinue();
await expect(n8n.workflowCredentialSetupModal.getModal()).toBeHidden();
const workflow = await n8n.canvasComposer.getWorkflowFromClipboard();
workflow.nodes.forEach((node: { credentials?: Record<string, unknown> }) => {
expect(Object.keys(node.credentials ?? {})).toHaveLength(1);
});
await expect(n8n.canvas.getSetupWorkflowCredentialsButton()).toBeHidden();
});
});
},
);
@@ -0,0 +1,308 @@
import { test, expect } from '../../../../fixtures/base';
import type { n8nPage } from '../../../../pages/n8nPage';
import type { TestRequirements } from '../../../../Types';
import allTemplatesSearchResponse from '../../../../workflows/all_templates_search_response.json';
import onboardingWorkflow from '../../../../workflows/Onboarding_workflow.json';
import salesTemplatesSearchResponse from '../../../../workflows/sales_templates_search_response.json';
import workflowTemplate from '../../../../workflows/Workflow_template_write_http_query.json';
const TEMPLATE_HOST = {
N8N_API: 'https://api.n8n.io/api/',
CUSTOM: 'random.domain',
} as const;
const URLS = {
N8N_WORKFLOWS: 'https://n8n.io/workflows',
} as const;
const TEMPLATE_ID = '1';
const TEST_CATEGORY = 'sales';
const SALES_CATEGORY_ID = 3;
const CATEGORIES = [
{ id: 1, name: 'Engineering' },
{ id: 2, name: 'Finance' },
{ id: 3, name: 'Sales' },
];
const COLLECTIONS = [
{
id: 1,
name: 'Test Collection',
workflows: [{ id: 1 }],
nodes: [],
},
];
// Helper functions
/**
* Prevents the browser's "before unload" confirmation dialog
* Used when navigating away from workflows with unsaved changes in tests
*/
function preventNavigation(n8n: n8nPage) {
return n8n.page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).preventNodeViewBeforeUnload = true;
});
}
/**
* Extracts the numeric count from UI text (e.g., "5 templates" 5)
* @param text - Text containing a number (e.g., "5 templates", "3 collections")
* @returns The extracted number, or 0 if no number found
*/
function parseCount(text: string | null): number {
const digits = text?.replace(/\D/g, '') ?? '';
return parseInt(digits || '0', 10);
}
function createTemplateHostRequirements(): TestRequirements {
return {
config: {
settings: {
templates: {
enabled: true,
host: TEMPLATE_HOST.N8N_API,
},
},
},
};
}
function createCustomTemplateHostRequirements(hostname: string): TestRequirements {
return {
config: {
settings: {
templates: {
enabled: true,
host: `https://${hostname}/api`,
},
},
},
intercepts: {
health: {
url: `https://${hostname}/api/health`,
response: { status: 'OK' },
},
categories: {
url: `https://${hostname}/api/templates/categories`,
response: { categories: CATEGORIES },
},
getTemplate: {
url: `https://${hostname}/api/workflows/templates/${TEMPLATE_ID}`,
response: {
id: 1,
name: onboardingWorkflow.name,
workflow: onboardingWorkflow,
},
},
getTemplatePreview: {
url: `https://${hostname}/api/templates/workflows/${TEMPLATE_ID}`,
response: workflowTemplate,
},
},
};
}
/**
* Setup dynamic template routes for collections and search
* This handles query parameter-based routing that cannot be configured statically
* - Collections API filters by category
* - Search API returns different results based on category
*/
async function setupDynamicTemplateRoutes(n8n: n8nPage, hostname: string) {
await n8n.page.route(`https://${hostname}/api/templates/collections*`, (route) => {
const url = new URL(route.request().url());
const categoryParam = url.searchParams.get('category[]');
const response = categoryParam === String(SALES_CATEGORY_ID) ? [] : COLLECTIONS;
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ collections: response }),
});
});
await n8n.page.route(`https://${hostname}/api/templates/search*`, (route) => {
const url = new URL(route.request().url());
const category = url.searchParams.get('category');
const response =
category === 'Sales' ? salesTemplatesSearchResponse : allTemplatesSearchResponse;
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
});
}
test.describe('Workflow templates', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.describe('For api.n8n.io', () => {
test('Opens website when clicking templates sidebar link', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(createTemplateHostRequirements());
await n8n.navigate.toWorkflows();
const templatesLink = n8n.sideBar.getTemplatesLink();
await expect(templatesLink).toBeVisible();
const href = await templatesLink.getAttribute('href');
expect(href).toContain(URLS.N8N_WORKFLOWS);
const url = new URL(href!);
const origin = await n8n.page.evaluate(() => window.location.origin);
const utmInstance = url.searchParams.get('utm_instance');
expect(utmInstance).toBeTruthy();
expect(decodeURIComponent(utmInstance!)).toContain(origin);
const utmVersion = url.searchParams.get('utm_n8n_version');
expect(utmVersion).toBeTruthy();
expect(utmVersion).toMatch(/[0-9]+\.[0-9]+\.[0-9]+/);
const utmAwc = url.searchParams.get('utm_awc');
expect(utmAwc).toBeTruthy();
expect(utmAwc).toMatch(/[0-9]+/);
await expect(templatesLink).toHaveAttribute('target', '_blank');
});
test('Redirects to website when visiting templates page directly', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(createTemplateHostRequirements());
await n8n.navigate.toTemplates();
await expect(n8n.templates.getPageHeading()).toBeVisible({ timeout: 10000 });
});
});
test.describe('For a custom template host', () => {
const hostname = TEMPLATE_HOST.CUSTOM;
test.beforeEach(async ({ n8n, setupRequirements }) => {
await setupRequirements(createCustomTemplateHostRequirements(hostname));
await setupDynamicTemplateRoutes(n8n, hostname);
});
test('can open onboarding flow', async ({ n8n }) => {
await preventNavigation(n8n);
await Promise.all([
n8n.page.waitForResponse(`https://${hostname}/api/workflows/templates/${TEMPLATE_ID}`),
n8n.page.waitForResponse('**/rest/workflows'),
n8n.navigate.toOnboardingTemplate(TEMPLATE_ID),
]);
await expect(n8n.page).toHaveURL(/.*\/workflow\/.*onboardingId=1$/);
const workflowNameOnboarding = await n8n.canvas.getWorkflowName().getAttribute('title');
expect(workflowNameOnboarding).toContain(`Demo: ${onboardingWorkflow.name}`);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(4);
await expect(n8n.canvas.sticky.getStickies()).toHaveCount(1);
});
test('can import template', async ({ n8n }) => {
await preventNavigation(n8n);
await Promise.all([
n8n.page.waitForResponse(`https://${hostname}/api/workflows/templates/${TEMPLATE_ID}`),
n8n.page.waitForResponse('**/rest/workflows/**'),
n8n.navigate.toTemplateImport(TEMPLATE_ID),
]);
// New workflows redirect to /workflow/<id>?new=true&templateId=1
await expect(n8n.page).toHaveURL(/\/workflow\/[a-zA-Z0-9_-]+\?.*templateId=1/);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(4);
await expect(n8n.canvas.sticky.getStickies()).toHaveCount(1);
const workflowName = await n8n.canvas.getWorkflowName().getAttribute('title');
expect(workflowName).toContain(onboardingWorkflow.name);
});
test('should save template id with the workflow', async ({ n8n }) => {
await n8n.templatesComposer.importFirstTemplate();
// Execute workflow to trigger autosave (imported templates don't auto-save immediately)
await n8n.canvas.hitExecuteWorkflow();
const saveResponsePromise = n8n.canvas.waitForSaveWorkflowCompleted();
const saveResponse = await saveResponsePromise;
const requestBody = saveResponse.request().postDataJSON();
expect(requestBody.meta.templateId).toBe(TEMPLATE_ID);
});
test('can open template with images and hides workflow screenshots', async ({ n8n }) => {
await n8n.navigate.toTemplate(TEMPLATE_ID);
await expect(n8n.templates.getDescription()).toBeVisible();
await expect(n8n.templates.getDescription().locator('img')).toHaveCount(1);
});
test('renders search elements correctly', async ({ n8n }) => {
await n8n.navigate.toTemplates();
await expect(n8n.templates.getSearchInput()).toBeVisible();
await expect(n8n.templates.getAllCategoriesFilter()).toBeVisible();
const categoryFilterCount = await n8n.templates.getCategoryFilters().count();
expect(categoryFilterCount).toBeGreaterThan(1);
const templateCardCount = await n8n.templates.getTemplateCards().count();
expect(templateCardCount).toBeGreaterThan(0);
});
test('can filter templates by category', async ({ n8n }) => {
await n8n.navigate.toTemplates();
await expect(n8n.templates.getTemplatesLoadingContainer()).toBeHidden();
await expect(n8n.templates.getCategoryFilter(TEST_CATEGORY)).toBeVisible();
const initialTemplateText = await n8n.templates.getTemplateCountLabel().textContent();
const initialTemplateCount = parseCount(initialTemplateText);
const initialCollectionText = await n8n.templates.getCollectionCountLabel().textContent();
const initialCollectionCount = parseCount(initialCollectionText);
await n8n.templates.clickCategoryFilter(TEST_CATEGORY);
await expect(n8n.templates.getTemplatesLoadingContainer()).toBeHidden();
const finalTemplateText = await n8n.templates.getTemplateCountLabel().textContent();
const finalTemplateCount = parseCount(finalTemplateText);
expect(finalTemplateCount).toBeLessThan(initialTemplateCount);
const finalCollectionText = await n8n.templates.getCollectionCountLabel().textContent();
const finalCollectionCount = parseCount(finalCollectionText);
expect(finalCollectionCount).toBeLessThan(initialCollectionCount);
});
test('should preserve search query in URL', async ({ n8n }) => {
await n8n.navigate.toTemplates();
await expect(n8n.templates.getTemplatesLoadingContainer()).toBeHidden();
await expect(n8n.templates.getCategoryFilter(TEST_CATEGORY)).toBeVisible();
await n8n.templates.clickCategoryFilter(TEST_CATEGORY);
await n8n.templates.getSearchInput().fill('auto');
await expect(n8n.page).toHaveURL(/\?categories=/);
await expect(n8n.page).toHaveURL(/&search=/);
await n8n.page.reload();
await expect(n8n.page).toHaveURL(/\?categories=/);
await expect(n8n.page).toHaveURL(/&search=/);
const salesFilterLabel = n8n.templates.getCategoryFilter(TEST_CATEGORY);
await expect(salesFilterLabel).toBeChecked();
await expect(n8n.templates.getSearchInput()).toHaveValue('auto');
await expect(n8n.templates.getCategoryFilters().nth(1)).toHaveText('Sales');
});
});
});