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
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:
+171
@@ -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(
|
||||
'I’m 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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
+41
@@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
+63
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+48
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+138
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+117
@@ -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();
|
||||
});
|
||||
});
|
||||
+106
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+128
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+311
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+100
@@ -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);
|
||||
});
|
||||
});
|
||||
+197
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
+73
@@ -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');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
+35
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
+139
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
+78
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user