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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,292 @@
import {
simpleAssistantResponse,
aiDisabledRequirements,
aiEnabledRequirements,
aiEnabledWithWorkflowRequirements,
aiEnabledWithQuickRepliesRequirements,
aiEnabledWithEndSessionRequirements,
aiEnabledWithSimpleChatRequirements,
} from '../../../config/ai-assistant-fixtures';
import { SCHEDULE_TRIGGER_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
type ChatRequestBody = {
payload?: {
type?: string;
text?: string;
question?: string;
context?: Record<string, unknown>;
};
};
test.describe('AI Assistant::disabled', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test('does not show assistant button if feature is disabled', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiDisabledRequirements);
await n8n.page.goto('/workflow/new');
await expect(n8n.canvas.canvasPane()).toBeVisible();
await expect(n8n.aiAssistant.getAskAssistantFloatingButton()).toHaveCount(0);
});
});
test.describe('AI Assistant::enabled', () => {
test('renders placeholder UI', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.goto('/workflow/new');
await expect(n8n.aiAssistant.getAskAssistantCanvasActionButton()).toBeVisible();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await expect(n8n.aiAssistant.getPlaceholderMessage()).toBeVisible();
await expect(n8n.aiAssistant.getChatInput()).toBeVisible();
await expect(n8n.aiAssistant.getSendMessageButton()).toBeDisabled();
await expect(n8n.aiAssistant.getCloseChatButton()).toBeVisible();
await n8n.aiAssistant.getCloseChatButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeHidden();
});
test('should show resizer when chat is open', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.goto('/workflow/new');
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantSidebarResizer()).toBeVisible();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await n8n.aiAssistant.getAskAssistantSidebarResizer().hover();
await n8n.aiAssistant.getCloseChatButton().click();
});
test('should start chat session from node error view', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithWorkflowRequirements);
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeVisible();
await expect(n8n.aiAssistant.getNodeErrorViewAssistantButton()).toBeEnabled();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesAll().first()).toContainText(
'Hey, this is an assistant message',
);
});
test('should render chat input correctly', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithWorkflowRequirements);
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await expect(n8n.aiAssistant.getChatInput()).toBeVisible();
await expect(n8n.aiAssistant.getSendMessageButton()).toBeDisabled();
await n8n.aiAssistant.getChatInput().fill('Test message');
await expect(n8n.aiAssistant.getChatInput()).toHaveValue('Test message');
await expect(n8n.aiAssistant.getSendMessageButton()).toBeEnabled();
await n8n.aiAssistant.getSendMessageButton().click();
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatInput()).toHaveValue('');
});
test('should render and handle quick replies', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithQuickRepliesRequirements);
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getQuickReplyButtons()).toHaveCount(2);
await expect(n8n.aiAssistant.getQuickReplyButtons()).toHaveCount(2);
await n8n.aiAssistant.getQuickReplyButtons().first().click();
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toContainText("Sure, let's do it");
});
test('should warn before starting a new session', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithWorkflowRequirements);
await n8n.canvas.openNode('Edit Fields');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
await n8n.aiAssistant.getCloseChatButton().click();
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getNewAssistantSessionModal()).toBeVisible();
await n8n.aiAssistant
.getNewAssistantSessionModal()
.getByRole('button', { name: 'Start new session' })
.click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(1);
});
test('should end chat session when `end_session` event is received', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithEndSessionRequirements);
await n8n.canvas.openNode('Stop and Error');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesSystem().first()).toContainText(
'session has ended',
);
});
test('should reset session after it ended and sidebar is closed', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.goto('/workflow/new');
await n8n.page.route('**/rest/ai/chat', async (route) => {
const requestBody = route.request().postDataJSON() as ChatRequestBody;
const isInit = requestBody.payload?.type === 'init-support-chat';
const response = isInit
? simpleAssistantResponse
: {
sessionId: '1',
messages: [
{
role: 'assistant',
type: 'message',
title: 'Glad to Help',
text: "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!",
},
{
role: 'assistant',
type: 'event',
eventName: 'end-session',
},
],
};
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
});
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await n8n.aiAssistant.sendMessage('Hello', 'enter-key');
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(2);
await n8n.aiAssistant.getCloseChatButton().click();
// Wait for sidebar to close
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeHidden();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(2);
await n8n.aiAssistant.sendMessage('Thanks, bye', 'enter-key');
await expect(n8n.aiAssistant.getChatMessagesSystem()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesSystem().first()).toContainText(
'session has ended',
);
await n8n.aiAssistant.getCloseChatButton().click();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getPlaceholderMessage()).toBeVisible();
});
test('should not reset assistant session when workflow is saved', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithSimpleChatRequirements);
await n8n.page.goto('/workflow/new');
await n8n.canvas.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.clickBackToCanvasButton();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await n8n.aiAssistant.sendMessage('Hello', 'enter-key');
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await n8n.canvas.openNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.execute();
await expect(n8n.aiAssistant.getPlaceholderMessage()).toHaveCount(0);
});
test('should send message via shift + enter even with global NodeCreator panel opened', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithSimpleChatRequirements);
await n8n.page.goto('/workflow/new');
await n8n.canvas.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.clickBackToCanvasButton();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await n8n.canvas.nodeCreator.open();
await n8n.aiAssistant.sendMessage('Hello', 'enter-key');
await expect(n8n.aiAssistant.getPlaceholderMessage()).toHaveCount(0);
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
});
});
@@ -0,0 +1,101 @@
import {
codeDiffSuggestionResponse,
applyCodeDiffResponse,
nodeExecutionSucceededResponse,
aiEnabledWorkflowBaseRequirements,
aiEnabledWithCodeDiffRequirements,
} from '../../../config/ai-assistant-fixtures';
import { test, expect } from '../../../fixtures/base';
test.describe('AI Assistant::enabled', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.describe('Code Node Error Help', () => {
test('should apply code diff to code node', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithCodeDiffRequirements);
let applySuggestionCalls = 0;
await n8n.page.route('**/rest/ai/chat/apply-suggestion', async (route) => {
applySuggestionCalls += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(applyCodeDiffResponse),
});
});
await n8n.canvas.openNode('Code');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(2);
await expect(n8n.aiAssistant.getCodeDiffs()).toHaveCount(1);
await expect(n8n.aiAssistant.getApplyCodeDiffButtons()).toHaveCount(1);
await n8n.aiAssistant.getApplyCodeDiffButtons().first().click();
await expect(n8n.aiAssistant.getApplyCodeDiffButtons()).toHaveCount(0);
await expect(n8n.aiAssistant.getUndoReplaceCodeButtons()).toHaveCount(1);
await expect(n8n.aiAssistant.getCodeReplacedMessage()).toBeVisible();
await expect(n8n.ndv.getCodeEditor()).toContainText('item.json.myNewField = 1');
await n8n.aiAssistant.getUndoReplaceCodeButtons().first().click();
await expect(n8n.aiAssistant.getApplyCodeDiffButtons()).toHaveCount(1);
await expect(n8n.aiAssistant.getCodeReplacedMessage()).toHaveCount(0);
expect(applySuggestionCalls).toBe(1);
await expect(n8n.ndv.getCodeEditor()).toContainText('item.json.myNewField = 1aaa');
await n8n.aiAssistant.getApplyCodeDiffButtons().first().click();
await expect(n8n.ndv.getCodeEditor()).toContainText('item.json.myNewField = 1');
});
test('should ignore node execution success and error messages after the node run successfully once', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWorkflowBaseRequirements);
let chatRequestCount = 0;
await n8n.page.route('**/rest/ai/chat', async (route) => {
chatRequestCount += 1;
const response =
chatRequestCount === 1 ? codeDiffSuggestionResponse : nodeExecutionSucceededResponse;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
});
await n8n.canvas.openNode('Code');
await n8n.ndv.execute();
await n8n.aiAssistant.getNodeErrorViewAssistantButton().click();
await n8n.ndv
.getCodeEditor()
.fill(
"// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();",
);
await n8n.ndv.execute();
await n8n.ndv
.getCodeEditor()
.fill(
"// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1aaaa!;\n}\n\nreturn $input.all();",
);
await n8n.ndv.execute();
await expect(n8n.aiAssistant.getChatMessagesAssistant().nth(2)).toContainText(
'Code node ran successfully, did my solution help resolve your issue?',
);
});
});
});
@@ -0,0 +1,162 @@
import {
aiEnabledRequirements,
aiEnabledWithSimpleChatRequirements,
} from '../../../config/ai-assistant-fixtures';
import {
GMAIL_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe(
'AI Assistant::enabled',
{
annotation: [{ type: 'owner', description: 'AI' }],
},
() => {
test.describe('Credential Help', () => {
test('should start credential help from node credential', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithSimpleChatRequirements);
await n8n.page.goto('/workflow/new');
await n8n.canvas.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.addNode(GMAIL_NODE_NAME, { action: 'Get many messages', closeNDV: false });
await n8n.ndv.clickCreateNewCredential();
await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
const assistantButton = n8n.aiAssistant
.getCredentialEditAssistantButton()
.locator('button');
await expect(assistantButton).toBeVisible();
await assistantButton.click();
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toContainText(
'How do I set up the credentials for Gmail OAuth2 API?',
);
await expect(n8n.aiAssistant.getChatMessagesAssistant().first()).toContainText(
'Hey, this is an assistant message',
);
await expect(assistantButton).toBeDisabled();
});
test('should start credential help from credential list', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithSimpleChatRequirements);
await n8n.navigate.toCredentials();
await n8n.workflows.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
const assistantButton = n8n.aiAssistant
.getCredentialEditAssistantButton()
.locator('button');
await expect(assistantButton).toBeVisible();
await assistantButton.click();
await expect(n8n.aiAssistant.getChatMessagesUser()).toHaveCount(1);
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toContainText(
'How do I set up the credentials for Notion API?',
);
await expect(n8n.aiAssistant.getChatMessagesAssistant().first()).toContainText(
'Hey, this is an assistant message',
);
await expect(assistantButton).toBeDisabled();
});
test('should not show assistant button if click to connect', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.route('**/types/credentials.json', async (route) => {
const response = await route.fetch();
const credentials = (await response.json()) as Array<
{ name?: string } & Record<string, unknown>
>;
const index = credentials.findIndex((c) => c.name === 'slackOAuth2Api');
if (index >= 0) {
credentials[index] = {
...credentials[index],
__overwrittenProperties: ['clientId', 'clientSecret'],
};
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(credentials),
});
});
await n8n.page.goto('/workflow/new');
await n8n.canvas.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode('Slack', { action: 'Get a channel' });
await n8n.ndv.clickCreateNewCredential();
// Default is managed OAuth (click to connect) — no assistant button
await expect(n8n.canvas.credentialModal.oauthConnectButton).toHaveCount(1);
await expect(n8n.canvas.credentialModal.getCredentialInputs()).toHaveCount(2);
await expect(n8n.aiAssistant.getCredentialEditAssistantButton()).toHaveCount(0);
// Switch to custom OAuth via dropdown — assistant button should appear
await n8n.canvas.credentialModal.selectAuthTypeFromDropdown('Custom OAuth2');
await expect(n8n.canvas.credentialModal.getCredentialInputs()).toHaveCount(4);
await expect(n8n.aiAssistant.getCredentialEditAssistantButton()).toHaveCount(1);
});
test('should not show assistant button when click to connect with some fields', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledRequirements);
await n8n.page.route('**/types/credentials.json', async (route) => {
const response = await route.fetch();
const credentials = (await response.json()) as Array<
{ name?: string } & Record<string, unknown>
>;
const index = credentials.findIndex((c) => c.name === 'microsoftOutlookOAuth2Api');
if (index >= 0) {
credentials[index] = {
...credentials[index],
__overwrittenProperties: [
'authUrl',
'accessTokenUrl',
'clientId',
'clientSecret',
'graphApiBaseUrl',
],
};
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(credentials),
});
});
await n8n.page.goto('/workflow/new');
await n8n.canvas.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode('Microsoft Outlook', { action: 'Get a calendar' });
await n8n.ndv.clickCreateNewCredential();
await expect(n8n.canvas.credentialModal.oauthConnectButton).toHaveCount(1);
await expect(n8n.canvas.credentialModal.getCredentialInputs()).toHaveCount(2);
await expect(n8n.aiAssistant.getCredentialEditAssistantButton()).toHaveCount(0);
});
});
},
);
@@ -0,0 +1,118 @@
import {
simpleAssistantResponse,
aiEnabledWithCodeSnippetRequirements,
aiEnabledWithHttpWorkflowRequirements,
} from '../../../config/ai-assistant-fixtures';
import { HTTP_REQUEST_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
type ChatRequestBody = {
payload?: {
type?: string;
text?: string;
question?: string;
context?: Record<string, unknown>;
};
};
test.describe('AI Assistant::enabled', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.describe('Support Chat', () => {
test('assistant returns code snippet', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithCodeSnippetRequirements);
await n8n.page.goto('/workflow/new');
await expect(n8n.aiAssistant.getAskAssistantCanvasActionButton()).toBeVisible();
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await n8n.aiAssistant.sendMessage('Show me an expression');
await expect(n8n.aiAssistant.getChatMessagesAll()).toHaveCount(3);
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toContainText(
'Show me an expression',
);
await expect(n8n.aiAssistant.getChatMessagesAssistant().first()).toContainText(
'To use expressions in n8n, follow these steps:',
);
await expect(n8n.aiAssistant.getChatMessagesAssistant().first()).toContainText('New York');
await expect(n8n.aiAssistant.getCodeSnippet()).toHaveText('{{$json.body.city}}');
});
test('should send current context to support chat', async ({ n8n, setupRequirements }) => {
await setupRequirements(aiEnabledWithHttpWorkflowRequirements);
const chatRequests: ChatRequestBody[] = [];
await n8n.page.route('**/rest/ai/chat', async (route) => {
const body = route.request().postDataJSON() as ChatRequestBody;
chatRequests.push(body);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(simpleAssistantResponse),
});
});
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await n8n.aiAssistant.sendMessage('What is wrong with this workflow?');
const supportRequest = chatRequests.find(
(request) => request.payload?.question === 'What is wrong with this workflow?',
);
expect(supportRequest).toBeDefined();
const supportContext = supportRequest?.payload?.context;
expect(supportContext).toBeDefined();
expect(supportContext?.currentView).toBeDefined();
expect(supportContext?.currentWorkflow).toBeDefined();
});
test('should not send workflow context if nothing changed', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(aiEnabledWithHttpWorkflowRequirements);
const chatRequests: ChatRequestBody[] = [];
await n8n.page.route('**/rest/ai/chat', async (route) => {
const body = route.request().postDataJSON() as ChatRequestBody;
chatRequests.push(body);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(simpleAssistantResponse),
});
});
await n8n.aiAssistant.getAskAssistantCanvasActionButton().click();
await n8n.aiAssistant.sendMessage('What is wrong with this workflow?', 'enter-key');
// Wait for message to be processed
await expect(n8n.aiAssistant.getChatMessagesAssistant()).toHaveCount(1);
await n8n.aiAssistant.sendMessage('And now?', 'enter-key');
await expect(n8n.aiAssistant.getChatMessagesAssistant()).toHaveCount(2);
const secondRequest = chatRequests.find((request) => request.payload?.text === 'And now?');
const secondContext = secondRequest?.payload?.context;
expect(secondContext?.currentWorkflow).toBeUndefined();
await n8n.canvas.openNode(HTTP_REQUEST_NODE_NAME);
await n8n.ndv.setParameterInputValue('url', 'https://example.com');
await n8n.ndv.close();
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.aiAssistant.sendMessage('What about now?', 'enter-key');
await expect(n8n.aiAssistant.getChatMessagesAssistant()).toHaveCount(3);
const thirdRequest = chatRequests.find(
(request) => request.payload?.text === 'What about now?',
);
const thirdContext = thirdRequest?.payload?.context;
expect(thirdContext?.currentWorkflow).toBeTruthy();
expect(thirdContext?.executionData).toBeTruthy();
});
});
});
@@ -0,0 +1,39 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Chat session ID reset', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_chat_partial_execution.json');
await n8n.notifications.quickCloseAll();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deselectAll();
});
test('should update session ID in node output when session is reset', async ({ n8n }) => {
await n8n.canvas.logsPanel.open();
await n8n.canvas.logsPanel.sendManualChatMessage('Test message 1');
await expect(n8n.canvas.logsPanel.getManualChatMessages()).toHaveCount(2);
const initialSessionId = await n8n.canvas.logsPanel.getSessionId(n8n.clipboard);
await n8n.canvas.logsPanel.clickLogEntryAtRow(0);
await expect(n8n.canvas.logsPanel.outputPanel.getTbodyCell(0, 1)).toContainText(
initialSessionId,
);
await n8n.canvas.logsPanel.refreshSession();
await expect(n8n.canvas.logsPanel.getManualChatMessages()).not.toBeAttached();
// Step 5: Get the new session ID
const newSessionId = await n8n.canvas.logsPanel.getSessionId(n8n.clipboard);
expect(newSessionId).not.toEqual(initialSessionId);
await n8n.canvas.logsPanel.sendManualChatMessage('Test message 2');
await expect(n8n.canvas.logsPanel.getManualChatMessages()).toHaveCount(2);
// Verify the NEW session ID in the output panel matches the chat header session ID
await expect(n8n.canvas.logsPanel.outputPanel.getTbodyCell(0, 1)).toContainText(newSessionId);
});
});
@@ -0,0 +1,123 @@
import { expect, test } from '../../../fixtures/base';
test.use({ capability: 'proxy' });
test.describe(
'Evaluations @capability:proxy',
{
annotation: [{ type: 'owner', description: 'AI' }],
},
() => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await n8n.goHome();
});
// @AI team to look at this
test.fixme('should load evaluations workflow and execute twice', async ({ n8n, services }) => {
await services.proxy.loadExpectations('evaluations');
await n8n.api.credentials.createCredentialFromDefinition({
name: 'Test Google Sheets',
type: 'googleApi',
data: {
email: 'email@quickstart-1234.iam.gserviceaccount.com',
// mock private key
privateKey: `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDx1//AaoSkyHYl
npqS3+uaePYhJXKD/T1h6zGThAUooN7ZzWK46nNcU1vghQMTlPMHfUTbl4xzZxEL
OYjyTPOKpwJvhmy44MU+zTQYJuUaU4dQuOCnnC61CL91Xy+8GJd7PvdUeVRWENWu
zzO825Fxeiy2qnbrOJfhYh+f9znwWM2R8/V6LIp1HSWNBU0h/NCesmVhGwTP2H/P
wGgFPzl9+effW8TgmAukVuZoG+z8pOiqJnZLgTOO++PLyM6UJe560UnAbv0yP4y5
lZ370XwOQ6gVIiB0+8Z2A3tJp6ackfoMfDYbuU+CAhFPqkdvXgbrYciUCr6fzINo
ImK6CcSDAgMBAAECggEAF0+XokdiI7QC11tzUMbuocQZDVbbs+c7/G08KRjnmmPv
NxU599L5baPHTlvj0QZhao5jjbsM2a7MkMVp8tkB/JJehLtzTVq1CHmlFNLi8Geu
ulQnq2A9jEuckMatBjdkmoeWNXlAbM9QmXn1ZbXQThzVpIHH1qJs2Veo7rVYy1bD
+hnzadyeXsHOC518wNAaF3b1UShybI3dlrHbXqqRmkOZP272IKfmvZ2KOcnFC+MT
cWLUGWBTq2YK+UJv09OXHEBnonrm18m2Sku+/PhFwjOiifIK/1MWILss60IB7dFm
7Fe7NAtYQMPZyDEqY5Xo+K4FwWYzfxfHPiJf7k0DqQKBgQD5Rz+HCZC8V5c1oK8/
1hGthyh5JdXxW7C8D1WVuo7W2OHrOJSDXjGhsxMjnKYdq/1YybJl9XpQSvZeumto
YazNiJqAexIlpmEHLW5gDtX3xpM0dujuJudTHYfveugtR8i/EZpWpFKv45/6Rm33
Yt2PaMjLuO7yW0buEjSQInHtHwKBgQD4XW44YujgF+xvMmx8+QyyNI2UNI1ZmnsU
VZLmDAn5+WDz5YtBXN9JGIXIk5279S7xzu9xyq7Ih6uedxE/hmzaHSZ1gl9Xasci
n86FGaGPm6RtEeZ8c68oqha7kddLoBwTPBoZq5NaCCaTh2TQkMPg+Ws3erM0pkyC
fqw1hzkYHQKBgQC2Iv3i3/VV+DXupCqIXRRrkx7abe/FO3aF4jppfXdSugNQR/YT
imZ/PIXWdmXVtk4VasIjx1oIgs1C57kE+qE1SAODrujSg5/Pi71jCFQEh54VLnEB
WYGZ9DDXpRkxxIqEOQtpFQWpqIrCZmWA5Ub3uttEJyrIADNyTfEEA3b0hwKBgHrn
STbQA2t5iz/PlQ4W9GhvRyxzAQu5PXTnj+UVSg6QkKDBE7NJsRjr8LA8FE9B2nRA
sg7+fJWxRYUKaNelvtIEoNZ/qIyKw3Zn3HvTHjcBj1GGDSfC24fk+5Dgb8j1t07x
a/0OAcIIzIYu9v2a1cPLyXnP10STksL0ymVGwEMlAoGBAK2dtYZllhooN/C4ssFW
nmfqICLWEc/UZSxmxau1rOz71GJiiHgXFmQgiZtpf3Qp3wKKtoFkf+sJ6zP2VX35
2tJcTO9lKm6kNa3eaveE/NJrkH5a0IpxrvDT1TvmnapaNEKuGZJAX5BNaggDrfEJ
m82JpEptTfAxFHtd8+Sb0U2G
-----END PRIVATE KEY-----`,
},
});
await n8n.navigate.toWorkflow('new');
// Import the evaluations workflow
await n8n.canvas.importWorkflow('evaluations_loop.json', 'Evaluations');
// Open each node to ensure credentials are set
await n8n.canvas.openNode('When fetching a dataset row');
await n8n.page.keyboard.press('Escape');
// Open each node to ensure credentials are set
await n8n.canvas.openNode('Set outputs');
await n8n.page.keyboard.press('Escape');
// Execute workflow from canvas - first execution
await n8n.canvas.clickExecuteWorkflowButton();
// wait for first run to finish
await n8n.notifications.waitForNotificationAndClose('Successful', { timeout: 10000 });
// wait for second run to finish
await n8n.notifications.waitForNotificationAndClose('Successful', { timeout: 10000 });
// 💡 To update recordings, remove stored expectations, set real credentials above and rerecord here.
// await services.proxy.recordExpectations('evaluations', { host: 'google', dedupe: true });
const batchUpdateRequests = (await services.proxy.getAllRequestsMade()).filter((request) => {
const path = request.httpRequest?.path;
const method = request.httpRequest?.method;
return (
method === 'POST' && typeof path === 'string' && path.endsWith('/values:batchUpdate')
);
});
/**
* Original Table in Google Sheets
* The loop should execute twice over both rows here
* Incrementing each value by 1 (expression in Set Output node)
*
* name email actual
test test 10
hello wolrd 104
*/
// Set output node was called twice in a loop, updating Google sheets output value
expect(batchUpdateRequests.length).toEqual(2);
expect((batchUpdateRequests[0]?.httpRequest?.body as { json: object })?.json).toEqual({
data: [
{
range: 'Sheet2!C2',
values: [[11]],
},
],
valueInputOption: 'RAW',
});
expect((batchUpdateRequests[1]?.httpRequest?.body as { json: object })?.json).toEqual({
data: [
{
range: 'Sheet2!C3',
values: [[105]],
},
],
valueInputOption: 'RAW',
});
});
},
);
@@ -0,0 +1,147 @@
import {
AGENT_NODE_NAME,
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
AI_TOOL_CODE_NODE_NAME,
CHAT_TRIGGER_NODE_DISPLAY_NAME,
HITL_TOOL_SUBCATEGORY,
MANUAL_CHAT_TRIGGER_NODE_NAME,
} from '../../../config/constants';
import { expect, test } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
async function addOpenAILanguageModelWithCredentials(
n8n: n8nPage,
parentNode: string,
options: { exactMatch?: boolean; closeNDV?: boolean } = { exactMatch: true, closeNDV: false },
) {
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
parentNode,
options,
);
await n8n.credentialsComposer.createFromNdv({
apiKey: 'abcd',
});
await n8n.ndv.clickBackToCanvasButton();
}
async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 3000) {
await n8n.notifications.waitForNotificationAndClose('Workflow executed successfully', {
timeout,
});
}
async function setEditorText(n8n: n8nPage, parameterName: string, value: string) {
const codeEditor = n8n.ndv.getParameterInput(parameterName).locator('.cm-content');
await codeEditor.click();
await n8n.page.keyboard.press('ControlOrMeta+a');
await n8n.page.keyboard.press('Delete');
await codeEditor.fill(value);
}
const hitlForToolsTestConfig = {
capability: {
services: ['proxy'],
env: {
N8N_COMMUNITY_PACKAGES_ENABLED: 'false',
},
},
} as const;
test.use(hitlForToolsTestConfig);
test.describe('HITL for Tools @capability:proxy', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('hitl-for-tools');
await n8n.canvas.openNewWorkflow();
});
test('should add a HITL node between Agent and Tool node', async ({ n8n }) => {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
await addOpenAILanguageModelWithCredentials(n8n, AGENT_NODE_NAME);
await n8n.canvas.addSupplementalNodeToParent(
AI_TOOL_CODE_NODE_NAME,
'ai_tool',
AGENT_NODE_NAME,
{ closeNDV: true },
);
await n8n.canvas.dragNodeToRelativePosition(AI_TOOL_CODE_NODE_NAME, 100, 50);
const specificConnection = n8n.canvas.connectionBetweenNodes(
AI_TOOL_CODE_NODE_NAME,
AGENT_NODE_NAME,
);
await expect(specificConnection).toBeVisible();
// eslint-disable-next-line playwright/no-force-option
await specificConnection.hover({ force: true });
const addNodeButton = n8n.page.getByTestId('add-connection-button');
await expect(addNodeButton).toBeVisible();
await addNodeButton.click();
await n8n.canvas.clickNodeCreatorItemName(MANUAL_CHAT_TRIGGER_NODE_NAME);
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
await expect(n8n.canvas.nodeConnections()).toHaveCount(4);
});
test('should add a HITL tool node and run it', async ({ n8n }) => {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
await addOpenAILanguageModelWithCredentials(n8n, AGENT_NODE_NAME);
await n8n.canvas.addSupplementalNodeToParent(
MANUAL_CHAT_TRIGGER_NODE_NAME,
'ai_tool',
AGENT_NODE_NAME,
{ closeNDV: true, subcategory: HITL_TOOL_SUBCATEGORY, exactMatch: true },
);
await n8n.canvas.addSupplementalNodeToParent(
AI_TOOL_CODE_NODE_NAME,
'ai_tool',
MANUAL_CHAT_TRIGGER_NODE_NAME,
{ closeNDV: false },
);
await n8n.ndv.getParameterInput('description').locator('textarea').fill('Send email');
await setEditorText(n8n, 'jsCode', 'return "Email sent";');
await n8n.ndv.setParameterSwitch('specifyInputSchema', true);
await setEditorText(n8n, 'jsonSchemaExample', '{"receiver": "", "body": ""}');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.addNode(MANUAL_CHAT_TRIGGER_NODE_NAME, {
closeNDV: false,
action: 'Send a message',
fromNode: AGENT_NODE_NAME,
});
await n8n.ndv.openExpressionEditorModal('message');
await n8n.ndv.fillExpressionEditorModalInput('{{ $json.output }}');
await n8n.ndv.getExpressionEditorModalOutput().click();
await n8n.page.keyboard.press('Escape');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.openNode(CHAT_TRIGGER_NODE_DISPLAY_NAME);
await n8n.ndv.addParameterOptionByName('Response mode');
await n8n.ndv.selectOptionInParameterDropdown('responseMode', 'Using Response Nodes');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.clickManualChatButton();
await n8n.canvas.logsPanel.sendManualChatMessage('Send welcome email to john@gmail.com');
const approveButton = n8n.page.getByTestId('canvas-chat').getByText('Approve');
await expect(approveButton).toBeVisible({ timeout: 15000 });
await approveButton.click({ button: 'middle' });
await waitForWorkflowSuccess(n8n);
});
});
@@ -0,0 +1,246 @@
import {
AGENT_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
AI_MEMORY_REDIS_CHAT_NODE_NAME,
AI_TOOL_CALCULATOR_NODE_NAME,
AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME,
AI_TOOL_CODE_NODE_NAME,
AI_TOOL_WIKIPEDIA_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
TOOL_SUBCATEGORY,
} from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
// Helper functions for common operations
async function addOpenAILanguageModelWithCredentials(
n8n: n8nPage,
parentNode: string,
options: { exactMatch?: boolean; closeNDV?: boolean } = { exactMatch: true, closeNDV: false },
) {
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
parentNode,
options,
);
await n8n.credentialsComposer.createFromNdv({
apiKey: 'abcd',
});
await n8n.ndv.clickBackToCanvasButton();
}
async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 3000) {
await n8n.notifications.waitForNotificationAndClose('Workflow executed successfully', {
timeout,
});
}
async function executeChatAndWaitForResponse(n8n: n8nPage, message: string) {
await n8n.canvas.logsPanel.sendManualChatMessage(message);
await waitForWorkflowSuccess(n8n);
}
async function verifyChatMessages(n8n: n8nPage, expectedCount: number, inputMessage?: string) {
const messages = n8n.canvas.getManualChatMessages();
await expect(messages).toHaveCount(expectedCount);
if (inputMessage) {
await expect(messages.first()).toContainText(inputMessage);
}
await expect(messages.last()).toBeVisible();
return messages;
}
async function verifyLogsPanelEntries(n8n: n8nPage, expectedEntries: string[]) {
await expect(n8n.canvas.logsPanel.getLogEntries().first()).toBeVisible();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(expectedEntries.length);
for (let i = 0; i < expectedEntries.length; i++) {
await expect(n8n.canvas.logsPanel.getLogEntries().nth(i)).toHaveText(expectedEntries[i]);
}
}
async function setupBasicAgentWorkflow(n8n: n8nPage, additionalNodes: string[] = []) {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
// Add additional nodes if specified
for (const nodeName of additionalNodes) {
await n8n.canvas.addSupplementalNodeToParent(nodeName, 'ai_tool', AGENT_NODE_NAME, {
closeNDV: true,
});
}
// Always add OpenAI Language Model
await addOpenAILanguageModelWithCredentials(n8n, AGENT_NODE_NAME);
}
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
test.describe('Workflow Execution Behavior', () => {
test('should not open chat modal', async ({ n8n }) => {
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
AGENT_NODE_NAME,
{ exactMatch: true, closeNDV: true },
);
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getManualChatModal()).toBeHidden();
});
test('should remove test workflow button', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
AGENT_NODE_NAME,
{ exactMatch: true, closeNDV: true },
);
await n8n.canvas.disableNodeFromContextMenu(SCHEDULE_TRIGGER_NODE_NAME);
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeHidden();
});
});
test.describe('Node Connection and Configuration', () => {
test('should add nodes to all Agent node input types', async ({ n8n }) => {
const agentSubNodes = [
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
AI_MEMORY_REDIS_CHAT_NODE_NAME,
AI_TOOL_CALCULATOR_NODE_NAME,
AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME,
];
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: false });
await n8n.ndv.checkParameterCheckboxInputByName('hasOutputParser');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
AGENT_NODE_NAME,
{ exactMatch: true, closeNDV: true },
);
await n8n.canvas.addSupplementalNodeToParent(
AI_MEMORY_REDIS_CHAT_NODE_NAME,
'ai_memory',
AGENT_NODE_NAME,
{ closeNDV: true },
);
await n8n.canvas.addSupplementalNodeToParent(
AI_TOOL_CALCULATOR_NODE_NAME,
'ai_tool',
AGENT_NODE_NAME,
{ closeNDV: true, subcategory: TOOL_SUBCATEGORY },
);
await n8n.canvas.addSupplementalNodeToParent(
AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME,
'ai_outputParser',
AGENT_NODE_NAME,
{ closeNDV: true },
);
for (const nodeName of agentSubNodes) {
await expect(n8n.canvas.connectionBetweenNodes(nodeName, AGENT_NODE_NAME)).toBeAttached();
}
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2 + agentSubNodes.length); // Chat Trigger + Agent + 4 inputs
});
test('should add multiple tool nodes to Agent node tool input type', async ({ n8n }) => {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
const tools = [
{ name: AI_TOOL_CALCULATOR_NODE_NAME, subcategory: TOOL_SUBCATEGORY },
{ name: AI_TOOL_CODE_NODE_NAME },
{ name: AI_TOOL_CODE_NODE_NAME },
{ name: AI_TOOL_WIKIPEDIA_NODE_NAME, subcategory: TOOL_SUBCATEGORY },
];
for (const tool of tools) {
await n8n.canvas.addSupplementalNodeToParent(tool.name, 'ai_tool', AGENT_NODE_NAME, {
closeNDV: true,
subcategory: tool.subcategory,
});
await expect(n8n.canvas.connectionBetweenNodes(tool.name, AGENT_NODE_NAME)).toBeAttached();
}
// Chat Trigger + Agent + Tools
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2 + tools.length);
});
});
test.describe('Chat Execution and Interaction', () => {
test('should be able to open and execute Agent node', async ({ n8n }) => {
await setupBasicAgentWorkflow(n8n);
const inputMessage = 'Hello!';
await n8n.canvas.clickManualChatButton();
await executeChatAndWaitForResponse(n8n, inputMessage);
// Verify chat message appears
await expect(n8n.canvas.getManualChatLatestBotMessage()).toBeVisible();
});
test('should add and use Manual Chat Trigger node together with Agent node', async ({
n8n,
}) => {
await setupBasicAgentWorkflow(n8n);
const inputMessage = 'Hello!';
await n8n.canvas.clickManualChatButton();
await executeChatAndWaitForResponse(n8n, inputMessage);
await verifyChatMessages(n8n, 2, inputMessage);
await verifyLogsPanelEntries(n8n, [
'When chat message received',
'AI Agent',
'OpenAI Chat Model',
]);
await n8n.canvas.closeManualChatModal();
await expect(n8n.canvas.logsPanel.getLogEntries()).toBeHidden();
await expect(n8n.canvas.getManualChatInput()).toBeHidden();
});
});
test('should keep the same session when switching tabs', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_chat_partial_execution.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.logsPanel.open();
// Send a message
await n8n.canvas.logsPanel.sendManualChatMessage('Test');
await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field');
await n8n.canvas.clickExecutionsTab();
await n8n.canvas.clickEditorTab();
await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field');
// Refresh session
await n8n.canvas.logsPanel.refreshSession();
await expect(n8n.canvas.logsPanel.getManualChatMessages()).not.toBeAttached();
});
});
@@ -0,0 +1,122 @@
import {
AGENT_NODE_NAME,
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
BASIC_LLM_CHAIN_NODE_NAME,
CHAT_TRIGGER_NODE_DISPLAY_NAME,
MANUAL_CHAT_TRIGGER_NODE_NAME,
} from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
// Helper functions for common operations
async function addOpenAILanguageModelWithCredentials(
n8n: n8nPage,
parentNode: string,
options: { exactMatch?: boolean; closeNDV?: boolean } = { exactMatch: true, closeNDV: false },
) {
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
parentNode,
options,
);
await n8n.credentialsComposer.createFromNdv({
apiKey: 'abcd',
});
await n8n.ndv.clickBackToCanvasButton();
}
async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 3000) {
await n8n.notifications.waitForNotificationAndClose('Workflow executed successfully', {
timeout,
});
}
async function executeChatAndWaitForResponse(n8n: n8nPage, message: string) {
await n8n.canvas.logsPanel.sendManualChatMessage(message);
await waitForWorkflowSuccess(n8n);
}
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
test.describe('Auto-add Behavior', () => {
test('should auto-add chat trigger and basic LLM chain when adding LLM node', async ({
n8n,
}) => {
await n8n.canvas.addNode(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, { closeNDV: true });
await expect(
n8n.canvas.connectionBetweenNodes(
CHAT_TRIGGER_NODE_DISPLAY_NAME,
BASIC_LLM_CHAIN_NODE_NAME,
),
).toBeAttached();
await expect(
n8n.canvas.connectionBetweenNodes(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
BASIC_LLM_CHAIN_NODE_NAME,
),
).toBeAttached();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
test('should not auto-add nodes if AI nodes are already present', async ({ n8n }) => {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, { closeNDV: true });
await expect(
n8n.canvas.connectionBetweenNodes(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME),
).toBeAttached();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
test('should not auto-add nodes if ChatTrigger is already present', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_CHAT_TRIGGER_NODE_NAME, {
closeNDV: true,
trigger: 'On new Chat event',
});
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, { closeNDV: true });
await expect(
n8n.canvas.connectionBetweenNodes(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME),
).toBeAttached();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
});
test.describe('Chat Execution and Interaction', () => {
test('should be able to open and execute Basic LLM Chain node', async ({ n8n }) => {
await n8n.canvas.addNode(BASIC_LLM_CHAIN_NODE_NAME, { closeNDV: true });
await addOpenAILanguageModelWithCredentials(n8n, BASIC_LLM_CHAIN_NODE_NAME);
await n8n.canvas.openNode(BASIC_LLM_CHAIN_NODE_NAME);
const inputMessage = 'Hello!';
await n8n.ndv.execute();
await executeChatAndWaitForResponse(n8n, inputMessage);
// Verify chat message appears
await expect(n8n.canvas.getManualChatLatestBotMessage()).toBeVisible();
});
});
});
@@ -0,0 +1,181 @@
import {
AGENT_NODE_NAME,
AI_TOOL_CALCULATOR_NODE_NAME,
AI_MEMORY_POSTGRES_NODE_NAME,
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
MANUAL_CHAT_TRIGGER_NODE_NAME,
} from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
// Helper functions for common operations
async function addOpenAILanguageModelWithCredentials(
n8n: n8nPage,
parentNode: string,
options: { exactMatch?: boolean; closeNDV?: boolean } = { exactMatch: true, closeNDV: false },
) {
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
parentNode,
options,
);
await n8n.credentialsComposer.createFromNdv({
apiKey: 'abcd',
});
await n8n.ndv.clickBackToCanvasButton();
}
async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 3000) {
await n8n.notifications.waitForNotificationAndClose('Workflow executed successfully', {
timeout,
});
}
async function executeChatAndWaitForResponse(n8n: n8nPage, message: string) {
await n8n.canvas.logsPanel.sendManualChatMessage(message);
await waitForWorkflowSuccess(n8n);
}
async function verifyChatMessages(n8n: n8nPage, expectedCount: number, inputMessage?: string) {
const messages = n8n.canvas.getManualChatMessages();
await expect(messages).toHaveCount(expectedCount);
if (inputMessage) {
await expect(messages.first()).toContainText(inputMessage);
}
await expect(messages.last()).toBeVisible();
return messages;
}
test.use({ capability: 'proxy' });
test.describe(
'Langchain Integration @capability:proxy',
{
annotation: [{ type: 'owner', description: 'AI' }],
},
() => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
// Create a ticket for this for AI team to fix
test.describe('Error Handling and Logs Display', () => {
test.fixme();
// Helper function to set up the agent workflow with Postgres error configuration
async function setupAgentWorkflowWithPostgresError(n8n: n8nPage) {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
// Add Calculator Tool (required for OpenAI model)
await n8n.canvas.addSupplementalNodeToParent(
AI_TOOL_CALCULATOR_NODE_NAME,
'ai_tool',
AGENT_NODE_NAME,
{ closeNDV: true },
);
// Add and configure Postgres Memory
await n8n.canvas.addSupplementalNodeToParent(
AI_MEMORY_POSTGRES_NODE_NAME,
'ai_memory',
AGENT_NODE_NAME,
{ closeNDV: false },
);
await n8n.credentialsComposer.createFromNdv({
password: 'testtesttest',
});
await n8n.ndv.getParameterInput('sessionIdType').click();
await n8n.page.getByRole('option', { name: 'Define below' }).click();
await n8n.ndv.getParameterInput('sessionKey').locator('input').fill('asdasd');
await n8n.ndv.clickBackToCanvasButton();
// Add and configure OpenAI Language Model
await addOpenAILanguageModelWithCredentials(n8n, AGENT_NODE_NAME);
await n8n.canvas.clickZoomToFitButton();
}
// Helper function to assert logs tab is active
async function assertLogsTabIsActive(n8n: n8nPage) {
await expect(n8n.ndv.getOutputDataContainer()).toBeVisible();
await expect(n8n.ndv.getAiOutputModeToggle()).toBeVisible();
const radioButtons = n8n.ndv.getAiOutputModeToggle().locator('[role="radio"]');
await expect(radioButtons).toHaveCount(2);
await expect(radioButtons.nth(1)).toHaveAttribute('aria-checked', 'true');
}
// Helper function to assert error message is visible
async function assertErrorMessageVisible(n8n: n8nPage) {
await expect(
n8n.ndv.getOutputPanel().getByTestId('node-error-message').first(),
).toBeVisible();
await expect(
n8n.ndv.getOutputPanel().getByTestId('node-error-message').first(),
).toContainText('Error in sub-node');
}
test('should open logs tab by default when there was an error', async ({ n8n }) => {
await setupAgentWorkflowWithPostgresError(n8n);
const inputMessage = 'Test the code tool';
// Execute workflow with chat trigger
await n8n.canvas.clickManualChatButton();
await executeChatAndWaitForResponse(n8n, inputMessage);
// Check that messages and logs are displayed
const messages = await verifyChatMessages(n8n, 2, inputMessage);
await expect(messages.last()).toContainText(
'[ERROR: The service refused the connection - perhaps it is offline]',
);
await expect(n8n.canvas.logsPanel.getLogEntries().first()).toBeVisible();
await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(3);
await expect(n8n.canvas.logsPanel.getSelectedLogEntry()).toHaveText('AI Agent');
await expect(n8n.canvas.logsPanel.outputPanel.get()).toContainText(
AI_MEMORY_POSTGRES_NODE_NAME,
);
await n8n.canvas.closeManualChatModal();
// Open the AI Agent node to see the logs
await n8n.canvas.openNode(AGENT_NODE_NAME);
// Assert that logs tab is active and error is displayed
await assertLogsTabIsActive(n8n);
await assertErrorMessageVisible(n8n);
});
test('should switch to logs tab on error, when NDV is already opened', async ({ n8n }) => {
// Remove the auto-added chat trigger
await n8n.canvas.addNode(MANUAL_CHAT_TRIGGER_NODE_NAME, { closeNDV: false });
// Set manual trigger to output standard pinned data
await n8n.ndv.getEditPinnedDataButton().click();
await n8n.ndv.savePinnedData();
await n8n.ndv.close();
// Set up the same workflow components but with manual trigger
await setupAgentWorkflowWithPostgresError(n8n);
// Open the AI Agent node
await n8n.canvas.openNode(AGENT_NODE_NAME);
await n8n.ndv.getParameterInput('promptType').click();
await n8n.page.getByRole('option', { name: 'Define below' }).click();
await n8n.ndv.getParameterInput('text').locator('textarea').fill('Some text');
await n8n.ndv.execute();
await waitForWorkflowSuccess(n8n);
// Assert that logs tab is active and error is displayed
await assertLogsTabIsActive(n8n);
await assertErrorMessageVisible(n8n);
});
});
},
);
@@ -0,0 +1,114 @@
import {
AGENT_NODE_NAME,
AI_TOOL_CALCULATOR_NODE_NAME,
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
MANUAL_CHAT_TRIGGER_NODE_NAME,
} from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
// Helper functions for common operations
async function addOpenAILanguageModelWithCredentials(
n8n: n8nPage,
parentNode: string,
options: { exactMatch?: boolean; closeNDV?: boolean } = { exactMatch: true, closeNDV: false },
) {
await n8n.canvas.addSupplementalNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
'ai_languageModel',
parentNode,
options,
);
await n8n.credentialsComposer.createFromNdv({
apiKey: 'abcd',
});
await n8n.ndv.clickBackToCanvasButton();
}
async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 3000) {
await n8n.notifications.waitForNotificationAndClose('Workflow executed successfully', {
timeout,
});
}
async function executeChatAndWaitForResponse(n8n: n8nPage, message: string) {
await n8n.canvas.logsPanel.sendManualChatMessage(message);
await waitForWorkflowSuccess(n8n);
}
async function setupBasicAgentWorkflow(n8n: n8nPage, additionalNodes: string[] = []) {
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: true });
// Add additional nodes if specified
for (const nodeName of additionalNodes) {
await n8n.canvas.addSupplementalNodeToParent(nodeName, 'ai_tool', AGENT_NODE_NAME, {
closeNDV: true,
});
}
// Always add OpenAI Language Model
await addOpenAILanguageModelWithCredentials(n8n, AGENT_NODE_NAME);
}
test.use({ capability: 'proxy' });
test.describe(
'Langchain Integration @capability:proxy',
{
annotation: [{ type: 'owner', description: 'AI' }],
},
() => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
// @AI team to look at this
test.describe('Tool Usage Notifications', () => {
test.fixme();
test('should show tool info notice if no existing tools were used during execution', async ({
n8n,
}) => {
await setupBasicAgentWorkflow(n8n, [AI_TOOL_CALCULATOR_NODE_NAME]);
await n8n.canvas.openNode(AGENT_NODE_NAME);
const inputMessage = 'Hello!';
await n8n.ndv.execute();
await executeChatAndWaitForResponse(n8n, inputMessage);
await n8n.canvas.closeManualChatModal();
await n8n.canvas.openNode(AGENT_NODE_NAME);
await expect(n8n.ndv.getRunDataInfoCallout()).toBeVisible();
});
test('should not show tool info notice if tools were used during execution', async ({
n8n,
}) => {
await n8n.canvas.addNode(MANUAL_CHAT_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(AGENT_NODE_NAME, { closeNDV: false });
await expect(n8n.ndv.getRunDataInfoCallout()).toBeHidden();
await n8n.ndv.clickBackToCanvasButton();
await addOpenAILanguageModelWithCredentials(n8n, AGENT_NODE_NAME);
await n8n.canvas.addSupplementalNodeToParent(
AI_TOOL_CALCULATOR_NODE_NAME,
'ai_tool',
AGENT_NODE_NAME,
{ closeNDV: true },
);
const inputMessage = 'What is 1000 * 10?';
await n8n.canvas.clickManualChatButton();
await executeChatAndWaitForResponse(n8n, inputMessage);
await n8n.canvas.closeManualChatModal();
await n8n.canvas.openNode(AGENT_NODE_NAME);
await expect(n8n.ndv.getRunDataInfoCallout()).toBeHidden();
});
});
},
);
@@ -0,0 +1,109 @@
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
// Helper functions for common operations
async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 10000) {
await n8n.notifications.waitForNotificationAndClose('Workflow executed successfully', {
timeout,
});
}
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
test.describe('Advanced Workflow Features', () => {
test('should render runItems for sub-nodes and allow switching between them', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('In_memory_vector_store_fake_embeddings.json');
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deselectAll();
await n8n.canvas.executeNode('Populate VS');
await waitForWorkflowSuccess(n8n);
const assertInputOutputTextExists = async (text: string) => {
await expect(n8n.ndv.getOutputPanel()).toContainText(text);
await expect(n8n.ndv.getInputPanel()).toContainText(text);
};
const assertInputOutputTextNotExists = async (text: string) => {
await expect(n8n.ndv.getOutputPanel()).not.toContainText(text);
await expect(n8n.ndv.getInputPanel()).not.toContainText(text);
};
await n8n.canvas.openNode('Character Text Splitter');
await expect(n8n.ndv.getOutputRunSelector()).toBeVisible();
await expect(n8n.ndv.getInputRunSelector()).toBeVisible();
await expect(n8n.ndv.getInputRunSelectorInput()).toHaveValue('3 of 3');
await expect(n8n.ndv.getOutputRunSelectorInput()).toHaveValue('3 of 3');
await assertInputOutputTextExists('Kyiv');
await assertInputOutputTextNotExists('Berlin');
await assertInputOutputTextNotExists('Prague');
await n8n.ndv.changeOutputRunSelector('2 of 3');
await assertInputOutputTextExists('Berlin');
await assertInputOutputTextNotExists('Kyiv');
await assertInputOutputTextNotExists('Prague');
await n8n.ndv.changeOutputRunSelector('1 of 3');
await assertInputOutputTextExists('Prague');
await assertInputOutputTextNotExists('Berlin');
await assertInputOutputTextNotExists('Kyiv');
await n8n.ndv.toggleInputRunLinking();
await n8n.ndv.changeOutputRunSelector('2 of 3');
await expect(n8n.ndv.getInputRunSelectorInput()).toHaveValue('1 of 3');
await expect(n8n.ndv.getOutputRunSelectorInput()).toHaveValue('2 of 3');
await expect(n8n.ndv.getInputPanel()).toContainText('Prague');
await expect(n8n.ndv.getInputPanel()).not.toContainText('Berlin');
await expect(n8n.ndv.getOutputPanel()).toContainText('Berlin');
await expect(n8n.ndv.getOutputPanel()).not.toContainText('Prague');
await n8n.ndv.toggleInputRunLinking();
await expect(n8n.ndv.getInputRunSelectorInput()).toHaveValue('1 of 3');
await expect(n8n.ndv.getOutputRunSelectorInput()).toHaveValue('1 of 3');
await assertInputOutputTextExists('Prague');
await assertInputOutputTextNotExists('Berlin');
await assertInputOutputTextNotExists('Kyiv');
});
test('should execute up to Node 1 when using partial execution', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_chat_partial_execution.json');
await n8n.canvas.clickZoomToFitButton();
// Check that chat modal is not initially visible
await expect(n8n.canvas.getManualChatModal().getByTestId('canvas-chat-body')).toBeHidden();
// Open Node 1 and execute it
await n8n.canvas.openNode('Node 1');
await n8n.ndv.execute();
// Chat modal should now be visible
await expect(n8n.canvas.getManualChatModal().getByTestId('canvas-chat-body')).toBeVisible();
// Send first message
await n8n.canvas.logsPanel.sendManualChatMessage('Test');
await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field_1');
// Refresh session
await n8n.canvas.logsPanel.refreshSession();
await expect(n8n.canvas.logsPanel.getManualChatMessages()).not.toBeAttached();
// Send another message
await n8n.canvas.logsPanel.sendManualChatMessage('Another test');
await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field_3');
await expect(n8n.canvas.getManualChatLatestBotMessage()).toContainText('this_my_field_4');
});
});
});
@@ -0,0 +1,44 @@
import { test, expect } from '../../../fixtures/base';
test.describe('RAG callout experiment', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.describe('NDV callout', () => {
test('should show callout and open template on click', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode('Zep Vector Store', {
action: 'Add documents to vector store',
closeNDV: false,
});
await expect(n8n.canvas.getRagCalloutTip()).toBeVisible();
const popupPromise = n8n.page.waitForEvent('popup');
await n8n.canvas.clickRagTemplateLink();
const popup = await popupPromise;
expect(popup.url()).toContain('/workflows/templates/rag-starter-template?fromJson=true');
await popup.close();
});
});
test.describe('search callout', () => {
test('should show callout and open template on click', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar('rag');
const popupPromise = n8n.page.waitForEvent('popup');
await expect(n8n.canvas.getRagTemplateLink()).toBeVisible();
await n8n.canvas.clickRagTemplateLink();
const popup = await popupPromise;
expect(popup.url()).toContain('/workflows/templates/rag-starter-template?fromJson=true');
await popup.close();
});
});
});
@@ -0,0 +1,123 @@
import { workflowBuilderEnabledRequirements } from '../../../config/ai-builder-fixtures';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
// Helper to open workflow builder and click a specific suggestion pill
async function openBuilderAndClickSuggestion(n8n: n8nPage, suggestionText: string) {
await n8n.aiBuilder.getCanvasBuildWithAIButton().click();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await expect(n8n.aiBuilder.getWorkflowSuggestions()).toBeVisible();
// Wait for suggestions to load
await n8n.aiBuilder.getSuggestionPills().first().waitFor({ state: 'visible' });
// Find and click the specific suggestion pill by text
const targetPill = n8n.aiBuilder.getSuggestionPills().filter({ hasText: suggestionText });
await expect(targetPill).toBeVisible();
await targetPill.click();
// Suggestion pill already populated the input, just submit with Enter
await n8n.aiAssistant.sendMessage('', 'enter-key');
}
// Enable proxy server for recording/replaying Anthropic API calls
test.use({
capability: {
services: ['proxy'],
env: {
N8N_AI_ANTHROPIC_KEY: 'sk-ant-test-key-for-mocked-tests',
},
},
});
test.describe(
'Workflow Builder @auth:owner @ai @capability:proxy',
{
annotation: [{ type: 'owner', description: 'AI' }],
},
() => {
test.beforeEach(async ({ setupRequirements, services }) => {
await setupRequirements(workflowBuilderEnabledRequirements);
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('workflow-builder');
});
test('should show Build with AI button on empty canvas', async ({ n8n }) => {
await n8n.page.goto('/workflow/new');
await expect(n8n.aiBuilder.getCanvasBuildWithAIButton()).toBeVisible();
});
test('should open workflow builder and show suggestions', async ({ n8n }) => {
await n8n.page.goto('/workflow/new');
await n8n.aiBuilder.getCanvasBuildWithAIButton().click();
await expect(n8n.aiAssistant.getAskAssistantSidebar()).toBeVisible();
await expect(n8n.aiAssistant.getAskAssistantChat()).toBeVisible();
await expect(n8n.aiBuilder.getWorkflowSuggestions()).toBeVisible();
await n8n.aiBuilder.getSuggestionPills().first().waitFor({ state: 'visible' });
const suggestions = n8n.aiBuilder.getSuggestionPills();
await expect(suggestions).toHaveCount(8);
});
// @AI team - investigated issues with this test, the replay of recorded events not working as expected
// doesn't appear to be matching in the correct order/some requests make it past the proxy leading to 401 error
test.fixme('should build workflow from suggested prompt', async ({ n8n }) => {
await n8n.page.goto('/workflow/new');
await openBuilderAndClickSuggestion(n8n, 'YouTube video chapters');
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toBeVisible();
// Wait for workflow to be built
await n8n.aiBuilder.waitForWorkflowBuildComplete();
await expect(n8n.canvas.getCanvasNodes().first()).toBeVisible();
const nodeCount = await n8n.canvas.getCanvasNodes().count();
expect(nodeCount).toBeGreaterThan(0);
// Verify "Execute and refine" button appears after workflow is built
await expect(n8n.page.getByRole('button', { name: 'Execute and refine' })).toBeVisible();
});
// suffers from the same issue as test above
test.fixme('should display assistant messages during workflow generation', async ({ n8n }) => {
await n8n.page.goto('/workflow/new');
await openBuilderAndClickSuggestion(n8n, 'YouTube video chapters');
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toBeVisible();
await n8n.aiAssistant.waitForStreamingComplete();
const assistantMessages = n8n.aiAssistant.getChatMessagesAssistant();
await expect(assistantMessages.first()).toBeVisible();
const messageCount = await assistantMessages.count();
expect(messageCount).toBeGreaterThan(0);
});
test('should stop workflow generation and show task aborted message', async ({ n8n }) => {
await n8n.page.goto('/workflow/new');
await openBuilderAndClickSuggestion(n8n, 'Daily weather report');
await expect(n8n.aiAssistant.getChatMessagesUser().first()).toBeVisible();
// Wait for stop button to be enabled (streaming has started)
const stopButton = n8n.aiAssistant.getSendMessageButton();
await expect(stopButton).toBeEnabled({ timeout: 30000 });
await stopButton.click();
// Verify "Task aborted" message appears (search by text, not test-id)
await expect(n8n.page.getByText('Task aborted')).toBeVisible();
// Verify canvas returns to default state (no nodes added)
const nodeCount = await n8n.canvas.getCanvasNodes().count();
expect(nodeCount).toBe(0);
// Verify the Build with AI button is still visible (canvas is back to default)
await expect(n8n.aiBuilder.getCanvasBuildWithAIButton()).toBeVisible();
});
},
);
@@ -0,0 +1,41 @@
import { test, expect } from '../../../fixtures/base';
test.describe('External Webhook Triggering', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
api,
}) => {
const { webhookPath, workflowId } = await api.workflows.importWorkflowFromFile(
'simple-webhook-test.json',
);
const testPayload = { message: 'Hello from Playwright test' };
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
data: testPayload,
});
expect(webhookResponse.ok()).toBe(true);
const execution = await api.workflows.waitForExecution(workflowId, 5000);
expect(execution.status).toBe('success');
const executionDetails = await api.workflows.getExecution(execution.id);
expect(executionDetails.data).toContain('Hello from Playwright test');
});
test('should surface workflow configuration errors to the caller', async ({ api }) => {
const { webhookPath } = await api.workflows.importWorkflowFromFile(
'webhook-misconfiguration-test.json',
);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(false);
expect(await webhookResponse.text()).toContain('Unused Respond to Webhook node');
});
});
@@ -0,0 +1,45 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Webhook Origin Isolation', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeAll(async ({ api }) => {
await api.workflows.importWorkflowFromFile('webhook-origin-isolation.json', {
makeUnique: false,
});
});
const webhookPaths = [
'webhook-response-data-text-html',
'webhook-response-data-wo-content-type',
'webhook-last-node-no-content-type-header',
'webhook-last-node-text-html-header',
'webhook-last-node-text-html-content-type',
'webhook-response-data-csp-header',
'webhook-last-node-csp-header',
'webhook-last-node-binary-text-html',
'webhook-last-node-binary-no-content-type',
'webhook-last-node-binary-csp-header',
'respond-to-webhook-text-no-content-type',
'respond-to-webhook-text-content-type-text-html',
'respond-to-webhook-text-csp-header',
'respond-to-webhook-json-as-text-html',
];
const expectedCSP =
'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols';
for (const webhookPath of webhookPaths) {
test(`Webhook responses should include the correct response headers for ${webhookPath}`, async ({
api,
}) => {
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
const headers = webhookResponse.headers();
expect(headers['content-security-policy']).toBe(expectedCSP);
});
}
});
@@ -0,0 +1,51 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
import simpleWorkflow from '../../../workflows/Manual_wait_set.json';
import workflowWithPinned from '../../../workflows/Webhook_set_pinned.json';
const requirements: TestRequirements = {
config: {
settings: {
previewMode: true,
},
},
};
test.describe('Demo', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ setupRequirements }) => {
await setupRequirements(requirements);
});
test('can import template', async ({ n8n }) => {
await n8n.demo.goto();
expect(await n8n.notifications.getAllNotificationTexts()).toHaveLength(0);
await n8n.demo.importWorkflow(simpleWorkflow);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
});
test('can import workflow with pin data', async ({ n8n }) => {
await n8n.demo.goto();
await expect(n8n.canvas.canvasPane()).toBeVisible();
await n8n.demo.importWorkflow(workflowWithPinned);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await n8n.canvas.openNode('Webhook');
await expect(n8n.ndv.outputPanel.getTableHeaders().first()).toContainText('headers');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 3)).toContainText('dragons');
});
test('can override theme to dark', async ({ n8n }) => {
await n8n.demo.goto('dark');
await expect(n8n.demo.getBody()).toHaveAttribute('data-theme', 'dark');
expect(await n8n.notifications.getAllNotificationTexts()).toHaveLength(0);
});
test('can override theme to light', async ({ n8n }) => {
await n8n.demo.goto('light');
await expect(n8n.demo.getBody()).toHaveAttribute('data-theme', 'light');
expect(await n8n.notifications.getAllNotificationTexts()).toHaveLength(0);
});
});
@@ -0,0 +1,48 @@
import { test, expect } from '../../../fixtures/base';
test.describe
.serial('Environment Feature Flags', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', async ({
api,
}) => {
const setResponse = await api.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: 'true',
});
expect(setResponse.data.success).toBe(true);
expect(setResponse.data.message).toBe('Environment feature flags updated');
expect(setResponse.data.flags).toBeInstanceOf(Object);
expect(setResponse.data.flags['N8N_ENV_FEAT_TEST']).toBe('true');
const currentFlags = await api.getEnvFeatureFlags();
expect(currentFlags).toBeInstanceOf(Object);
expect(currentFlags.data['N8N_ENV_FEAT_TEST']).toBe('true');
});
test('should reset feature flags at runtime', async ({ api }) => {
const setResponse1 = await api.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: 'true',
});
expect(setResponse1.data.success).toBe(true);
expect(setResponse1.data.flags['N8N_ENV_FEAT_TEST']).toBe('true');
const clearResponse = await api.clearEnvFeatureFlags();
expect(clearResponse.data.success).toBe(true);
expect(clearResponse.data.flags).toBeInstanceOf(Object);
expect(clearResponse.data.flags['N8N_ENV_FEAT_TEST']).toBeUndefined();
const currentFlags = await api.getEnvFeatureFlags();
expect(currentFlags).toBeInstanceOf(Object);
expect(currentFlags.data['N8N_ENV_FEAT_TEST']).toBeUndefined();
});
});
@@ -0,0 +1,183 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const NOW = Date.now();
const ONE_DAY = 24 * 60 * 60 * 1000;
const THREE_DAYS = ONE_DAY * 3;
const SEVEN_DAYS = ONE_DAY * 7;
const ABOUT_SIX_MONTHS = ONE_DAY * 30 * 6 + ONE_DAY;
const ACTIVATED_USER_SETTINGS = {
userActivated: true,
userActivatedAt: NOW - THREE_DAYS - 1000,
};
const getNpsTestRequirements: TestRequirements = {
config: {
settings: {
telemetry: {
enabled: true,
},
},
},
intercepts: {
npsSurveyApi: {
url: '**/rest/user-settings/nps-survey',
response: { success: true },
},
telemetryTest: {
url: '**/test/telemetry',
response: { status: 'ok' },
},
telemetryProxy: {
url: '**/rest/telemetry/proxy',
response: { status: 'ok' },
},
telemetryRudderstack: {
url: '**/rest/telemetry/rudderstack',
response: { status: 'ok' },
},
},
};
test.fixme(
'NPS Survey',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.page.route('**/rest/login', async (route) => {
const response = await route.fetch();
const originalJson = await response.json();
const modifiedData = {
...originalJson,
data: {
...originalJson.data,
settings: {
...originalJson.data?.settings,
...ACTIVATED_USER_SETTINGS,
},
},
};
await route.fulfill({
status: response.status(),
headers: response.headers(),
contentType: 'application/json',
body: JSON.stringify(modifiedData),
});
});
await n8n.goHome();
});
test('shows nps survey to recently activated user and can submit feedback', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(getNpsTestRequirements);
await n8n.canvas.visitWithTimestamp(NOW);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeVisible();
expect(await n8n.npsSurvey.getRatingButtonCount()).toBe(11);
await n8n.npsSurvey.clickRating(0);
await n8n.npsSurvey.fillFeedback('n8n is the best');
await n8n.npsSurvey.clickSubmitButton();
await n8n.canvas.visitWithTimestamp(NOW + ONE_DAY);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + ABOUT_SIX_MONTHS);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeVisible();
});
test('allows user to ignore survey 3 times before stopping to show until 6 months later', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(getNpsTestRequirements);
await n8n.canvas.visitWithTimestamp(NOW);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.notifications.quickCloseAll();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeVisible();
await n8n.npsSurvey.closeSurvey();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + ONE_DAY);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + SEVEN_DAYS + 10000);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.notifications.quickCloseAll();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeVisible();
await n8n.npsSurvey.closeSurvey();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + SEVEN_DAYS + 10000);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + (SEVEN_DAYS + 10000) * 2 + ONE_DAY);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.notifications.quickCloseAll();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeVisible();
await n8n.npsSurvey.closeSurvey();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + (SEVEN_DAYS + 10000) * 2 + ONE_DAY * 2);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + (SEVEN_DAYS + 10000) * 3 + ONE_DAY * 3);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeHidden();
await n8n.canvas.visitWithTimestamp(NOW + (SEVEN_DAYS + 10000) * 3 + ABOUT_SIX_MONTHS);
// Add a node to trigger autosave (required for NPS survey to show)
await n8n.canvas.addNode('Manual Trigger');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.waitForSaveWorkflowCompleted();
await expect(n8n.npsSurvey.getNpsSurveyModal()).toBeVisible();
});
},
);
@@ -0,0 +1,216 @@
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
test.describe('Security Notifications', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
async function setupVersionsApiMock(
n8n: n8nPage,
options: {
hasSecurityIssue?: boolean;
hasSecurityFix?: boolean;
securityIssueFixVersion?: string;
} = {},
) {
const {
hasSecurityIssue = false,
hasSecurityFix = false,
securityIssueFixVersion = '',
} = options;
await n8n.page.route('**/api/versions/**', async (route) => {
// Extract current version from URL path
const url = route.request().url();
const currentVersion = url.split('/').pop() ?? '1.106.1';
// Parse version to create next version
const versionParts = currentVersion.split('.');
const nextPatchVersion = `${versionParts[0]}.${versionParts[1]}.${parseInt(versionParts[2]) + 1}`;
const mockVersions = [
{
name: currentVersion,
nodes: [],
createdAt: '2025-06-24T00:00:00Z',
description: hasSecurityIssue ? 'Current version with security issue' : 'Current version',
documentationUrl: 'https://docs.n8n.io',
hasBreakingChange: false,
hasSecurityFix: false,
hasSecurityIssue,
securityIssueFixVersion:
securityIssueFixVersion === 'useNextPatch' ? nextPatchVersion : securityIssueFixVersion,
},
{
name: nextPatchVersion,
nodes: [],
createdAt: '2025-06-25T00:00:00Z',
description: hasSecurityFix ? 'Fixed version' : 'Next version',
documentationUrl: 'https://docs.n8n.io',
hasBreakingChange: false,
hasSecurityFix,
hasSecurityIssue: false,
securityIssueFixVersion: '',
},
];
await route.fulfill({ json: mockVersions });
});
}
async function setupApiFailure(n8n: n8nPage) {
await n8n.page.route('**/api/versions/**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'API Error' }),
});
});
}
test.describe('Notifications disabled', () => {
test.beforeEach(async ({ setupRequirements }) => {
await setupRequirements({
config: {
settings: {
versionNotifications: {
enabled: false,
endpoint: 'https://test.api.n8n.io/api/versions/',
whatsNewEnabled: false,
whatsNewEndpoint: 'https://test.api.n8n.io/api/whats-new',
infoUrl: 'https://test.docs.n8n.io/hosting/installation/updating/',
},
},
},
});
});
test('should not check for versions if feature is disabled', async ({ n8n }) => {
// Track whether any API requests are made to versions endpoint
let versionsApiCalled = false;
await n8n.page.route('**/api/versions/**', () => {
versionsApiCalled = true;
});
await n8n.goHome();
// Wait a moment for any potential API calls or notifications
// eslint-disable-next-line playwright/no-networkidle
await n8n.page.waitForLoadState('networkidle');
// Verify no API request was made to versions endpoint when notifications are disabled
expect(versionsApiCalled).toBe(false);
});
});
test.describe('Notifications enabled', () => {
test.beforeEach(async ({ setupRequirements }) => {
await setupRequirements({
config: {
settings: {
versionNotifications: {
enabled: true,
endpoint: 'https://test.api.n8n.io/api/versions/',
whatsNewEnabled: true,
whatsNewEndpoint: 'https://test.api.n8n.io/api/whats-new',
infoUrl: 'https://test.docs.n8n.io/hosting/installation/updating/',
},
},
},
});
});
test('should display security notification with correct messaging and styling', async ({
n8n,
}) => {
await setupVersionsApiMock(n8n, { hasSecurityIssue: true, hasSecurityFix: true });
// Reload to trigger version check
await n8n.page.reload();
await n8n.goHome();
// Verify security notification appears with default message
const notification = n8n.notifications.getNotificationByTitle('Critical update available');
await expect(notification).toBeVisible();
await expect(notification).toContainText('Please update to latest version.');
await expect(notification).toContainText('More info');
// Verify warning styling
await expect(n8n.notifications.getWarningNotifications()).toBeVisible();
// Close the notification
await n8n.notifications.closeNotificationByText('Critical update available');
// Now test with specific fix version
await setupVersionsApiMock(n8n, {
hasSecurityIssue: true,
hasSecurityFix: true,
securityIssueFixVersion: 'useNextPatch',
});
// Reload to trigger new version check with fix version
await n8n.goHome();
// Verify notification shows specific fix version (dynamically generated)
const notificationWithFixVersion = n8n.notifications.getNotificationByTitle(
'Critical update available',
);
await expect(notificationWithFixVersion).toBeVisible();
await expect(notificationWithFixVersion).toContainText('Please update to version');
await expect(notificationWithFixVersion).toContainText('or higher.');
});
test('should open versions modal when clicking security notification', async ({ n8n }) => {
await setupVersionsApiMock(n8n, {
hasSecurityIssue: true,
hasSecurityFix: true,
securityIssueFixVersion: 'useNextPatch',
});
await n8n.goHome();
// Wait for and click the security notification
const notification = n8n.notifications.getNotificationByTitle('Critical update available');
await expect(notification).toBeVisible();
await notification.click();
// Verify versions modal opens
const versionsModal = n8n.versions.getVersionUpdatesPanel();
await expect(versionsModal).toBeVisible();
// Verify security update badge exists for the new version
const versionCard = n8n.versions.getVersionCard().first();
const securityBadge = versionCard.locator('.el-tag--danger').getByText('Security update');
await expect(securityBadge).toBeVisible();
});
test('should not display security notification when theres no security issue', async ({
n8n,
}) => {
await setupVersionsApiMock(n8n, { hasSecurityIssue: false });
await n8n.goHome();
// Verify no security notification appears when no security issue
const notification = n8n.notifications.getNotificationByTitle('Critical update available');
await expect(notification).toBeHidden();
});
test('should handle API failure gracefully', async ({ n8n }) => {
// Enable notifications but mock API failure
await setupApiFailure(n8n);
await n8n.goHome();
const { projectId } = await n8n.projectComposer.createProject();
await n8n.page.goto(`projects/${projectId}/workflows`);
// Verify no security notification appears on API failure
const notification = n8n.notifications.getNotificationByTitle('Critical update available');
await expect(notification).toBeHidden();
// Verify the app still functions normally
await expect(n8n.workflows.getProjectName()).toBeVisible();
});
});
});
@@ -0,0 +1,72 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const requirements: TestRequirements = {
config: {
settings: {
releaseChannel: 'stable',
versionCli: '1.0.0',
versionNotifications: {
enabled: true,
endpoint: 'https://api.n8n.io/api/versions/',
whatsNewEnabled: true,
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
infoUrl: 'https://docs.n8n.io/getting-started/installation/updating.html',
},
},
},
intercepts: {
versions: {
url: '**/api/versions/**',
response: [
{
name: '1.0.0',
nodes: [],
createdAt: '2025-06-01T00:00:00Z',
description: 'Current version',
documentationUrl: 'https://docs.n8n.io',
hasBreakingChange: false,
hasSecurityFix: false,
hasSecurityIssue: false,
securityIssueFixVersion: '',
},
{
name: '1.0.1',
nodes: [],
createdAt: '2025-06-15T00:00:00Z',
description: 'Version 1.0.1',
documentationUrl: 'https://docs.n8n.io',
hasBreakingChange: false,
hasSecurityFix: false,
hasSecurityIssue: false,
securityIssueFixVersion: '',
},
{
name: '1.0.2',
nodes: [],
createdAt: '2025-06-30T00:00:00Z',
description: 'Version 1.0.2',
documentationUrl: 'https://docs.n8n.io',
hasBreakingChange: false,
hasSecurityFix: false,
hasSecurityIssue: false,
securityIssueFixVersion: '',
},
],
},
},
};
test.describe('Versions', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should show updates in help section', async ({ n8n, setupRequirements }) => {
await setupRequirements(requirements);
await n8n.goHome();
await n8n.sideBar.expand();
await n8n.sideBar.clickHelpMenuItem();
await expect(n8n.sideBar.getVersionUpdateItem()).toContainText('Update (2 versions behind)');
});
});
@@ -0,0 +1,21 @@
import { test, expect } from '../../../fixtures/base';
test.describe(
'Admin user',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test('should see same Settings sub menu items as instance owner', async ({ n8n }) => {
await n8n.api.setupTest('signin-only', 'owner');
await n8n.settingsPersonal.gotoSettings();
const ownerMenuItems = await n8n.settingsPersonal.getMenuItems().count();
await n8n.api.setupTest('signin-only', 'admin');
await n8n.settingsPersonal.gotoSettings();
await expect(n8n.settingsPersonal.getMenuItems()).toHaveCount(ownerMenuItems);
});
},
);
@@ -0,0 +1,22 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Authentication', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
const testCases = [
{ role: 'default', expectedUrl: /\/workflow/, auth: '' },
{ role: 'owner', expectedUrl: /\/workflow/, auth: '@auth:owner' },
{ role: 'admin', expectedUrl: /\/workflow/, auth: '@auth:admin' },
{ role: 'member', expectedUrl: /\/workflow/, auth: '@auth:member' },
{ role: 'none', expectedUrl: /\/signin/, auth: '@auth:none' },
];
for (const { role, expectedUrl, auth } of testCases) {
test(`${role} authentication ${auth}`, async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(expectedUrl);
});
}
});
@@ -0,0 +1,35 @@
import { test, expect } from '../../../fixtures/base';
test.use({
capability: 'oidc',
ignoreHTTPSErrors: true, // Keycloak uses self-signed certs
});
test.describe('OIDC Authentication @capability:oidc', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test('should configure OIDC and login with Keycloak @auth:owner', async ({
n8n,
api,
services,
}) => {
const keycloak = services.keycloak;
await api.enableFeature('oidc');
await n8n.oidcComposer.configureOidc(
keycloak.internalDiscoveryUrl,
keycloak.clientId,
keycloak.clientSecret,
);
await n8n.sideBar.signOutFromWorkflows();
await n8n.page.waitForURL('/signin');
await n8n.signIn.getSsoButton().click();
await n8n.keycloakLogin.login(keycloak.testUser.email, keycloak.testUser.password);
await expect(n8n.page).toHaveURL(/\/(workflow|home)/);
await expect(n8n.sideBar.getSettings()).toBeVisible();
});
});
@@ -0,0 +1,21 @@
import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'email' });
test('Password reset email is delivered @capability:email', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, async ({ api, services }) => {
const ownerEmail = 'nathan@n8n.io';
const res = await api.request.post('/rest/forgot-password', {
data: { email: ownerEmail },
});
expect(res.ok()).toBeTruthy();
const msg = await services.mailpit.waitForMessage({
to: ownerEmail,
subject: /password reset/i,
});
expect(msg).toBeTruthy();
});
@@ -0,0 +1,23 @@
import { INSTANCE_OWNER_CREDENTIALS } from '../../../config/test-users';
import { test, expect } from '../../../fixtures/base';
test.describe(
'Sign In',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test('should login and logout @auth:none', async ({ n8n }) => {
await n8n.goHome();
await n8n.signIn.goto();
await n8n.signIn.loginWithEmailAndPassword(
INSTANCE_OWNER_CREDENTIALS.email,
INSTANCE_OWNER_CREDENTIALS.password,
);
await expect(n8n.sideBar.getSettings()).toBeVisible();
});
},
);
@@ -0,0 +1,98 @@
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
} from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe('Canvas Node Actions', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test.describe('Node Search and Add', () => {
test('should search and add a basic node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME)).toBeVisible();
});
test('should search and add Linear node with action', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode('Linear', { action: 'Create an issue' });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await expect(n8n.canvas.nodeByName('Create an issue')).toBeVisible();
});
test('should search and add Webhook node (no actions)', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeByName('Webhook')).toBeVisible();
});
test('should search and add Jira node with trigger', async ({ n8n }) => {
await n8n.canvas.addNode('Jira Software', { trigger: 'On issue created' });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeByName('Jira Trigger')).toBeVisible();
});
test('should clear search and show all nodes', async ({ n8n }) => {
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar('Linear');
const searchCount = await n8n.canvas.nodeCreatorNodeItems().count();
await expect(n8n.canvas.nodeCreatorNodeItems()).toHaveCount(1);
await n8n.canvas.nodeCreatorSearchBar().clear();
const nodeCount = await n8n.canvas.nodeCreatorNodeItems().count();
expect(nodeCount).toBeGreaterThan(searchCount);
});
test('should add connected node via 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');
await n8n.page.keyboard.press('Enter');
await n8n.canvas.clickNodeCreatorItemName('Code in JavaScript');
await n8n.page.keyboard.press('Enter');
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should add disconnected node when nothing selected', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.deselectAll();
await n8n.canvas.addNode('Code', { action: 'Code in JavaScript', closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
});
});
test.describe('Node Creator Interactions', () => {
test('should close node creator with escape key', async ({ n8n }) => {
await n8n.canvas.clickCanvasPlusButton();
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeHidden();
});
test('should filter nodes by search term', async ({ n8n }) => {
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar('HTTP');
const filteredItems = n8n.canvas.nodeCreatorNodeItems();
await expect(filteredItems.first()).toContainText('HTTP');
});
});
});
@@ -0,0 +1,97 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.describe('Credentials', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('composer: createFromList creates credential', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const credentialName = `credential-${nanoid()}`;
await n8n.navigate.toCredentials(projectId);
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: '1234567890' },
{
name: credentialName,
closeDialog: false,
},
);
await expect(n8n.credentials.cards.getCredential(credentialName)).toBeVisible();
});
test('composer: createFromNdv creates credential for node', async ({ n8n }) => {
const name = `credential-${nanoid()}`;
await n8n.start.fromNewProjectBlankCanvas();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
await n8n.credentialsComposer.createFromNdv({ apiKey: '1234567890' }, { name });
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(name);
});
test('composer: createFromApi creates credential (then NDV picks it up)', async ({ n8n }) => {
const name = `credential-${nanoid()}`;
const projectId = await n8n.start.fromNewProjectBlankCanvas();
await n8n.credentialsComposer.createFromApi({
name,
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId,
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(name);
});
test('create a new credential from empty state using the credential chooser list', async ({
n8n,
}) => {
const projectId = await n8n.start.fromNewProject();
await n8n.navigate.toCredentials(projectId);
await n8n.credentials.emptyListCreateCredentialButton.click();
await n8n.credentials.createCredentialFromCredentialPicker('Notion API', {
apiKey: '1234567890',
});
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
});
test('create a new credential from the NDV', async ({ n8n }) => {
const uniqueCredentialName = `credential-${nanoid()}`;
await n8n.start.fromNewProjectBlankCanvas();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
await n8n.ndv.getNodeCredentialsSelect().click();
await n8n.ndv.credentialDropdownCreateNewCredential().click();
await n8n.canvas.credentialModal.addCredential(
{
apiKey: '1234567890',
},
{ name: uniqueCredentialName },
);
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(uniqueCredentialName);
});
test('add an existing credential from the NDV', async ({ n8n }) => {
const uniqueCredentialName = `credential-${nanoid()}`;
const projectId = await n8n.start.fromNewProjectBlankCanvas();
await n8n.api.credentials.createCredential({
name: uniqueCredentialName,
type: 'notionApi',
data: {
apiKey: '1234567890',
},
projectId,
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(uniqueCredentialName);
});
});
@@ -0,0 +1,92 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Node Details Configuration', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should configure webhook node', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'POST',
path: 'test-webhook',
authentication: 'Basic Auth',
});
await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('test-webhook');
});
test('should configure HTTP Request node', async ({ n8n }) => {
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.setupHelper.httpRequest({
method: 'POST',
url: 'https://api.example.com/test',
sendQuery: true,
sendHeaders: false,
});
await expect(n8n.ndv.getParameterInputField('url')).toHaveValue('https://api.example.com/test');
});
test('should auto-detect parameter types', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.setParameter('httpMethod', 'PUT');
await n8n.ndv.setupHelper.setParameter('path', 'auto-detect-test');
await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('auto-detect-test');
});
test('should use explicit types for better performance', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.setParameter('httpMethod', 'PATCH', 'dropdown');
await n8n.ndv.setupHelper.setParameter('path', 'explicit-types', 'text');
await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('explicit-types');
});
test('should configure Edit Fields node with single field', async ({ n8n }) => {
await n8n.canvas.addNode('Edit Fields (Set)');
await n8n.ndv.editFields.setSingleFieldValue('testField', 'string', 'Hello World');
const nameInput = n8n.ndv.getAssignmentName('assignments', 0).getByRole('textbox');
await expect(nameInput).toHaveValue('testField');
});
test('should configure Edit Fields node with multiple fields', async ({ n8n }) => {
await n8n.canvas.addNode('Edit Fields (Set)');
await n8n.ndv.editFields.setFieldsValues([
{ name: 'stringField', type: 'string', value: 'Test String' },
{ name: 'numberField', type: 'number', value: 123 },
{ name: 'booleanField', type: 'boolean', value: true },
]);
await expect(
n8n.ndv.getAssignmentCollectionContainer('assignments').getByTestId('assignment'),
).toHaveCount(3);
});
test('should configure Edit Fields node with all field types', async ({ n8n }) => {
await n8n.canvas.addNode('Edit Fields (Set)');
await n8n.ndv.editFields.setFieldsValues([
{ name: 'myString', type: 'string', value: 'Hello' },
{ name: 'myNumber', type: 'number', value: 42 },
{ name: 'myBoolean', type: 'boolean', value: false },
{ name: 'myArray', type: 'array', value: '["item1", "item2"]' },
]);
await expect(
n8n.ndv.getAssignmentCollectionContainer('assignments').getByTestId('assignment'),
).toHaveCount(4);
});
});
@@ -0,0 +1,139 @@
import { nanoid } from 'nanoid';
import { expect, test } from '../../../fixtures/base';
test.describe('User API Service', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.describe('Internal API (Cookie Auth)', () => {
test('should create a user with default values', async ({ api }) => {
const user = await api.users.create();
expect(user.email).toContain('testuser');
expect(user.email).toContain('@test.com');
expect(user.firstName).toBe('Test');
expect(user.lastName).toContain('User');
expect(user.role).toContain('member');
});
test('should create a user with custom values', async ({ api }) => {
const customEmail = `custom-${nanoid()}@test.com`;
const customPassword = 'CustomPass123!';
const user = await api.users.create({
email: customEmail,
password: customPassword,
firstName: 'John',
lastName: 'Doe',
role: 'global:member',
});
expect(user.email.toLowerCase()).toBe(customEmail.toLowerCase());
expect(user.firstName).toBe('John');
expect(user.lastName).toBe('Doe');
expect(user.role).toContain('member');
});
test('should create a member user by default', async ({ api }) => {
const user = await api.users.create();
expect(user.role).toContain('member');
expect(user.role).toBe('global:member');
});
test('should maintain separate sessions for multiple users', async ({ n8n, api }) => {
await n8n.navigate.toPersonalSettings();
const user = await api.users.create();
await n8n.page.reload();
await expect(n8n.settingsPersonal.getUserRole()).toHaveText('Owner');
// New user page should have test name
const memberN8n = await n8n.start.withUser(user);
await memberN8n.navigate.toPersonalSettings();
await expect(memberN8n.settingsPersonal.getUserRole()).toHaveText('Member');
// n8n main should still have owner context
await n8n.page.reload();
await expect(n8n.settingsPersonal.getUserRole()).toHaveText('Owner');
// user page should still have member role
await memberN8n.page.reload();
await expect(memberN8n.settingsPersonal.getUserRole()).toHaveText('Member');
});
});
test.describe('Public API (API Key Auth)', () => {
test('should create an API key', async ({ api }) => {
const label = `Test Key ${nanoid()}`;
const apiKey = await api.publicApi.createApiKey(label);
expect(apiKey.label).toBe(label);
expect(apiKey.rawApiKey).toBeDefined();
expect(apiKey.rawApiKey.length).toBeGreaterThan(0);
});
test('should create a user via public API', async ({ api }) => {
const user = await api.publicApi.createUser({
email: `public-api-user-${nanoid()}@test.com`,
firstName: 'Public',
lastName: 'ApiUser',
});
expect(user.email).toContain('public-api-user');
expect(user.firstName).toBe('Public');
expect(user.lastName).toBe('ApiUser');
expect(user.role).toBe('global:member');
});
test('should list users via public API', async ({ api }) => {
// Create a user first
await api.publicApi.createUser({
email: `list-test-user-${nanoid()}@test.com`,
});
const users = await api.publicApi.getUsers({ includeRole: true });
expect(users.length).toBeGreaterThan(0);
// Should have at least the owner
const owner = users.find((u) => u.role === 'global:owner');
expect(owner).toBeDefined();
});
test('should create multiple users and maintain separate sessions', async ({ n8n, api }) => {
// Create users via public API
const user1 = await api.publicApi.createUser({
email: `multi-user-1-${nanoid()}@test.com`,
firstName: 'User',
lastName: 'One',
});
const user2 = await api.publicApi.createUser({
email: `multi-user-2-${nanoid()}@test.com`,
firstName: 'User',
lastName: 'Two',
});
// Owner should see their settings
await n8n.navigate.toPersonalSettings();
await expect(n8n.settingsPersonal.getUserRole()).toHaveText('Owner');
// Create isolated browser contexts for each user
const user1N8n = await n8n.start.withUser(user1);
const user2N8n = await n8n.start.withUser(user2);
// Verify each user sees their own context
await user1N8n.navigate.toPersonalSettings();
await expect(user1N8n.settingsPersonal.getUserRole()).toHaveText('Member');
await user2N8n.navigate.toPersonalSettings();
await expect(user2N8n.settingsPersonal.getUserRole()).toHaveText('Member');
// Owner should still be owner after all this
await n8n.page.reload();
await expect(n8n.settingsPersonal.getUserRole()).toHaveText('Owner');
});
});
});
@@ -0,0 +1,52 @@
import { test, expect } from '../../../fixtures/base';
test.describe('UI Test Entry Points', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.describe('Entry Point: Home Page', () => {
test('should navigate from home', async ({ n8n }) => {
await n8n.start.fromHome();
expect(n8n.page.url()).toContain('/home/workflows');
});
});
test.describe('Entry Point: Blank Canvas', () => {
test('should navigate from blank canvas', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
test.describe('Entry Point: Basic Workflow Creation', () => {
test('should create a new project and workflow', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
test.describe('Entry Point: Imported Workflow', () => {
test('should import a webhook workflow', async ({ n8n }) => {
const workflowImportResult = await n8n.start.fromImportedWorkflow('simple-webhook-test.json');
const { webhookPath } = workflowImportResult;
const testPayload = { message: 'Hello from Playwright test' };
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
const webhookResponse = await n8n.page.request.post(`/webhook-test/${webhookPath}`, {
data: testPayload,
});
expect(webhookResponse.ok()).toBe(true);
});
test('should import a workflow', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('manual.json');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Success');
await expect(n8n.canvas.canvasPane()).toBeVisible();
});
});
});
@@ -0,0 +1,82 @@
import assert from 'node:assert';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'proxy' });
// @capability:proxy tag ensures that test suite is only run when proxy is available
test.describe('Proxy server @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ services }) => {
await services.proxy.clearAllExpectations();
});
test('should verify ProxyServer container is running', async ({ services }) => {
const mockResponse = await services.proxy.createGetExpectation('/health', {
status: 'healthy',
});
assert(typeof mockResponse !== 'string');
expect(mockResponse.statusCode).toBe(201);
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/health' })).toBe(false);
// Verify the mock endpoint works
const healthResponse = await fetch(`${services.proxy.url}/health`);
expect(healthResponse.ok).toBe(true);
const healthData = await healthResponse.json();
expect(healthData.status).toBe('healthy');
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/health' })).toBe(true);
});
test('should run a simple workflow calling http endpoint', async ({ n8n, services }) => {
const mockResponse = { data: 'Hello from ProxyServer!', test: '1' };
// Create expectation in mockserver to handle the request
await services.proxy.createGetExpectation('/data', mockResponse, { test: '1' });
await n8n.canvas.openNewWorkflow();
// This is calling a random endpoint http://mock-api.com
await n8n.canvas.importWorkflow('Simple_workflow_with_http_node.json', 'Test');
// Execute workflow - this should now proxy through mockserver
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
await n8n.canvas.openNode('HTTP Request');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('Hello from ProxyServer!');
// Verify the request was handled by mockserver
expect(
await services.proxy.wasRequestMade({
method: 'GET',
path: '/data',
queryStringParameters: { test: ['1'] },
}),
).toBe(true);
});
test('should use stored expectations respond to api request', async ({ services }) => {
await services.proxy.loadExpectations('proxy-server');
const response = await fetch(`${services.proxy.url}/mock-endpoint`);
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.title).toBe('delectus aut autem');
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/mock-endpoint' })).toBe(
true,
);
});
test('should run a simple workflow proxying HTTPS request', async ({ n8n }) => {
await n8n.canvas.openNewWorkflow();
await n8n.canvas.importWorkflow('Simple_workflow_with_http_node.json', 'Test');
await n8n.canvas.openNode('HTTP Request');
await n8n.ndv.setParameterInput('url', 'https://jsonplaceholder.typicode.com/todos/1');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('1');
});
});
@@ -0,0 +1,38 @@
import { CODE_NODE_NAME, MANUAL_TRIGGER_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
/**
* Task Runner Tests
*
* Task runner is always enabled in all container stacks.
* These tests verify code execution functionality.
*/
test.describe('Task Runner', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('should execute Javascript with task runner enabled', 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', closeNDV: true });
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
test('should execute Python with task runner enabled', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(CODE_NODE_NAME, {
action: 'Code in Python',
closeNDV: true,
});
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
});
@@ -0,0 +1,105 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
test.use(chatHubTestConfig);
test.describe('File attachment @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
let tmpDir: string;
let testImagePath: string;
let testTextPath: string;
test.beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-hub-test-'));
testImagePath = path.join(tmpDir, 'test-image.png');
testTextPath = path.join(tmpDir, 'test-file.txt');
// 100x100 solid red square PNG
const pngBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAkElEQVR42u3QMQ0AAAjAsPk3DRb4eJpUQZviSIEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFnvFp4t6yugc3LNAAAAAElFTkSuQmCC',
'base64',
);
await fs.writeFile(testImagePath, pngBuffer);
// Simple text file
await fs.writeFile(testTextPath, 'I am a file');
});
test.afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test('image attachment', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // auto-select a model
await page.getFileInput().setInputFiles(testImagePath);
await page.getChatInput().fill('What color is this image? Reply with just the color name.');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What color is this image?');
await expect(page.getAttachmentsAt(0)).toHaveCount(1);
await expect(page.getAttachmentsAt(0).nth(0)).toBeVisible();
await expect(page.getChatMessages().nth(1)).toContainText(/red/i);
// Verify image is persisted and can be opened in new tab
await n8n.page.reload();
const newPage = await page.openAttachmentAt(0, 0);
await expect(newPage.locator('img')).toBeVisible();
await expect(newPage.locator('img')).toHaveJSProperty('naturalWidth', 100);
await expect(newPage.locator('img')).toHaveJSProperty('naturalHeight', 100);
await newPage.close();
});
test('text file attachment', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i);
await page.getFileInput().setInputFiles(testTextPath);
await page.getChatInput().fill('What is the exact content of this file?');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What is the exact content');
await expect(page.getAttachmentsAt(0)).toHaveCount(1);
await expect(page.getChatMessages().nth(1)).toContainText('I am a file');
});
test('reference attachment in subsequent message', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i);
// Send initial message with attachment
await page.getFileInput().setInputFiles(testImagePath);
await page.getChatInput().fill('What color is this image? Reply with just the color name.');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What color is this image?');
await expect(page.getChatMessages().nth(1)).toContainText(/red/i);
// Send follow-up question referencing the image
await page.getChatInput().fill('Does the image include text? Reply just yes or no.');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(2)).toContainText('Does the image include text?');
await expect(page.getChatMessages().nth(3)).toContainText(/no/i);
});
});
@@ -0,0 +1,107 @@
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { CredentialModal } from '../../../pages/components/CredentialModal';
test.use(chatHubTestConfig);
test.describe('Basic conversation @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('new chat with pre-configured credentials', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getGreetingMessage()).toContainText('Start a chat with');
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // pre-selected
await page.getChatInput().fill('Hello');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('Hello');
await expect(page.getChatMessages().nth(1)).toContainText('Hello! How can I help you today?');
await expect(page.sidebar.getConversations().first()).toHaveAccessibleName(/greeting/i); // verify auto-generated title
});
// Test with a different user to avoid race condition on credentials
test('new chat without pre-configured credentials @auth:member', async ({
n8n,
anthropicApiKey,
}) => {
const page = new ChatHubChatPage(n8n.page);
const credModal = new CredentialModal(n8n.page.getByTestId('editCredential-modal'));
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getGreetingMessage()).toContainText('Select a model to start chatting');
await page.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
await page.getVisiblePopoverMenuItem('Configure credentials', { exact: true }).click();
await credModal.fillField('apiKey', anthropicApiKey);
await credModal.save();
await credModal.close();
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // auto-select a model
await page.getChatInput().fill('Hello from e2e');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('Hello from e2e');
await expect(page.getChatMessages().nth(1)).toContainText('Hello!');
await expect(page.sidebar.getConversations().first()).toHaveAccessibleName(/greeting/i); // verify auto-generated title
});
test('conversation flow', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // auto-select a model
// STEP: send first prompt
await page.getChatInput().fill('Hi');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toHaveText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages()).toHaveCount(2);
// STEP: send 2nd prompt
await page.getChatInput().fill('How are you?');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages().nth(2)).toContainText('How are you?');
await expect(page.getChatMessages().nth(3)).toContainText("I'm doing well");
await expect(page.getChatMessages()).toHaveCount(4);
// STEP: regenerate response to first prompt
await page.clickRegenerateButtonAt(1);
await expect(page.getChatMessages().nth(1)).toContainText('Hello!');
await expect(page.getChatMessages()).toHaveCount(2);
// STEP: switch to previous alternative
await page.clickPrevAlternativeButtonAt(1);
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages()).toHaveCount(4);
// STEP: edit 2nd prompt
await page.clickEditButtonAt(2);
await page.getEditorAt(2).fill('Hola');
await page.getSendButtonAt(2).click();
await expect(page.getChatMessages().nth(3)).toContainText('¡Hola!');
await expect(page.getChatMessages()).toHaveCount(4);
// STEP: reload page and verify persistence
await n8n.page.reload();
await expect(page.getChatMessages()).toHaveCount(4);
await expect(page.getChatMessages().nth(0)).toContainText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages().nth(2)).toContainText('Hola');
await expect(page.getChatMessages().nth(3)).toContainText('¡Hola!');
});
});
@@ -0,0 +1,56 @@
import { chatHubTestConfig, expect, test } from './fixtures';
import { INSTANCE_OWNER_CREDENTIALS } from '../../../config/test-users';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
test.use(chatHubTestConfig);
test.describe('Chat user role @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('use chat as chat user @auth:chat', async ({ n8n, anthropicApiKey }) => {
const ownerN8n = await n8n.start.withUser(INSTANCE_OWNER_CREDENTIALS);
// Create global credential as owner
const cred = await ownerN8n.api.credentials.createCredential({
name: 'Global Anthropic API key',
type: 'anthropicApi',
isGlobal: true,
data: {
apiKey: anthropicApiKey,
},
});
await n8n.goHome();
await n8n.page.waitForURL('/home/chat'); // home is chat UI for chat users
const page = new ChatHubChatPage(n8n.page);
// Verify global credential is available and pre-selected
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // pre-selected
await expect(page.getSelectedCredentialName()).toHaveText('Global Anthropic API key');
await page.getModelSelectorButton().click();
await page.getVisiblePopoverMenuItem('Anthropic').click();
await page.getVisiblePopoverMenuItem('Configure credentials', { exact: true }).click();
// Verify other credentials are not available and cannot be created
await page.credModal.getCredentialSelector().click();
await expect(page.credModal.getCreateButton()).toBeDisabled();
await expect(page.credModal.getVisiblePopoverOption()).toHaveCount(1);
await expect(page.credModal.getVisiblePopoverOption().nth(0)).toContainText(
'Global Anthropic API key',
);
await page.credModal.getCloseButton().click();
// Send chat message
await page.getChatInput().fill('Hi');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toHaveText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages()).toHaveCount(2);
await ownerN8n.api.credentials.deleteCredential(cred.id);
});
});
@@ -0,0 +1,95 @@
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { ChatHubPersonalAgentsPage } from '../../../pages/ChatHubPersonalAgentsPage';
test.use(chatHubTestConfig);
test.describe('Personal agent @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('create personal agent and start conversation @auth:owner', async ({
n8n,
anthropicCredential: _,
}) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await page.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.getVisiblePopoverMenuItem('Personal agents').hover({ force: true });
await page.getVisiblePopoverMenuItem('New Agent', { exact: true }).click();
await page.personalAgentModal.getNameField().fill('e2e agent');
await page.personalAgentModal.getDescriptionField().fill('just for testing');
await page.personalAgentModal.getSystemPromptField().fill('reply in Chinese');
await page.personalAgentModal.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
await page.getVisiblePopoverMenuItem('Claude Opus 4.5', { exact: true }).click();
await page.personalAgentModal.getSaveButton().click();
await expect(page.personalAgentModal.getRoot()).not.toBeInViewport(); // wait for modal to close
await expect(page.getModelSelectorButton()).toContainText('e2e agent');
await page.getChatInput().fill('Hello');
await page.getSendButton().click();
await expect(page.getChatMessages().last()).toContainText('你好');
});
test('manage personal agents @auth:admin', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubPersonalAgentsPage(n8n.page);
const chatPage = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHubPersonalAgents();
await page.getNewAgentButton().click();
// STEP: create an agent
await page.editModal.getNameField().fill('e2e agent');
await page.editModal.getDescriptionField().fill('just for testing');
await page.editModal.getSystemPromptField().fill('reply in Chinese');
await page.editModal.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.editModal.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
await page.editModal.getVisiblePopoverMenuItem('Claude Opus 4.5', { exact: true }).click();
await page.editModal.getSaveButton().click();
await expect(page.editModal.getRoot()).not.toBeInViewport(); // wait for modal to close
await expect(page.getAgentCards()).toHaveCount(1);
await expect(page.getAgentCards().nth(0)).toContainText('e2e agent');
// STEP: send message to the created agent
await page.getAgentCards().nth(0).click();
await chatPage.dismissWelcomeScreen();
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText('你好');
// STEP: update instructions for the agent
await chatPage.sidebar.getPersonalAgentButton().click();
await page.getEditButtonAt(0).click();
await page.editModal.getSystemPromptField().fill('reply in Japanese');
await page.editModal.getSaveButton().click();
await expect(page.editModal.getRoot()).not.toBeInViewport(); // wait for modal to close
// STEP: send message to the updated agent
await page.getAgentCards().nth(0).click();
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText('こんにちは');
// STEP: delete the agent
await chatPage.sidebar.getPersonalAgentButton().click();
await page.getMenuAt(0).click();
await page.getVisiblePopoverMenuItem('Delete').click();
await n8n.page.getByRole('dialog').getByText('Delete', { exact: true }).click(); // confirmation dialog
await expect(page.getAgentCards()).toHaveCount(0);
});
});
@@ -0,0 +1,121 @@
import { chatHubTestConfig, expect, test } from './fixtures';
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../config/test-users';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { ChatHubSettingsPage } from '../../../pages/ChatHubSettingsPage';
test.use(chatHubTestConfig);
test.describe('Settings @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('set global credentials for a provider', async ({ n8n, anthropicCredential }) => {
const page = new ChatHubSettingsPage(n8n.page);
await n8n.navigate.toChatHubSettings();
// Open Anthropic settings
await page.getProviderActionToggle('Anthropic').click();
await page.getVisiblePopoverMenuItem('Edit provider').click();
// Configure default credential
await page.providerModal.getCredentialPicker().click();
await page.providerModal.getVisiblePopoverOption(anthropicCredential.name).click();
// Open credential modal and make it globally shared
await page.providerModal.getEditCredentialButton().click();
await page.credentialModal.changeTab('Sharing');
await page.credentialModal.getUsersSelect().click();
await page.credentialModal.getVisiblePopoverOption('All users and projects').click();
await page.credentialModal.save();
await page.credentialModal.close();
// Save settings
await page.providerModal.getConfirmButton().click();
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(1);
const memberN8n = await n8n.start.withUser(INSTANCE_MEMBER_CREDENTIALS[0]);
const chatPage = new ChatHubChatPage(memberN8n.page);
await memberN8n.navigate.toChatHub();
await chatPage.dismissWelcomeScreen();
await expect(chatPage.getSelectedCredentialName()).toHaveText(anthropicCredential.name);
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().nth(0)).toContainText('Hello');
await expect(chatPage.getChatMessages().nth(1)).toContainText(
'Hello! How can I help you today?',
);
await memberN8n.page.close();
});
test('restrict available LLM providers and models', async ({
n8n,
anthropicCredential,
anthropicApiKey,
}) => {
const page = new ChatHubSettingsPage(n8n.page);
await n8n.navigate.toChatHubSettings();
// Open Anthropic settings
await page.getProviderActionToggle('Anthropic').click();
await page.getVisiblePopoverMenuItem('Edit provider').click();
// Anthropic: configure default credential
await page.providerModal.getCredentialPicker().click();
await page.providerModal.getVisiblePopoverOption(anthropicCredential.name).click();
// Anthropic: enable limit models toggle
await page.providerModal.getLimitModelsToggle().click();
// Anthropic: select only Claude Opus 4.5
await page.providerModal.getModelSelector().click();
await page.providerModal.getVisiblePopoverOption('Claude Opus 4.5').click();
// Anthropic: save settings
await page.providerModal.getConfirmButton().click();
await expect(page.providerModal.getRoot()).toBeHidden();
// Open OpenAI settings
await page.getProviderActionToggle('OpenAI').click();
await page.getVisiblePopoverMenuItem('Edit provider').click();
// OpenAI: disable provider and save
await expect(page.providerModal.getEnabledToggle()).toBeChecked();
await page.providerModal.getEnabledToggle().click();
await page.providerModal.getConfirmButton().click();
await expect(page.providerModal.getRoot()).toBeHidden();
await n8n.page.close();
// Log in as member and verify only selected model is available
const memberN8n = await n8n.start.withUser(INSTANCE_MEMBER_CREDENTIALS[0]);
const chatPage = new ChatHubChatPage(memberN8n.page);
const cred = await memberN8n.api.credentials.createCredential({
name: 'Member API key',
type: 'anthropicApi',
data: {
apiKey: anthropicApiKey,
},
});
await memberN8n.navigate.toChatHub();
await chatPage.dismissWelcomeScreen();
await chatPage.getModelSelectorButton().click();
await expect(chatPage.getVisiblePopoverMenuItem('Anthropic')).toBeVisible();
await expect(chatPage.getVisiblePopoverMenuItem('OpenAI')).toBeHidden();
await chatPage.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
const anthropicModels = chatPage.getVisiblePopoverMenuItem(/^Claude/);
await expect(anthropicModels).toHaveText(['Claude Opus 4.5']);
await memberN8n.api.credentials.deleteCredential(cred.id);
});
});
@@ -0,0 +1,57 @@
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
test.use(chatHubTestConfig);
test.describe('Tools usage @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('use web search tool in conversation', async ({
n8n,
anthropicCredential: _,
jinaCredential,
}) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i);
// Open tools manager modal
await page.getToolsButton().click();
await expect(page.toolsModal.getRoot()).toBeVisible();
// Add Jina AI tool from available tools list
await page.toolsModal.getAddButton('Jina AI').click();
// Select credential in settings view
await page.toolsModal.getCredentialSelect().click();
await page.getVisiblePopoverOption(jinaCredential.name).click();
// Change Operation from "Read" to "Search"
await page.toolsModal.getParameterInput('operation').click();
await page.getVisiblePopoverOption('Search').click();
// Set search query and simplify to "defined automatically by the model"
await page.toolsModal.getFromAiOverrideButton('searchQuery').click();
await page.toolsModal.getFromAiOverrideButton('simplify').click();
// Save and close
await page.toolsModal.getSaveButton().click();
await page.toolsModal.getCloseButton().click();
await expect(page.toolsModal.getRoot()).toBeHidden();
// Verify tool is shown
await expect(page.getToolsButton()).toContainText('Search web in Jina AI');
// Send message and check response
await page.getChatInput().fill('What is n8n?');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What is n8n?');
await expect(page.getChatMessages().nth(1)).toContainText(/automation/i, {
timeout: 60000,
});
});
});
@@ -0,0 +1,113 @@
import { expect, test, chatHubTestConfig } from './fixtures';
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../config/test-users';
import { CanvasPage } from '../../../pages/CanvasPage';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { ChatHubWorkflowAgentsPage } from '../../../pages/ChatHubWorkflowAgentsPage';
import { NodeDetailsViewPage } from '../../../pages/NodeDetailsViewPage';
test.use(chatHubTestConfig);
test.describe('Workflow agent @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('manage workflow agents @auth:admin', async ({ n8n, agentWorkflow }) => {
const agentsPage = new ChatHubWorkflowAgentsPage(n8n.page);
const chatPage = new ChatHubChatPage(n8n.page);
// STEP: Navigate to workflow agents page and verify agent is listed
await n8n.navigate.toChatHubWorkflowAgents();
await expect(agentsPage.getAgentCards()).toHaveCount(1);
await expect(agentsPage.getAgentCards().nth(0)).toContainText(agentWorkflow.name);
// STEP: Click agent card to start conversation
await agentsPage.getAgentCards().nth(0).click();
await chatPage.dismissWelcomeScreen();
await expect(chatPage.getModelSelectorButton()).toContainText(agentWorkflow.name);
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText(/Bonjour/i);
// STEP: Open workflow in new tab and update system prompt
const tab1 = await chatPage.clickOpenWorkflowButton();
const canvas = new CanvasPage(tab1);
const ndv = new NodeDetailsViewPage(tab1);
await canvas.openNode('AI Agent');
await ndv.fillParameterInput('System Message', 'Reply in Finnish');
await ndv.close();
await canvas.publishWorkflow();
await tab1.close();
// STEP: Select the workflow agent for new conversation
await n8n.navigate.toChatHub();
await chatPage.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500);
await chatPage.getVisiblePopoverMenuItem('Workflow agents').hover({ force: true });
await chatPage.getVisiblePopoverMenuItem(agentWorkflow.name, { exact: true }).click();
// STEP: Send message again
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText(/Hei|Moi/i);
// STEP: Open workflow in new tab and disable ChatHub
const tab2 = await chatPage.clickOpenWorkflowButton();
const canvas2 = new CanvasPage(tab2);
const ndv2 = new NodeDetailsViewPage(tab2);
await canvas2.openNode('When chat message received');
await ndv2.getParameterSwitch('availableInChat').click();
await ndv2.close();
await canvas2.publishWorkflow();
await tab2.close();
await n8n.navigate.toChatHubWorkflowAgents();
await expect(agentsPage.getEmptyText()).toBeVisible();
});
test('sharing workflow agent with project chat user', async ({
n8n,
anthropicCredential,
agentWorkflow,
project,
}) => {
const memberN8n = await n8n.start.withUser(INSTANCE_MEMBER_CREDENTIALS[0]);
const memberEmail = INSTANCE_MEMBER_CREDENTIALS[0].email;
// Transfer workflow and credential to it
await n8n.api.credentials.transferCredential(anthropicCredential.id, project.id);
await n8n.api.workflows.transfer(agentWorkflow.id, project.id);
// Verify that the agent is visible to owner
const ownerWorkflowAgentsPage = new ChatHubWorkflowAgentsPage(n8n.page);
await n8n.navigate.toChatHubWorkflowAgents();
await expect(ownerWorkflowAgentsPage.getAgentCards()).toContainText([agentWorkflow.name]);
// Verify that the agent is not visible to member before sharing
const memberWorkflowAgentsPage = new ChatHubWorkflowAgentsPage(memberN8n.page);
await memberN8n.navigate.toChatHubWorkflowAgents();
await expect(memberWorkflowAgentsPage.getEmptyText()).toBeVisible();
// Add member user to the project with project chat user role
await n8n.navigate.toProjectSettings(project.id);
await n8n.projectSettings.getMembersSearchInput().click();
await n8n.projectSettings.getVisiblePopoverOption(memberEmail).click();
await expect(n8n.projectSettings.getMembersTable()).toContainText(memberEmail);
await n8n.projectSettings.getRoleDropdownFor(memberEmail).click();
await n8n.projectSettings.getVisiblePopoverOption('Project Chat User').click();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
// Verify that the agent is visible and usable to member after sharing
await memberN8n.page.reload();
await expect(memberWorkflowAgentsPage.getAgentCards()).toContainText([agentWorkflow.name]);
await memberWorkflowAgentsPage.getAgentCards().nth(0).click();
const memberChatPage = new ChatHubChatPage(memberN8n.page);
await memberChatPage.dismissWelcomeScreen();
await memberChatPage.getChatInput().fill('Hello');
await memberChatPage.getSendButton().click();
await expect(memberChatPage.getChatMessages().last()).toContainText(/Bonjour/i);
});
});
@@ -0,0 +1,122 @@
import type { Project } from '@n8n/db';
import type { IWorkflowBase } from 'n8n-workflow';
import { test as base, expect as baseExpect } from '../../../fixtures/base';
import type { CredentialResponse } from '../../../services/credential-api-helper';
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'mock-anthropic-api-key';
const JINA_API_KEY = process.env.JINA_API_KEY ?? 'mock-jina-api-key';
type ChatHubFixtures = {
project: Project;
anthropicCredential: CredentialResponse;
anthropicApiKey: string;
jinaCredential: CredentialResponse;
jinaApiKey: string;
chatHubProxySetup: undefined;
agentWorkflow: IWorkflowBase;
};
export const chatHubTestConfig = {
timezoneId: 'America/New_York',
capability: {
services: ['proxy'],
env: {
N8N_COMMUNITY_PACKAGES_ENABLED: 'false',
},
},
} as const;
export const test = base.extend<ChatHubFixtures>({
anthropicApiKey: async ({}, use) => {
await use(ANTHROPIC_API_KEY);
},
jinaApiKey: async ({}, use) => {
await use(JINA_API_KEY);
},
chatHubProxySetup: [
async ({ services }, use) => {
// Setup
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('chat-hub', { strictBodyMatching: true });
await use(undefined);
// Teardown
if (!process.env.CI) {
await services.proxy.recordExpectations('chat-hub', {
dedupe: true,
transform: (expectation) => {
const response = expectation.httpResponse as {
headers?: Record<string, string[]>;
};
if (response?.headers) {
delete response.headers['anthropic-organization-id'];
}
return expectation;
},
});
}
},
{ auto: true },
],
project: async ({ n8n }, use) => {
const project = await n8n.api.projects.createProject('ChatHub test project');
await use(project);
await n8n.api.projects.deleteProject(project.id);
},
agentWorkflow: async ({ n8n, anthropicCredential, project: _ }, use) => {
const res = await n8n.api.workflows.importWorkflowFromFile('chat-hub-workflow-agent.json', {
transform: (workflow) => {
const anthropicNode = workflow.nodes?.find((n) => n.type.includes('lmChatAnthropic'));
anthropicNode!.credentials!.anthropicApi = anthropicCredential;
return workflow;
},
});
await use(res.createdWorkflow);
await n8n.api.workflows.archive(res.workflowId);
await n8n.api.workflows.delete(res.workflowId);
},
anthropicCredential: async ({ n8n, anthropicApiKey, project: _ }, use) => {
const res = await n8n.api.credentials.createCredential({
name: `Anthropic cred ${crypto.randomUUID().slice(0, 8)}`,
type: 'anthropicApi',
data: {
apiKey: anthropicApiKey,
},
});
await use(res);
await n8n.api.credentials.deleteCredential(res.id);
},
jinaCredential: async ({ n8n, jinaApiKey }, use) => {
const res = await n8n.api.credentials.createCredential({
name: `Jina AI cred ${crypto.randomUUID().slice(0, 8)}`,
type: 'jinaAiApi',
data: {
apiKey: jinaApiKey,
},
});
await use(res);
await n8n.api.credentials.deleteCredential(res.id);
},
});
export const expect = baseExpect;
@@ -0,0 +1,92 @@
import { test, expect } from '../../../fixtures/base';
import basePlanData from '../../../fixtures/plan-data-trial.json';
import type { n8nPage } from '../../../pages/n8nPage';
import type { TestRequirements } from '../../../Types';
test.use({ capability: { env: { TEST_ISOLATION: 'cloud' } } });
const fiveDaysFromNow = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);
const planData = { ...basePlanData, expirationDate: fiveDaysFromNow.toJSON() };
const cloudTrialRequirements: TestRequirements = {
config: {
settings: {
publicApi: {
enabled: false,
latestVersion: 1,
path: 'api',
swaggerUi: { enabled: false },
},
deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' },
aiCredits: {
enabled: true,
credits: 100,
setup: true,
},
banners: {
dismissed: ['V1'], // Prevent V1 banner interference
},
},
},
intercepts: {
'cloud-plan': {
url: '**/rest/admin/cloud-plan',
response: planData,
},
'cloud-user': {
url: '**/rest/cloud/proxy/user/me',
response: {},
},
},
};
const setupCloudTest = async (
n8n: n8nPage,
setupRequirements: (requirements: TestRequirements) => Promise<void>,
requirements: TestRequirements,
) => {
await setupRequirements(requirements);
await n8n.page.waitForLoadState();
};
test.describe('Cloud @db:reset @auth:owner', {
annotation: [
{ type: 'owner', description: 'Cloud Platform' },
],
}, () => {
test.describe('Trial Upgrade', () => {
test('should render trial banner for opt-in cloud user', async ({ n8n, setupRequirements }) => {
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.start.fromBlankCanvas();
await n8n.sideBar.expand();
await expect(n8n.sideBar.getTrialBanner()).toBeVisible();
});
});
test.describe('Admin Home', () => {
test('should show admin button', async ({ n8n, setupRequirements }) => {
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.start.fromBlankCanvas();
await n8n.sideBar.expand();
await expect(n8n.sideBar.getAdminPanel()).toBeVisible();
});
});
test.describe('Public API', () => {
test('should show upgrade CTA for Public API if user is trialing', async ({
n8n,
setupRequirements,
}) => {
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
await n8n.navigate.toApiSettings();
await n8n.page.waitForLoadState();
await expect(n8n.settingsPersonal.getUpgradeCta()).toBeVisible();
});
});
});
@@ -0,0 +1,201 @@
import type { CreateCredentialDto } from '@n8n/api-types';
import { test, expect } from '../../../fixtures/base';
test.describe('Credential API Operations', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe('Basic CRUD Operations', () => {
test('should create, retrieve, update, and delete credential', async ({ api }) => {
const credentialData: CreateCredentialDto = {
name: 'Test HTTP Basic Auth',
type: 'httpBasicAuth',
data: {
user: 'test_user',
password: 'test_password',
},
};
const { credentialId, createdCredential } =
await api.credentials.createCredentialFromDefinition(credentialData);
expect(credentialId).toBeTruthy();
expect(createdCredential.type).toBe('httpBasicAuth');
expect(createdCredential.name).toContain('Test HTTP Basic Auth (Test');
const retrievedCredential = await api.credentials.getCredential(credentialId);
expect(retrievedCredential.id).toBe(credentialId);
expect(retrievedCredential.type).toBe('httpBasicAuth');
expect(retrievedCredential.name).toBe(createdCredential.name);
const credentialWithData = await api.credentials.getCredential(credentialId, {
includeData: true,
});
expect(credentialWithData.data).toBeDefined();
expect(credentialWithData.data?.user).toBe('test_user');
const updatedName = 'Updated HTTP Basic Auth';
const updatedCredential = await api.credentials.updateCredential(credentialId, {
name: updatedName,
data: {
user: 'updated_user',
password: 'updated_password',
},
});
expect(updatedCredential.name).toBe(updatedName);
const verifyUpdated = await api.credentials.getCredential(credentialId, {
includeData: true,
});
expect(verifyUpdated.name).toBe(updatedName);
expect(verifyUpdated.data?.user).toBe('updated_user');
const deleteResult = await api.credentials.deleteCredential(credentialId);
expect(deleteResult).toBe(true);
await expect(api.credentials.getCredential(credentialId)).rejects.toThrow();
});
});
test.describe('Credential Listing', () => {
test('should list credentials with different query options', async ({ api }) => {
const credential1 = await api.credentials.createCredentialFromDefinition({
name: 'First Test Credential',
type: 'httpBasicAuth',
data: { user: 'user1', password: 'pass1' },
});
const credential2 = await api.credentials.createCredentialFromDefinition({
name: 'Second Test Credential',
type: 'httpHeaderAuth',
data: { name: 'Authorization', value: 'Bearer token' },
});
const allCredentials = await api.credentials.getCredentials();
expect(allCredentials.length).toBeGreaterThanOrEqual(2);
const createdIds = [credential1.credentialId, credential2.credentialId];
const foundCredentials = allCredentials.filter((c) => createdIds.includes(c.id));
expect(foundCredentials).toHaveLength(2);
const credentialsWithScopes = await api.credentials.getCredentials({
includeGlobal: false,
includeScopes: true,
});
expect(credentialsWithScopes[0].scopes).toBeDefined();
expect(Array.isArray(credentialsWithScopes[0].scopes)).toBe(true);
const credentialsWithData = await api.credentials.getCredentials({
includeGlobal: false,
includeData: true,
});
const foundWithData = credentialsWithData.filter((c) => createdIds.includes(c.id));
expect(foundWithData.some((c) => c.data)).toBe(true);
});
});
test.describe('Project Integration', () => {
test('should handle credential-project associations', async ({ api }) => {
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
const project = await api.projects.createProject('Test Project for Credentials');
const credential = await api.credentials.createCredentialFromDefinition({
name: 'Project Credential',
type: 'httpBasicAuth',
data: { user: 'user', password: 'pass' },
projectId: project.id,
});
const projectCredentials = await api.credentials.getCredentialsForWorkflow({
projectId: project.id,
});
expect(projectCredentials).toBeDefined();
expect(Array.isArray(projectCredentials)).toBe(true);
const foundCredential = projectCredentials.find((c) => c.id === credential.credentialId);
expect(foundCredential).toBeDefined();
});
test('should transfer credential between projects', async ({ api }) => {
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
const sourceProject = await api.projects.createProject('Source Project');
const destinationProject = await api.projects.createProject('Destination Project');
const credential = await api.credentials.createCredentialFromDefinition({
name: 'Transfer Test Credential',
type: 'httpBasicAuth',
data: { user: 'user', password: 'pass' },
projectId: sourceProject.id,
});
const sourceCredentials = await api.credentials.getCredentialsForWorkflow({
projectId: sourceProject.id,
});
const foundInSource = sourceCredentials.find((c) => c.id === credential.credentialId);
expect(foundInSource).toBeDefined();
await api.credentials.transferCredential(credential.credentialId, destinationProject.id);
const destinationCredentials = await api.credentials.getCredentialsForWorkflow({
projectId: destinationProject.id,
});
const foundInDestination = destinationCredentials.find(
(c) => c.id === credential.credentialId,
);
expect(foundInDestination).toBeDefined();
const sourceCredentialsAfter = await api.credentials.getCredentialsForWorkflow({
projectId: sourceProject.id,
});
const stillInSource = sourceCredentialsAfter.find((c) => c.id === credential.credentialId);
expect(stillInSource).toBeUndefined();
});
});
test.describe('Data Persistence', () => {
test('should maintain credential data across operations', async ({ api }) => {
const originalData: CreateCredentialDto = {
name: 'Persistence Test Credential',
type: 'httpBasicAuth',
data: {
user: 'persistent_user',
password: 'persistent_password',
},
};
const { credentialId } = await api.credentials.createCredentialFromDefinition(originalData);
const afterCreate = await api.credentials.getCredential(credentialId, {
includeData: true,
});
expect(afterCreate.data?.user).toBe('persistent_user');
await api.credentials.updateCredential(credentialId, {
data: {
user: 'updated_persistent_user',
password: 'updated_persistent_password',
},
});
const afterUpdate = await api.credentials.getCredential(credentialId, {
includeData: true,
});
expect(afterUpdate.data?.user).toBe('updated_persistent_user');
expect(afterUpdate.data?.password).toBeDefined();
const allCredentials = await api.credentials.getCredentials();
const foundCredential = allCredentials.find((c) => c.id === credentialId);
expect(foundCredential).toBeDefined();
expect(foundCredential!.type).toBe('httpBasicAuth');
});
});
});
@@ -0,0 +1,351 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.describe(
'Credentials',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
});
test('should create a new credential using empty state', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const credentialName = `My awesome Notion account ${nanoid()}`;
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: '1234567890' },
{ name: credentialName, projectId },
);
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
await expect(n8n.credentials.cards.getCredential(credentialName)).toBeVisible();
});
test('should sort credentials', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const credentialA = `A Credential ${nanoid()}`;
const credentialZ = `Z Credential ${nanoid()}`;
await n8n.api.credentials.createCredential({
name: credentialA,
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId,
});
await n8n.api.credentials.createCredential({
name: credentialZ,
type: 'trelloApi',
data: { apiKey: 'test_api_key', apiToken: 'test_api_token' },
projectId,
});
await n8n.navigate.toCredentials(projectId);
await n8n.credentials.clearSearch();
await n8n.credentials.sortByNameDescending();
const firstCardDescending = n8n.credentials.cards.getCredentials().first();
await expect(firstCardDescending).toContainText(credentialZ);
await n8n.credentials.sortByNameAscending();
const firstCardAscending = n8n.credentials.cards.getCredentials().first();
await expect(firstCardAscending).toContainText(credentialA);
});
test('should create credentials from NDV for node with multiple auth options', async ({
n8n,
}) => {
await n8n.start.fromNewProjectBlankCanvas();
const credentialName = `My Google Service Account ${nanoid()}`;
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Gmail', { action: 'Send a message' });
await n8n.ndv.clickCreateNewCredential();
// Gmail has 2 auth options (OAuth2 + Service Account), shown as a dropdown
await expect(n8n.canvas.credentialModal.getModeSelector()).toBeVisible();
await n8n.canvas.credentialModal.selectAuthTypeFromDropdown(/Service Account/);
// Fill in the Service Account fields and save
await n8n.canvas.credentialModal.addCredential(
{
email: 'test@project.iam.gserviceaccount.com',
privateKey: 'test_private_key',
},
{ name: credentialName },
);
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName);
});
test('should show multiple credential types in the same dropdown', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProjectBlankCanvas();
const serviceAccountCredentialName2 = `OAuth2 Credential ${nanoid()}`;
const serviceAccountCredentialName = `Service Account Credential ${nanoid()}`;
await n8n.api.credentials.createCredential({
name: serviceAccountCredentialName2,
type: 'googleApi',
data: { email: 'test@service.com', privateKey: 'test_key' },
projectId,
});
await n8n.api.credentials.createCredential({
name: serviceAccountCredentialName,
type: 'googleApi',
data: { email: 'test@service.com', privateKey: 'test_key' },
projectId,
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Gmail', { action: 'Send a message' });
await n8n.ndv.getCredentialSelect().click();
await expect(n8n.ndv.getCredentialOptionByText(serviceAccountCredentialName2)).toBeVisible();
await expect(n8n.ndv.getCredentialOptionByText(serviceAccountCredentialName)).toBeVisible();
await expect(n8n.ndv.credentialDropdownCreateNewCredential()).toBeVisible();
await expect(n8n.ndv.getCredentialDropdownOptions()).toHaveCount(2);
});
test('should correctly render required and optional credentials', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
await n8n.canvas.addNode('Pipedrive', { trigger: 'On new Pipedrive event' });
await n8n.ndv.selectOptionInParameterDropdown('incomingAuthentication', 'Basic Auth');
await expect(n8n.ndv.getNodeCredentialsSelect()).toHaveCount(2);
await n8n.ndv.clickCreateNewCredential(0);
// First credential type has multiple auth options → mode selector visible
await expect(n8n.canvas.credentialModal.getModeSelector()).toBeVisible();
await n8n.canvas.credentialModal.close();
await n8n.ndv.clickCreateNewCredential(1);
await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
// Second credential type has single auth option → no mode selector
await expect(n8n.canvas.credentialModal.getModeSelector()).toBeHidden();
await n8n.canvas.credentialModal.close();
});
test('should create credentials from NDV for node with no auth options', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
const credentialName = `My Trello Account ${nanoid()}`;
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Trello', { action: 'Create a card' });
await n8n.credentialsComposer.createFromNdv(
{
apiKey: 'test_api_key',
apiToken: 'test_api_token',
},
{ name: credentialName },
);
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName);
});
test('should delete credentials from NDV', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
const credentialName = `Notion Credential ${nanoid()}`;
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
await n8n.credentialsComposer.createFromNdv(
{ apiKey: '1234567890' },
{ name: credentialName },
);
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName);
await n8n.canvas.credentialModal.editCredential();
await n8n.canvas.credentialModal.deleteCredential();
await n8n.canvas.credentialModal.confirmDelete();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Credential deleted'),
).toBeVisible();
await expect(n8n.ndv.getCredentialSelect()).not.toHaveValue(credentialName);
});
test('should rename credentials from NDV', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
const initialName = `My Trello Account ${nanoid()}`;
const renamedName = `Something else ${nanoid()}`;
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Trello', { action: 'Create a card' });
await n8n.credentialsComposer.createFromNdv(
{
apiKey: 'test_api_key',
apiToken: 'test_api_token',
},
{ name: initialName },
);
await n8n.canvas.credentialModal.editCredential();
await n8n.canvas.credentialModal.renameCredential(renamedName);
await n8n.canvas.credentialModal.save();
await n8n.canvas.credentialModal.close();
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(renamedName);
});
test('should edit credential for non-standard credential type', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
const initialName = `Adalo Credential ${nanoid()}`;
const editedName = `Something else ${nanoid()}`;
await n8n.canvas.addNode('AI Agent', { closeNDV: true });
await n8n.canvas.addNode('HTTP Request Tool');
await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Predefined Credential Type');
await n8n.ndv.selectOptionInParameterDropdown('nodeCredentialType', 'Adalo API');
await n8n.credentialsComposer.createFromNdv(
{
apiKey: 'test_adalo_key',
appId: 'test_app_id',
},
{ name: initialName },
);
await n8n.canvas.credentialModal.editCredential();
await n8n.canvas.credentialModal.renameCredential(editedName);
await n8n.canvas.credentialModal.save();
await n8n.canvas.credentialModal.close();
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(editedName);
});
test('should set a default credential when adding nodes', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProjectBlankCanvas();
const credentialName = `My awesome Notion account ${nanoid()}`;
await n8n.api.credentials.createCredential({
name: credentialName,
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId,
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName);
const credentials = await n8n.api.credentials.getCredentials();
const credential = credentials.find((c) => c.name === credentialName);
await n8n.api.credentials.deleteCredential(credential!.id);
});
test('should set a default credential when editing a node', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProjectBlankCanvas();
const credentialName = `My awesome Notion account ${nanoid()}`;
await n8n.api.credentials.createCredential({
name: credentialName,
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId,
});
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Predefined Credential Type');
await n8n.ndv.selectOptionInParameterDropdown('nodeCredentialType', 'Notion API');
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName);
const credentials = await n8n.api.credentials.getCredentials();
const credential = credentials.find((c) => c.name === credentialName);
await n8n.api.credentials.deleteCredential(credential!.id);
});
test('should setup generic authentication for HTTP node', async ({ n8n }) => {
await n8n.start.fromNewProjectBlankCanvas();
const credentialName = `Query Auth Credential ${nanoid()}`;
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Generic Credential Type');
await n8n.ndv.selectOptionInParameterDropdown('genericAuthType', 'Query Auth');
await n8n.credentialsComposer.createFromNdv(
{
name: 'api_key',
value: 'test_query_value',
},
{ name: credentialName },
);
await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName);
});
test('should not show OAuth redirect URL section when OAuth2 credentials are overridden', async ({
n8n,
}) => {
// Mock credential types response to simulate admin override
await n8n.page.route('**/rest/types/credentials.json', async (route) => {
const response = await route.fetch();
const json = await response.json();
// Override Slack OAuth2 credential properties
if (json.slackOAuth2Api) {
json.slackOAuth2Api.__overwrittenProperties = ['clientId', 'clientSecret'];
}
await route.fulfill({ json });
});
await n8n.start.fromNewProjectBlankCanvas();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Slack', { action: 'Get a channel' });
await n8n.ndv.clickCreateNewCredential();
// With overridden OAuth2 properties, the mode selector shows a dropdown
// with Managed OAuth2 selected by default — redirect URL should be hidden
await expect(n8n.canvas.credentialModal.getModeSelector()).toBeVisible();
await expect(n8n.canvas.credentialModal.getOAuthRedirectUrl()).toBeHidden();
await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
});
test('ADO-2583 should show notifications above credential modal overlay', async ({ n8n }) => {
await n8n.page.route('**/rest/credentials', async (route) => {
if (route.request().method() === 'POST') {
await route.abort('failed');
} else {
await route.continue();
}
});
const projectId = await n8n.start.fromNewProject();
await n8n.navigate.toCredentials(projectId);
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.canvas.credentialModal.fillField('apiKey', '1234567890');
const saveBtn = n8n.canvas.credentialModal.getSaveButton();
await saveBtn.click();
const errorNotification = n8n.notifications.getErrorNotifications();
await expect(errorNotification).toBeVisible();
await expect(n8n.canvas.credentialModal.getModal()).toBeVisible();
const modalOverlay = n8n.page.locator('.el-overlay').first();
await expect(errorNotification).toHaveCSS('z-index', '2100');
await expect(modalOverlay).toHaveCSS('z-index', '2001');
});
},
);
@@ -0,0 +1,160 @@
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'global-credentials' } } });
test.describe('Global credentials', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ api }) => {
await api.enableFeature('sharing');
});
test('owner should create HTTP header credential and set to global', async ({ n8n }) => {
await n8n.api.signin('owner');
// Navigate to credentials page
await n8n.navigate.toCredentials();
// Create new credential
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Header Auth');
// Fill in credential fields
await n8n.credentials.credentialModal.fillField('name', 'Authorization');
await n8n.credentials.credentialModal.fillField('value', 'Bearer test-token-123');
// Set credential name
await n8n.credentials.credentialModal.getCredentialName().click();
await n8n.credentials.credentialModal.getNameInput().fill('Global HTTP Header Cred');
// Switch to Sharing tab
await n8n.credentials.credentialModal.changeTab('Sharing');
// Share with all users (set to global)
await n8n.credentials.credentialModal.getUsersSelect().click();
await n8n.credentials.credentialModal.getVisibleDropdown().getByText('All users').click();
// Save the credential with sharing
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
// Verify credential appears in list with global badge
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).toBeVisible();
await expect(
n8n.credentials.cards
.getCredential('Global HTTP Header Cred')
.getByTestId('credential-global-badge'),
).toBeVisible();
});
test('member should see global credential in credentials view', async ({ n8n }) => {
await n8n.api.signin('member', 0);
// Navigate to credentials page
await n8n.navigate.toCredentials();
// Verify global credential is visible to member
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).toBeVisible();
// Verify global badge is displayed
await expect(
n8n.credentials.cards
.getCredential('Global HTTP Header Cred')
.getByTestId('credential-global-badge'),
).toBeVisible();
});
test('member should execute workflow with HTTP node using global credential', async ({
n8n,
baseURL,
}) => {
await n8n.api.signin('member', 0);
// Create a new workflow
await n8n.navigate.toWorkflow('new');
await n8n.canvas.setWorkflowName('Test Global Credential Workflow');
// Add manual trigger and HTTP Request node
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', `${baseURL}/rest/settings`);
await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Generic Credential Type');
await n8n.ndv.selectOptionInParameterDropdown('genericAuthType', 'Header Auth');
// Verify global credential is available in the credential select
const credentialSelect = n8n.ndv.getCredentialSelect();
await credentialSelect.click();
// Check that global credential appears in dropdown
const dropdown = n8n.credentials.credentialModal.getVisibleDropdown();
await expect(dropdown.getByText('Global HTTP Header Cred')).toBeVisible();
// Select the global credential
await dropdown.getByText('Global HTTP Header Cred').click();
// Verify credential is selected
await expect(credentialSelect).toHaveValue('Global HTTP Header Cred');
// Close NDV
await n8n.ndv.clickBackToCanvasButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
});
test('owner should be able to remove global sharing', async ({ n8n }) => {
await n8n.api.signin('owner');
// Navigate to credentials page
await n8n.navigate.toCredentials();
// Open the global credential
await n8n.credentials.cards.getCredential('Global HTTP Header Cred').click();
// Switch to Sharing tab
await n8n.credentials.credentialModal.changeTab('Sharing');
// Verify "All users" is in the sharing list
await expect(
n8n.credentials.credentialModal
.getModal()
.getByTestId('project-sharing-list-item')
.filter({ hasText: 'All users' }),
).toBeVisible();
// Remove global sharing by clicking the remove button
await n8n.credentials.credentialModal
.getModal()
.getByTestId('project-sharing-list-item')
.filter({ hasText: 'All users' })
.getByTestId('project-sharing-remove')
.click();
// Save the changes
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
// Verify global badge is no longer visible
await expect(
n8n.credentials.cards
.getCredential('Global HTTP Header Cred')
.getByTestId('credential-global-badge'),
).toBeHidden();
});
test('member should not see credential after global sharing removed', async ({ n8n }) => {
await n8n.api.signin('member', 0);
// Navigate to credentials page
await n8n.navigate.toCredentials();
// Verify credential is no longer visible to member
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).toBeHidden();
});
});
@@ -0,0 +1,42 @@
import { test, expect } from '../../../fixtures/base';
test.describe(
'OAuth Credentials',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test('should create and connect with Google OAuth2', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProjectBlankCanvas();
await n8n.navigate.toCredentials(projectId);
await n8n.credentials.emptyListCreateCredentialButton.click();
await n8n.credentials.createCredentialFromCredentialPicker(
'Google OAuth2 API',
{
clientId: 'test-key',
clientSecret: 'test-secret',
},
{ closeDialog: false, skipSave: true },
);
const popupPromise = n8n.page.waitForEvent('popup');
await n8n.credentials.credentialModal.oauthConnectButton.click();
const popup = await popupPromise;
const popupUrl = popup.url();
expect(popupUrl).toContain('accounts.google.com');
expect(popupUrl).toContain('client_id=test-key');
await popup.close();
await n8n.page.evaluate(() => {
const channel = new BroadcastChannel('oauth-callback');
channel.postMessage('success');
});
await expect(n8n.credentials.credentialModal.oauthConnectSuccessBanner).toContainText(
'Account connected',
);
});
},
);
@@ -0,0 +1,575 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
test.describe(
'Data Table details view',
{
annotation: [{ type: 'owner', description: 'Adore' }],
},
() => {
let testDataTableName: string;
const COLUMN_NAMES = {
name: 'name',
age: 'age',
active: 'active',
birthday: 'birthday',
} as const;
const generateTestData = () => [
{
name: `User ${nanoid(8)}`,
age: '30',
active: 'true',
birthday: '2024-01-15',
},
{
name: `User ${nanoid(8)}`,
age: '25',
active: 'false',
birthday: '2024-06-20',
},
{
name: `User ${nanoid(8)}`,
age: '45',
active: 'true',
birthday: '2024-12-10',
},
];
const addColumnsAndGetIds = async (
n8n: n8nPage,
method: 'header' | 'table',
): Promise<{
nameColumn: string;
ageColumn: string;
activeColumn: string;
birthdayColumn: string;
}> => {
const addColumnFn =
method === 'header'
? n8n.dataTableDetails.addColumn.bind(n8n.dataTableDetails)
: n8n.dataTableDetails.addColumn.bind(n8n.dataTableDetails);
await addColumnFn(COLUMN_NAMES.name, 'string', method);
await addColumnFn(COLUMN_NAMES.age, 'number', method);
await addColumnFn(COLUMN_NAMES.active, 'boolean', method);
await addColumnFn(COLUMN_NAMES.birthday, 'date', method);
const visibleColumns = n8n.dataTableDetails.getVisibleColumns();
await expect(visibleColumns).toHaveCount(7);
await expect(n8n.dataTableDetails.getColumnHeaderByName(COLUMN_NAMES.name)).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName(COLUMN_NAMES.age)).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName(COLUMN_NAMES.active)).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName(COLUMN_NAMES.birthday)).toBeVisible();
return {
nameColumn: await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name),
ageColumn: await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.age),
activeColumn: await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.active),
birthdayColumn: await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.birthday),
};
};
const fillRowData = async (
n8n: n8nPage,
rowIndex: number,
columnIds: {
nameColumn: string;
ageColumn: string;
activeColumn: string;
birthdayColumn: string;
},
data: { name: string; age: string; active: string; birthday: string },
skipFirstDoubleClick: boolean = false,
) => {
await n8n.dataTableDetails.setCellValue(rowIndex, columnIds.nameColumn, data.name, 'string', {
skipDoubleClick: skipFirstDoubleClick,
});
await n8n.dataTableDetails.setCellValue(rowIndex, columnIds.ageColumn, data.age, 'number');
await n8n.dataTableDetails.setCellValue(
rowIndex,
columnIds.activeColumn,
data.active,
'boolean',
);
await n8n.dataTableDetails.setCellValue(
rowIndex,
columnIds.birthdayColumn,
data.birthday,
'date',
);
};
const verifyCellValues = async (
n8n: n8nPage,
columnIds: {
nameColumn: string;
ageColumn: string;
activeColumn: string;
birthdayColumn: string;
},
testData: Array<{ name: string; age: string; active: string; birthday: string }>,
) => {
const firstRowNameValue = await n8n.dataTableDetails.getCellValue(
0,
columnIds.nameColumn,
'string',
);
expect(firstRowNameValue).toContain(testData[0].name);
const secondRowAgeValue = await n8n.dataTableDetails.getCellValue(
1,
columnIds.ageColumn,
'number',
);
expect(secondRowAgeValue).toContain(testData[1].age);
const thirdRowActiveValue = await n8n.dataTableDetails.getCellValue(
2,
columnIds.activeColumn,
'boolean',
);
expect(thirdRowActiveValue).toBe(testData[2].active);
const firstRowBirthdayValue = await n8n.dataTableDetails.getCellValue(
0,
columnIds.birthdayColumn,
'date',
);
expect(firstRowBirthdayValue).toContain(testData[0].birthday);
};
test.beforeEach(async ({ n8n, api }) => {
await api.enableFeature('sharing');
await api.enableFeature('folders');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.goHome();
await n8n.sideBar.clickPersonalMenuItem();
testDataTableName = `Data Table ${nanoid(8)}`;
await n8n.projectTabs.clickDataTablesTab();
await n8n.dataTable.clickAddDataTableAction();
await n8n.dataTableComposer.createNewDataTable(testDataTableName);
});
test('Should display empty state with default columns', async ({ n8n }) => {
const dataTableDetailsContainer = n8n.dataTableDetails.getPageWrapper();
await expect(dataTableDetailsContainer).toBeVisible();
const emptyStateMessage = n8n.dataTableDetails.getNoRowsMessage();
await expect(emptyStateMessage).toBeVisible();
const visibleColumns = n8n.dataTableDetails.getVisibleColumns();
await expect(visibleColumns).toHaveCount(3);
await expect(n8n.dataTableDetails.getColumnHeaderByName('id')).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName('createdAt')).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName('updatedAt')).toBeVisible();
});
test('Should add columns of different types and rows from the header buttons', async ({
n8n,
}) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
const columnIds = await addColumnsAndGetIds(n8n, 'header');
const testData = generateTestData();
await n8n.dataTableDetails.addRow();
await expect(n8n.dataTableDetails.getNoRowsMessage()).toBeHidden();
await fillRowData(n8n, 0, columnIds, testData[0], true);
await n8n.dataTableDetails.addRow();
await fillRowData(n8n, 1, columnIds, testData[1], true);
await n8n.dataTableDetails.addRow();
await fillRowData(n8n, 2, columnIds, testData[2], true);
await verifyCellValues(n8n, columnIds, testData);
});
test('Should add columns of different types and rows from the table buttons', async ({
n8n,
}) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
const columnIds = await addColumnsAndGetIds(n8n, 'table');
const testData = generateTestData();
await n8n.dataTableDetails.addRowFromTable();
await expect(n8n.dataTableDetails.getNoRowsMessage()).toBeHidden();
await fillRowData(n8n, 0, columnIds, testData[0], true);
await n8n.dataTableDetails.addRowFromTable();
await fillRowData(n8n, 1, columnIds, testData[1], true);
await n8n.dataTableDetails.addRowFromTable();
await fillRowData(n8n, 2, columnIds, testData[2], true);
await verifyCellValues(n8n, columnIds, testData);
});
test('Should automatically move to second page when adding 21st row', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
for (let i = 0; i < 20; i++) {
await n8n.dataTableDetails.addRow();
// This loop was enough to break it, as we'd get transaction lock errors
// eslint-disable-next-line playwright/no-wait-for-timeout
await n8n.page.waitForTimeout(150);
}
const rowsOnPage1 = n8n.dataTableDetails.getDataRows();
await expect(rowsOnPage1).toHaveCount(20);
await n8n.dataTableDetails.addRow();
const rowsOnPage2 = n8n.dataTableDetails.getDataRows();
await expect(rowsOnPage2).toHaveCount(1);
});
test('Should select and delete rows', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
const nameColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name);
const rowData = ['Row 1', 'Row 2', 'Row 3', 'Row 4', 'Row 5'];
for (let i = 0; i < rowData.length; i++) {
await n8n.dataTableDetails.addRow();
// eslint-disable-next-line playwright/no-wait-for-timeout
await n8n.page.waitForTimeout(150);
await n8n.dataTableDetails.setCellValue(i, nameColumn, rowData[i], 'string', {
skipDoubleClick: true,
});
}
const initialRows = n8n.dataTableDetails.getDataRows();
await expect(initialRows).toHaveCount(5);
await n8n.dataTableDetails.selectRow(1);
await n8n.dataTableDetails.selectRow(3);
const selectedItemsInfo = n8n.dataTableDetails.getSelectedItemsInfo();
await expect(selectedItemsInfo).toBeVisible();
await expect(selectedItemsInfo).toContainText('2');
await n8n.dataTableDetails.deleteSelectedRows();
const remainingRows = n8n.dataTableDetails.getDataRows();
await expect(remainingRows).toHaveCount(3);
await expect(selectedItemsInfo).toBeHidden();
const row0Value = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(row0Value).toContain('Row 1');
const row1Value = await n8n.dataTableDetails.getCellValue(1, nameColumn, 'string');
expect(row1Value).toContain('Row 3');
const row2Value = await n8n.dataTableDetails.getCellValue(2, nameColumn, 'string');
expect(row2Value).toContain('Row 5');
});
test('Should clear selection', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
const nameColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name);
for (let i = 0; i < 3; i++) {
await n8n.dataTableDetails.addRow();
await n8n.dataTableDetails.setCellValue(i, nameColumn, `Row ${i + 1}`, 'string', {
skipDoubleClick: true,
});
}
await n8n.dataTableDetails.selectRow(0);
await n8n.dataTableDetails.selectRow(1);
await n8n.dataTableDetails.selectRow(2);
const selectedItemsInfo = n8n.dataTableDetails.getSelectedItemsInfo();
await expect(selectedItemsInfo).toBeVisible();
await expect(selectedItemsInfo).toContainText('3');
await n8n.dataTableDetails.clearSelection();
await expect(selectedItemsInfo).toBeHidden();
const rows = n8n.dataTableDetails.getDataRows();
await expect(rows).toHaveCount(3);
});
test('Should add columns of each type with rows and then delete all columns', async ({
n8n,
}) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
const columnIds = await addColumnsAndGetIds(n8n, 'header');
const testData = generateTestData().slice(0, 2);
await n8n.dataTableDetails.addRow();
await fillRowData(n8n, 0, columnIds, testData[0], true);
await n8n.dataTableDetails.addRow();
await fillRowData(n8n, 1, columnIds, testData[1], true);
const rows = n8n.dataTableDetails.getDataRows();
await expect(rows).toHaveCount(2);
await n8n.dataTableDetails.deleteColumn(COLUMN_NAMES.name);
await expect(n8n.dataTableDetails.getVisibleColumns()).toHaveCount(6);
await n8n.dataTableDetails.deleteColumn(COLUMN_NAMES.age);
await expect(n8n.dataTableDetails.getVisibleColumns()).toHaveCount(5);
await n8n.dataTableDetails.deleteColumn(COLUMN_NAMES.active);
await expect(n8n.dataTableDetails.getVisibleColumns()).toHaveCount(4);
await n8n.dataTableDetails.deleteColumn(COLUMN_NAMES.birthday);
await expect(n8n.dataTableDetails.getVisibleColumns()).toHaveCount(3);
await expect(n8n.dataTableDetails.getColumnHeaderByName('id')).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName('createdAt')).toBeVisible();
await expect(n8n.dataTableDetails.getColumnHeaderByName('updatedAt')).toBeVisible();
await expect(rows).toHaveCount(2);
});
test('Should rename data table from breadcrumbs', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
const nameBreadcrumb = n8n.dataTableDetails.getDataTableBreadcrumb();
const initialName = (await nameBreadcrumb.textContent())?.toString();
const newName = `Renamed Table ${nanoid(8)}`;
await n8n.dataTableDetails.renameDataTable(newName);
await expect(nameBreadcrumb).toContainText(newName);
expect(initialName).not.toEqual(newName);
});
test.fixme('Should filter correctly using column filters', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.setPageSize('10');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.age, 'number', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.active, 'boolean', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.birthday, 'date', 'header');
const nameColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name);
const ageColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.age);
const activeColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.active);
const birthdayColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.birthday);
const rowsData = [
{ name: 'User 1', age: '20', active: 'true', birthday: '2024-01-01' },
{ name: 'User 2', age: '21', active: 'true', birthday: '2024-01-02' },
{ name: 'User 3', age: '22', active: 'true', birthday: '2024-01-03' },
{ name: 'User 4', age: '23', active: 'true', birthday: '2024-01-04' },
{ name: 'User 5', age: '24', active: 'true', birthday: '2024-01-05' },
{ name: 'User 6', age: '25', active: 'false', birthday: '2024-01-06' },
{ name: 'User 7', age: '20', active: 'false', birthday: '2024-01-07' },
{ name: 'User 8', age: '21', active: 'false', birthday: '2024-01-08' },
{ name: 'User 9', age: '22', active: 'false', birthday: '2024-01-09' },
{ name: 'User 10', age: '23', active: 'false', birthday: '2024-01-10' },
{ name: 'User 11', age: '24', active: 'true', birthday: '2024-01-11' },
{ name: 'User 12', age: '25', active: 'true', birthday: '2024-01-12' },
{ name: 'User 13', age: '20', active: 'true', birthday: '2024-01-13' },
{ name: 'User 14', age: '21', active: 'false', birthday: '2024-01-14' },
{ name: 'User 15', age: '22', active: 'false', birthday: '2024-01-15' },
] as const;
for (let i = 0; i < 15; i++) {
await n8n.dataTableDetails.addRow();
const rowIndexOnPage = i % 10;
await n8n.dataTableDetails.setCellValue(
rowIndexOnPage,
nameColumn,
rowsData[i].name,
'string',
{
skipDoubleClick: true,
},
);
await n8n.dataTableDetails.setCellValue(
rowIndexOnPage,
ageColumn,
rowsData[i].age,
'number',
);
await n8n.dataTableDetails.setCellValue(
rowIndexOnPage,
activeColumn,
rowsData[i].active,
'boolean',
);
await n8n.dataTableDetails.setCellValue(
rowIndexOnPage,
birthdayColumn,
rowsData[i].birthday,
'date',
);
}
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(5);
await n8n.dataTableDetails.setTextFilter(COLUMN_NAMES.name, 'User 1');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(7);
await n8n.dataTableDetails.clearColumnFilter(COLUMN_NAMES.name);
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(10);
await n8n.dataTableDetails.setNumberFilter(COLUMN_NAMES.age, '22', 'greaterThan');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
await n8n.dataTableDetails.clearColumnFilter(COLUMN_NAMES.age);
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(10);
await n8n.dataTableDetails.setBooleanFilter(COLUMN_NAMES.active, true);
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(8);
await n8n.dataTableDetails.clearColumnFilter(COLUMN_NAMES.active);
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(10);
await n8n.dataTableDetails.setDateFilter(COLUMN_NAMES.birthday, '2024-01-10', 'greaterThan');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(5);
await n8n.dataTableDetails.clearColumnFilter(COLUMN_NAMES.birthday);
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(10);
await n8n.dataTableDetails.setBooleanFilter(COLUMN_NAMES.active, true);
await n8n.dataTableDetails.setNumberFilter(COLUMN_NAMES.age, '22', 'greaterThan');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(4);
});
test('Should reorder columns using drag and drop', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.age, 'number', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.active, 'boolean', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.birthday, 'date', 'header');
await n8n.dataTableDetails.addColumn('email', 'string', 'header');
const initialOrder = await n8n.dataTableDetails.getColumnOrder();
expect(initialOrder).toContain(COLUMN_NAMES.name);
expect(initialOrder).toContain(COLUMN_NAMES.age);
expect(initialOrder).toContain(COLUMN_NAMES.active);
expect(initialOrder).toContain(COLUMN_NAMES.birthday);
expect(initialOrder).toContain('email');
const nameIndex = initialOrder.indexOf(COLUMN_NAMES.name);
const activeIndex = initialOrder.indexOf(COLUMN_NAMES.active);
const emailIndex = initialOrder.indexOf('email');
await n8n.dataTableDetails.dragColumnToPosition(COLUMN_NAMES.active, COLUMN_NAMES.name);
const orderAfterFirstDrag = await n8n.dataTableDetails.getColumnOrder();
const newActiveIndex = orderAfterFirstDrag.indexOf(COLUMN_NAMES.active);
const newNameIndex = orderAfterFirstDrag.indexOf(COLUMN_NAMES.name);
expect(newActiveIndex).toBeLessThan(newNameIndex);
expect(activeIndex).toBeGreaterThan(nameIndex);
await n8n.dataTableDetails.dragColumnToPosition('email', COLUMN_NAMES.age);
const orderAfterSecondDrag = await n8n.dataTableDetails.getColumnOrder();
const emailIndexAfter = orderAfterSecondDrag.indexOf('email');
const ageIndexAfter = orderAfterSecondDrag.indexOf(COLUMN_NAMES.age);
expect(emailIndexAfter).toBeLessThan(ageIndexAfter);
expect(emailIndex).toBeGreaterThan(initialOrder.indexOf(COLUMN_NAMES.age));
await n8n.dataTableDetails.dragColumnToPosition(COLUMN_NAMES.birthday, COLUMN_NAMES.name);
const finalOrder = await n8n.dataTableDetails.getColumnOrder();
const birthdayFinalIndex = finalOrder.indexOf(COLUMN_NAMES.birthday);
const nameFinalIndex = finalOrder.indexOf(COLUMN_NAMES.name);
expect(birthdayFinalIndex).toBeLessThan(nameFinalIndex);
});
test('Should search and filter rows globally', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.age, 'number', 'header');
const nameColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name);
const ageColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.age);
const testData = [
{ name: 'Alice Johnson', age: '25' },
{ name: 'Bob Smith', age: '30' },
{ name: 'Charlie Brown', age: '35' },
{ name: 'Diana Prince', age: '28' },
{ name: 'Eve Adams', age: '32' },
{ name: 'Frank Miller', age: '29' },
];
for (let i = 0; i < testData.length; i++) {
await n8n.dataTableDetails.addRow();
await n8n.dataTableDetails.setCellValue(i, nameColumn, testData[i].name, 'string', {
skipDoubleClick: true,
});
await n8n.dataTableDetails.setCellValue(i, ageColumn, testData[i].age, 'number');
}
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
// Test search for partial name match
await n8n.dataTableDetails.search('Alice');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const aliceValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(aliceValue).toContain('Alice Johnson');
// Test search for last name
await n8n.dataTableDetails.search('Smith');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const bobValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(bobValue).toContain('Bob Smith');
// Test search across all columns (search by age)
await n8n.dataTableDetails.search('30');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const bobAgeValue = await n8n.dataTableDetails.getCellValue(0, ageColumn, 'number');
expect(bobAgeValue).toContain('30');
// Test search with multiple results
await n8n.dataTableDetails.search('a');
const multipleResultsCount = await n8n.dataTableDetails.getDataRows().count();
expect(multipleResultsCount).toBeGreaterThan(1);
// Clear search and verify all rows are shown
await n8n.dataTableDetails.clearSearch();
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
// Test case-insensitive search
await n8n.dataTableDetails.search('ALICE');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const aliceCaseValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(aliceCaseValue).toContain('Alice Johnson');
// Clear search for next test
await n8n.dataTableDetails.clearSearch();
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
// test search combined with column filter
await n8n.dataTableDetails.setNumberFilter(COLUMN_NAMES.age, '29', 'greaterThan');
await n8n.dataTableDetails.search('Adams');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const adamValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(adamValue).toContain('Eve Adams');
});
},
);
@@ -0,0 +1,160 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.describe('Data Table list view', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n, api }) => {
await api.enableFeature('sharing');
await api.enableFeature('folders');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.goHome();
});
test('Should correctly render project data tables in project and everything in overview', async ({
n8n,
}) => {
const TEST_PROJECTS = [
{
name: `Project ${nanoid(8)}`,
dataTable: `Data Table ${nanoid(8)}`,
},
{
name: `Project ${nanoid(8)}`,
dataTable: `Data Table ${nanoid(8)}`,
},
];
// Create projects and check that they only render their own data tables
for (const project of TEST_PROJECTS) {
await n8n.dataTableComposer.createDataTableInNewProject(
project.name,
project.dataTable,
'empty-state',
);
await expect(n8n.dataTable.getDataTableCardByName(project.dataTable)).toBeVisible();
await expect(n8n.dataTable.getDataTableCards()).toHaveCount(1);
}
// Go to overview, both data tables should be visible
await n8n.navigate.toDatatables();
for (const project of TEST_PROJECTS) {
await expect(n8n.dataTable.getDataTableCardByName(project.dataTable)).toBeVisible();
}
});
test('Should create data table in personal project when created from Overview', async ({
n8n,
}) => {
const TEST_DATA_TABLE_NAME = `Data Table ${nanoid(8)}`;
await n8n.page.goto('projects/home/datatables');
await n8n.dataTable.clickAddDataTableAction();
const newDataTableModal = n8n.dataTable.getNewDataTableModal();
await expect(newDataTableModal).toBeVisible();
await n8n.dataTableComposer.createNewDataTable(TEST_DATA_TABLE_NAME);
const dataTableDetailsContainer = n8n.dataTableDetails.getPageWrapper();
await expect(dataTableDetailsContainer).toBeVisible();
const dataTableProjectBreadcrumb = n8n.dataTableDetails.getDataTableProjectBreadcrumb();
await expect(dataTableProjectBreadcrumb).toHaveText('Personal');
const dataTableBreadcrumb = n8n.dataTableDetails.getDataTableBreadcrumb();
await expect(dataTableBreadcrumb).toContainText(TEST_DATA_TABLE_NAME);
});
test('Should create data table from project empty state', async ({ n8n }) => {
const TEST_PROJECT_NAME = `Project ${nanoid(8)}`;
const TEST_DATA_TABLE_NAME = `Data Table ${nanoid(8)}`;
await n8n.dataTableComposer.createDataTableInNewProject(
TEST_PROJECT_NAME,
TEST_DATA_TABLE_NAME,
'empty-state',
);
await expect(n8n.dataTable.getDataTableCardByName(TEST_DATA_TABLE_NAME)).toBeVisible();
});
test('Should create project data table from header dropdown', async ({ n8n }) => {
const TEST_PROJECT_NAME = `Project ${nanoid(8)}`;
const TEST_DATA_TABLE_NAME = `Data Table ${nanoid(8)}`;
await n8n.dataTableComposer.createDataTableInNewProject(
TEST_PROJECT_NAME,
TEST_DATA_TABLE_NAME,
'header-dropdown',
);
await expect(n8n.dataTable.getDataTableCardByName(TEST_DATA_TABLE_NAME)).toBeVisible();
});
test('Should create data table from workflows tab', async ({ n8n }) => {
const TEST_PROJECT_NAME = `Project ${nanoid(8)}`;
const TEST_DATA_TABLE_NAME = `Data Table ${nanoid(8)}`;
await n8n.dataTableComposer.createDataTableInNewProject(
TEST_PROJECT_NAME,
TEST_DATA_TABLE_NAME,
'header-dropdown',
false,
);
await expect(n8n.dataTable.getDataTableCardByName(TEST_DATA_TABLE_NAME)).toBeVisible();
});
test('Should delete data table from card actions', async ({ n8n }) => {
const TEST_PROJECT_NAME = `Project ${nanoid(8)}`;
const TEST_DATA_TABLE_NAME = `Data Table ${nanoid(8)}`;
await n8n.dataTableComposer.createDataTableInNewProject(
TEST_PROJECT_NAME,
TEST_DATA_TABLE_NAME,
'empty-state',
);
await n8n.dataTable.clickDataTableCardActionsButton(TEST_DATA_TABLE_NAME);
await n8n.dataTable.getDataTableCardAction('delete').click();
await expect(n8n.dataTable.getDeleteDataTableModal()).toBeVisible();
await n8n.dataTable.clickDeleteDataTableConfirmButton();
await expect(n8n.dataTable.getDataTableCardByName(TEST_DATA_TABLE_NAME)).toBeHidden();
});
test('Should paginate data table list correctly', async ({ n8n }) => {
const TEST_PROJECT_NAME = `Project ${nanoid(8)}`;
const TOTAL_DATA_TABLES = 11;
const PAGE_SIZE = 10;
const { projectId } = await n8n.projectComposer.createProject(TEST_PROJECT_NAME);
await n8n.page.goto(`projects/${projectId}/datatables`);
// Create just enough data tables to require pagination
for (let i = 0; i < TOTAL_DATA_TABLES; i++) {
await n8n.dataTable.clickAddDataTableAction();
await n8n.dataTableComposer.createNewDataTable(`Data Table ${i + 1}`);
await n8n.sideBar.clickProjectMenuItem(TEST_PROJECT_NAME);
await n8n.dataTable.clickDataTableProjectTab();
}
// Change page size to PAGE_SIZE
await n8n.dataTable.selectDataTablePageSize(PAGE_SIZE.toString());
// First page should only have PAGE_SIZE items
await expect(n8n.dataTable.getDataTableCards()).toHaveCount(PAGE_SIZE);
// Forward to next page, should show the rest
await n8n.dataTable.getPaginationNextButton().click();
await expect(n8n.dataTable.getDataTableCards()).toHaveCount(TOTAL_DATA_TABLES - PAGE_SIZE);
});
});
@@ -0,0 +1,217 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
import { DYNAMIC_CRED_ENDPOINT_TOKEN } from '../../../services/dynamic-credential-api-helper';
/**
* E2E tests for the dynamic credentials feature.
*
* Requires:
* - capability: 'dynamic-credentials' (Keycloak container + env vars)
* - api.enableFeature('dynamicCredentials') (license feature)
*/
test.use({
capability: 'dynamic-credentials',
ignoreHTTPSErrors: true, // Keycloak uses a self-signed certificate
});
/**
* Tests for the execution-status endpoint: external (marketplace) users
* checking whether their credentials are configured for a given workflow.
*
* Architecture under test:
* External user → GET /rest/workflows/:id/execution-status
* → X-Authorization authenticates the request to n8n
* → Bearer token extracted from Authorization header for credential context
* → Token validated against Keycloak (userinfo endpoint)
* → Credential status returned (missing / configured)
*/
test.describe(
'Dynamic Credentials: execution-status @capability:dynamic-credentials',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
/**
* Happy path: external user calls execution-status with a valid Keycloak bearer token.
* The credential is not yet authorized for that user → status should be "missing"
* and an authorizationUrl should be provided to start the OAuth2 flow.
*/
test('should report credentials as missing for a new external user @auth:owner', async ({
api,
services,
}) => {
const keycloak = services.keycloak;
// Create an OAuth2 resolver that validates tokens via Keycloak's userinfo endpoint.
// Uses the internal URL so the n8n container can reach Keycloak directly.
const resolver = await api.dynamicCredentials.createResolver({
name: `Keycloak Resolver ${nanoid()}`,
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: keycloak.internalDiscoveryUrl,
validation: 'oauth2-userinfo',
},
});
// Create an OAuth2 credential flagged as resolvable (no static data needed)
const credential = await api.credentials.createCredential({
name: `Resolvable OAuth2 Credential ${nanoid()}`,
type: 'oAuth2Api',
data: { grantType: 'authorizationCode' },
isResolvable: true,
});
// Create a workflow that uses that credential, with the resolver as workflow-level fallback
const workflow = await api.workflows.createWorkflow({
name: `Dynamic Credential Workflow ${nanoid()}`,
nodes: [
{
id: nanoid(),
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0] as [number, number],
parameters: {},
credentials: {
oAuth2Api: { id: credential.id, name: credential.name },
},
},
],
connections: {},
settings: {
// Workflow-level resolver used as fallback for all resolvable credentials
credentialResolverId: resolver.id,
},
});
// Obtain a real access token for the Keycloak test user via ROPC (no browser needed)
const accessToken = await keycloak.getAccessToken(
keycloak.testUser.email,
keycloak.testUser.password,
);
// External (unauthenticated) call:
// - X-Authorization authenticates the request to n8n
// - Authorization: Bearer provides the user identity for credential resolution
const status = await api.dynamicCredentials.getExecutionStatus(workflow.id, {
bearerToken: accessToken,
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
});
expect(status.workflowId).toBe(workflow.id);
expect(status.readyToExecute).toBe(false);
expect(status.credentials).toHaveLength(1);
const credentialStatus = status.credentials![0];
expect(credentialStatus.credentialId).toBe(credential.id);
expect(credentialStatus.credentialStatus).toBe('missing');
expect(credentialStatus.credentialType).toBe('oAuth2Api');
// authorizationUrl must be present so the user can start the OAuth2 authorization flow
expect(credentialStatus.authorizationUrl).toBeTruthy();
expect(credentialStatus.authorizationUrl).toContain(credential.id);
expect(credentialStatus.authorizationUrl).toContain('authorize');
// revokeUrl must also be present
expect(credentialStatus.revokeUrl).toBeTruthy();
expect(credentialStatus.revokeUrl).toContain(credential.id);
expect(credentialStatus.revokeUrl).toContain('revoke');
});
/**
* Happy path: external user has already completed the OAuth2 authorization flow.
* The credential is stored in dynamic_credential_entry for this user →
* readyToExecute should be true and credentialStatus should be "configured".
*/
test('should report ready when workflow has resolvable credentials with existing entries for user @auth:owner', async ({
api,
services,
}) => {
const keycloak = services.keycloak;
const externalBase = keycloak.discoveryUrl.replace('/.well-known/openid-configuration', '');
const internalBase = keycloak.internalDiscoveryUrl.replace(
'/.well-known/openid-configuration',
'',
);
// Obtain a Keycloak access token for the test user (ROPC — no browser needed)
const accessToken = await keycloak.getAccessToken(
keycloak.testUser.email,
keycloak.testUser.password,
);
// Create an OAuth2 resolver that validates tokens via Keycloak's userinfo endpoint
const resolver = await api.dynamicCredentials.createResolver({
name: `Keycloak Resolver ${nanoid()}`,
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: keycloak.internalDiscoveryUrl,
validation: 'oauth2-userinfo',
},
});
// Create a properly-configured oAuth2Api credential pointing at Keycloak.
// The credential is resolvable — its tokens are stored per-user by the resolver.
const credential = await api.credentials.createCredential({
name: `Keycloak OAuth2 Credential ${nanoid()}`,
type: 'oAuth2Api',
data: {
grantType: 'authorizationCode',
authUrl: `${externalBase}/protocol/openid-connect/auth`,
accessTokenUrl: `${internalBase}/protocol/openid-connect/token`,
clientId: keycloak.clientId,
clientSecret: keycloak.clientSecret,
scope: 'openid',
ignoreSSLIssues: true,
},
isResolvable: true,
});
// Create a workflow that uses that credential
const workflow = await api.workflows.createWorkflow({
name: `Configured Credential Workflow ${nanoid()}`,
nodes: [
{
id: nanoid(),
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0] as [number, number],
parameters: {},
credentials: {
oAuth2Api: { id: credential.id, name: credential.name },
},
},
],
connections: {},
settings: {
credentialResolverId: resolver.id,
},
});
// Complete the OAuth2 authorization code flow for the test user.
// This stores the user's Keycloak tokens in the dynamic_credential_entry table.
const keycloakAuthUrl = await api.dynamicCredentials.getAuthorizationUrl(
credential.id,
resolver.id,
accessToken,
);
const n8nCallbackUrl = await keycloak.completeAuthorizationCodeFlow(keycloakAuthUrl);
// GET the n8n callback with the owner session: n8n exchanges the code and stores tokens
await api.request.get(n8nCallbackUrl);
// Credential is now configured for this user → readyToExecute should be true
const status = await api.dynamicCredentials.getExecutionStatus(workflow.id, {
bearerToken: accessToken,
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
});
expect(status.workflowId).toBe(workflow.id);
expect(status.readyToExecute).toBe(true);
expect(status.credentials).toHaveLength(1);
expect(status.credentials![0].credentialStatus).toBe('configured');
});
},
);
@@ -0,0 +1,208 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
import { DYNAMIC_CRED_ENDPOINT_TOKEN } from '../../../services/dynamic-credential-api-helper';
/**
* E2E tests for the dynamic credentials feature.
*
* Requires:
* - capability: 'dynamic-credentials' (Keycloak container + env vars)
* - api.enableFeature('dynamicCredentials') (license feature)
*/
test.use({
capability: 'dynamic-credentials',
ignoreHTTPSErrors: true, // Keycloak uses a self-signed certificate
});
/**
* Integration test: external user triggers a workflow via a production webhook.
* The resolvable oAuth2Api credential is pre-authorized via the Keycloak authorization
* code flow, then the HTTP Request node uses it to call the Keycloak userinfo endpoint.
*
* Flow:
* 1. Create OAuth2 resolver + resolvable oAuth2Api credential (configured for Keycloak)
* 2. Build the workflow (webhook + HTTP Request using the credential) — not yet active
* 3. Get Keycloak access token (ROPC — identifies the external user)
* 4. Call execution-status → credential reports "missing" → extract authorizationUrl
* 5. POST to authorizationUrl → Keycloak login page → complete authorization code flow
* 6. n8n callback stores user's tokens in dynamic_credential_entry
* 7. Verify execution-status now reports credential as "configured"
* 8. Activate the workflow (webhook + HTTP Request node using the credential)
* 9. Trigger the production webhook with the bearer token
* 10. Wait for execution and assert success (HTTP node resolved credential + called userinfo)
*/
test.describe(
'Dynamic Credentials: webhook execution @capability:dynamic-credentials',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test('should execute HTTP node with resolvable OAuth2 credential via production webhook @auth:owner', async ({
api,
services,
}) => {
const keycloak = services.keycloak;
// Derive Keycloak endpoint URLs from the discovery URL.
// authUrl: EXTERNAL URL — the test machine visits this for the authorization redirect.
// accessTokenUrl: INTERNAL URL — n8n exchanges the auth code server-to-server.
const externalBase = keycloak.discoveryUrl.replace('/.well-known/openid-configuration', '');
const internalBase = keycloak.internalDiscoveryUrl.replace(
'/.well-known/openid-configuration',
'',
);
// Create an OAuth2 resolver that validates tokens via Keycloak's userinfo endpoint
const resolver = await api.dynamicCredentials.createResolver({
name: `Keycloak OAuth2 Resolver ${nanoid()}`,
type: 'credential-resolver.oauth2-1.0',
config: {
metadataUri: keycloak.internalDiscoveryUrl,
validation: 'oauth2-userinfo',
},
});
// Create a properly-configured oAuth2Api credential pointing at Keycloak.
// The credential is resolvable — its tokens are stored per-user by the resolver.
const credential = await api.credentials.createCredential({
name: `Keycloak OAuth2 Credential ${nanoid()}`,
type: 'oAuth2Api',
data: {
grantType: 'authorizationCode',
authUrl: `${externalBase}/protocol/openid-connect/auth`,
accessTokenUrl: `${internalBase}/protocol/openid-connect/token`,
clientId: keycloak.clientId,
clientSecret: keycloak.clientSecret,
scope: 'openid',
ignoreSSLIssues: true,
},
isResolvable: true,
});
// Build a workflow: webhook trigger → HTTP Request (calls Keycloak userinfo with credential)
// The workflow is created BEFORE authorization so we can obtain the authorizationUrl
// from the execution-status endpoint (the real flow a marketplace user would follow).
const { workflowId, webhookPath, createdWorkflow } =
await api.workflows.createWorkflowFromDefinition({
name: `Dynamic Credential HTTP Webhook Workflow ${nanoid()}`,
nodes: [
{
id: nanoid(),
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [0, 0] as [number, number],
parameters: {
httpMethod: 'GET',
path: 'placeholder',
responseMode: 'onReceived', // Respond immediately; execution runs async
// Configure the execution context hook to extract the bearer token
// from the Authorization header. Without this, the dynamic credential
// resolver can't identify the user during execution.
executionsHooksVersion: 1,
contextEstablishmentHooks: {
hooks: [
{
hookName: 'BearerTokenExtractor',
isAllowedToFail: false,
},
],
},
},
},
{
id: nanoid(),
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0] as [number, number],
parameters: {
// Keycloak userinfo endpoint — accepts Bearer tokens and returns user info (200)
url: `${internalBase}/protocol/openid-connect/userinfo`,
authentication: 'predefinedCredentialType',
nodeCredentialType: 'oAuth2Api',
},
credentials: {
oAuth2Api: { id: credential.id, name: credential.name },
},
},
],
connections: {
Webhook: {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
},
},
settings: {
credentialResolverId: resolver.id,
},
});
// Obtain a Keycloak access token for the test user (ROPC — no browser needed).
// This token is used as the user identity throughout the flow.
const accessToken = await keycloak.getAccessToken(
keycloak.testUser.email,
keycloak.testUser.password,
);
// Step 1: Check execution-status before authorization.
// The credential is not yet configured → status is "missing".
// The response includes an authorizationUrl pointing to the n8n authorize endpoint.
const initialStatus = await api.dynamicCredentials.getExecutionStatus(workflowId, {
bearerToken: accessToken,
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
});
expect(initialStatus.credentials).toHaveLength(1);
expect(initialStatus.credentials![0].credentialStatus).toBe('missing');
// Step 2: Use the authorizationUrl from execution-status to start the OAuth2 flow.
// This is the URL a real marketplace user would follow after seeing "missing" status.
const n8nAuthorizeUrl = initialStatus.credentials![0].authorizationUrl!;
expect(n8nAuthorizeUrl).toBeTruthy();
// POST to the n8n authorize endpoint → returns the Keycloak authorization page URL
const keycloakAuthUrl = await api.dynamicCredentials.startAuthorizationFromStatusUrl(
n8nAuthorizeUrl,
accessToken,
);
// Step 3: Complete the Keycloak authorization code flow for the test user.
// Navigates Keycloak's login form and returns the n8n callback URL (with code + state).
const n8nCallbackUrl = await keycloak.completeAuthorizationCodeFlow(keycloakAuthUrl);
// GET the n8n callback with the owner session: n8n exchanges the code and stores tokens
await api.request.get(n8nCallbackUrl);
// Activate the workflow to register the production webhook URL
await api.workflows.activate(workflowId, createdWorkflow.versionId as string);
try {
// Verify the credential is now "configured" for this user before triggering
const status = await api.dynamicCredentials.getExecutionStatus(workflowId, {
bearerToken: accessToken,
endpointToken: DYNAMIC_CRED_ENDPOINT_TOKEN,
});
expect(status.credentials).toHaveLength(1);
expect(status.credentials![0].credentialStatus).toBe('configured');
// Trigger the production webhook with the bearer token.
// n8n extracts the token from the Authorization header for credential resolution.
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath!}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
maxNotFoundRetries: 5,
});
expect(webhookResponse.status()).toBe(200);
// Wait for the async execution to complete.
// The HTTP Request node resolves the credential → injects Bearer token → calls Keycloak userinfo → 200
const execution = await api.workflows.waitForExecution(workflowId, 15000);
expect((execution as unknown as { status: string }).status).toBe('success');
} finally {
// Deactivate to prevent orphaned active webhooks after the test
await api.workflows.deactivate(workflowId);
}
});
},
);
@@ -0,0 +1,399 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
/**
* E2E tests for the Internal MCP Service (/mcp-server/http).
*
* This tests the built-in MCP server that exposes n8n workflows to external
* MCP clients (like Claude AI). It provides 3 tools:
* - search_workflows: Search for workflows available in MCP
* - get_workflow_details: Get detailed information about a workflow
* - execute_workflow: Execute a workflow and get results
*
* Authentication is via Bearer token (MCP API key).
*
* NOTE: Tests run serially because n8n only supports ONE MCP API key at a time.
* Each test uses rotateMcpApiKey() to get a usable key (since getMcpApiKey()
* returns REDACTED after the first call), and rotation invalidates the previous
* key. Running in parallel would cause race conditions where tests invalidate
* each other's keys.
*/
test.describe(
'MCP Service',
{
annotation: [{ type: 'owner', description: 'AI' }],
},
() => {
// Run tests serially - n8n only supports one MCP API key at a time,
// and rotation invalidates the previous key
test.describe.configure({ mode: 'serial' });
// Enable MCP access before each test
test.beforeEach(async ({ api }) => {
await api.setMcpAccess(true);
});
test.describe('Authentication', () => {
test('should reject requests without bearer token', async ({ api }) => {
const message = api.mcp.createMessage('tools/list');
const response = await api.mcp.internalMcpSendMessageNoAuth(message);
expect(response.status()).toBe(401);
});
test('should reject requests with invalid bearer token', async ({ api }) => {
const message = api.mcp.createMessage('tools/list');
const response = await api.mcp.internalMcpSendMessageNoAuth(message, {
Authorization: 'Bearer invalid-token-12345',
});
expect(response.status()).toBe(401);
});
test('should accept valid API key', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const message = api.mcp.createMessage('tools/list');
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
expect(response.status()).toBeLessThan(300);
});
test('should reject requests after key rotation with old key', async ({ api }) => {
const { apiKey: oldKey } = await api.rotateMcpApiKey();
const { apiKey: newKey } = await api.rotateMcpApiKey();
const message = api.mcp.createMessage('tools/list');
const responseWithOldKey = await api.mcp.internalMcpSendMessageNoAuth(message, {
Authorization: `Bearer ${oldKey}`,
});
expect(responseWithOldKey.status()).toBe(401);
const responseWithNewKey = await api.mcp.internalMcpSendMessage(newKey, message);
expect(responseWithNewKey.status()).toBeLessThan(300);
});
});
test.describe('MCP Settings', () => {
test('should reject when MCP access is disabled', async ({ api }) => {
await api.setMcpAccess(false);
try {
const { apiKey } = await api.rotateMcpApiKey();
const message = api.mcp.createMessage('tools/list');
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
expect(response.status()).toBe(403);
const body = await response.json();
expect(body.message).toContain('MCP access is disabled');
} finally {
await api.setMcpAccess(true);
}
});
});
test.describe('tools/list', () => {
test('should return all 3 built-in tools', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const tools = await api.mcp.internalMcpListTools(apiKey);
expect(tools).toHaveLength(3);
const toolNames = tools.map((t) => t.name).sort();
expect(toolNames).toEqual(['execute_workflow', 'get_workflow_details', 'search_workflows']);
});
test('should include proper tool descriptions and schemas', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const tools = await api.mcp.internalMcpListTools(apiKey);
const searchTool = tools.find((t) => t.name === 'search_workflows');
expect(searchTool).toBeDefined();
expect(searchTool!.description).toContain('Search');
expect(searchTool!.inputSchema).toBeDefined();
const detailsTool = tools.find((t) => t.name === 'get_workflow_details');
expect(detailsTool).toBeDefined();
expect(detailsTool!.description).toContain('workflow');
expect(detailsTool!.inputSchema).toBeDefined();
const executeTool = tools.find((t) => t.name === 'execute_workflow');
expect(executeTool).toBeDefined();
expect(executeTool!.description).toContain('Execute');
expect(executeTool!.inputSchema).toBeDefined();
});
});
test.describe('search_workflows', () => {
test('should return workflows marked as available in MCP', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpSearchWorkflows(apiKey);
expect(result.count).toBeGreaterThanOrEqual(1);
expect(result.data.length).toBeGreaterThanOrEqual(1);
const foundWorkflow = result.data.find((w) => w.id === workflowId);
expect(foundWorkflow).toBeDefined();
expect(foundWorkflow!.active).toBe(true);
expect(foundWorkflow!.scopes).toBeDefined();
expect(foundWorkflow!.availableInMCP).toBe(true);
});
test('should return workflows not marked as available in MCP with availableInMCP: false', async ({
api,
}) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-unavailable.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpSearchWorkflows(apiKey);
const foundWorkflow = result.data.find((w) => w.id === workflowId);
expect(foundWorkflow).toBeDefined();
expect(foundWorkflow!.availableInMCP).toBe(false);
});
test('should support limit parameter', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpSearchWorkflows(apiKey, { limit: 1 });
expect(result.data.length).toBeLessThanOrEqual(1);
});
test('should support query filter for name search', async ({ api }) => {
const uniqueName = `Searchable-${nanoid(8)}`;
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-basic.json',
{
transform: (wf) => {
wf.name = uniqueName;
return wf;
},
},
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpSearchWorkflows(apiKey, { query: uniqueName });
expect(result.data.length).toBe(1);
expect(result.data[0].id).toBe(workflowId);
});
test('should return workflow metadata (id, name, scopes)', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpSearchWorkflows(apiKey);
const foundWorkflow = result.data.find((w) => w.id === workflowId);
expect(foundWorkflow).toBeDefined();
expect(foundWorkflow!.id).toBe(workflowId);
expect(foundWorkflow!.name).toBeTruthy();
expect(foundWorkflow!.scopes).toBeInstanceOf(Array);
expect(typeof foundWorkflow!.canExecute).toBe('boolean');
expect(typeof foundWorkflow!.availableInMCP).toBe('boolean');
});
});
test.describe('get_workflow_details', () => {
test('should return detailed info for accessible workflow', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpGetWorkflowDetails(apiKey, workflowId);
expect(result.workflow).toBeDefined();
expect(result.workflow.id).toBe(workflowId);
expect(result.workflow.nodes).toBeDefined();
expect(result.workflow.connections).toBeDefined();
expect(result.workflow.settings).toBeDefined();
expect(result.workflow.scopes).toBeDefined();
expect(typeof result.workflow.canExecute).toBe('boolean');
});
test('should return error for non-existent workflow', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const fakeWorkflowId = 'nonexistent-workflow-id-12345';
await expect(
api.mcp.internalMcpGetWorkflowDetails(apiKey, fakeWorkflowId),
).rejects.toThrow();
});
test('should return error for workflow not available in MCP', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-unavailable.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
await expect(api.mcp.internalMcpGetWorkflowDetails(apiKey, workflowId)).rejects.toThrow();
});
test('should include trigger info in response', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-webhook.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpGetWorkflowDetails(apiKey, workflowId);
expect(result.triggerInfo).toBeDefined();
});
});
test.describe('execute_workflow', () => {
test('should execute workflow successfully', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, workflowId);
expect(result.success).toBe(true);
expect(result.executionId).toBeTruthy();
expect(result.result).toBeDefined();
});
test('should return error for non-existent workflow', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const fakeWorkflowId = 'nonexistent-workflow-id-12345';
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, fakeWorkflowId);
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
test('should return error for workflow not available in MCP', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-unavailable.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, workflowId);
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
test('should execute webhook workflow with inputs', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-service/mcp-available-webhook.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const { apiKey } = await api.rotateMcpApiKey();
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, workflowId, {
type: 'webhook',
webhookData: {
method: 'POST',
body: { message: 'Hello from MCP test' },
},
});
expect(result.success).toBe(true);
expect(result.executionId).toBeTruthy();
});
});
test.describe('Error Handling', () => {
test('should handle malformed JSON-RPC messages', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
// Missing required 'jsonrpc: "2.0"' field
const malformedMessage = {
id: nanoid(),
method: 'tools/list',
};
const response = await api.mcp.internalMcpSendMessage(apiKey, malformedMessage);
// Server returns 400 Bad Request for malformed JSON-RPC
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32700); // Parse error
expect(body.error.message).toBeTruthy();
});
test('should handle unknown methods', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const message = api.mcp.createMessage('unknown/method');
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
// Server returns 200 OK with SSE response containing error
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/event-stream');
// Parse SSE format: extract JSON from "data: {...}" line
const text = await response.text();
const dataLine = text.split('\n').find((line) => line.startsWith('data:'))!;
const body = JSON.parse(dataLine.slice(5).trim()) as {
error: { code: number; message: string };
};
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32601); // Method not found
expect(body.error.message).toBeTruthy();
});
test('should handle invalid tool parameters', async ({ api }) => {
const { apiKey } = await api.rotateMcpApiKey();
const message = api.mcp.createMessage('tools/call', {
name: 'search_workflows',
arguments: {
limit: 'not-a-number',
},
});
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
// Server returns 200 OK with SSE response
expect(response.ok()).toBe(true);
expect(response.headers()['content-type']).toContain('text/event-stream');
// Parse SSE format: extract JSON from "data: {...}" line
const text = await response.text();
const dataLine = text.split('\n').find((line) => line.startsWith('data:'))!;
const body = JSON.parse(dataLine.slice(5).trim()) as {
error?: unknown;
result?: unknown;
jsonrpc: string;
};
expect(body.jsonrpc).toBe('2.0');
// Should return either a JSON-RPC error or a successful result
expect(body.error !== undefined || body.result !== undefined).toBe(true);
});
});
},
);
@@ -0,0 +1,67 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Node Creator Actions', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should add node to canvas from actions panel', async ({ n8n }) => {
const editImageNode = 'Edit Image';
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor(editImageNode);
await n8n.canvas.nodeCreator.selectItem(editImageNode);
await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText(editImageNode);
await n8n.canvas.nodeCreator.selectItem('Crop Image');
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
test('should search through actions and confirm added action', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('ftp');
await n8n.canvas.nodeCreator.selectItem('FTP');
await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('FTP');
await n8n.canvas.nodeCreator.clearSearch();
await n8n.canvas.nodeCreator.searchFor('rename');
await n8n.canvas.nodeCreator.selectItem('Rename');
await expect(n8n.ndv.getContainer()).toBeVisible();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
test('should show multiple actions for multi-action nodes', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('OpenWeatherMap');
await n8n.canvas.nodeCreator.selectItem('OpenWeatherMap');
await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('OpenWeatherMap');
await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible();
await expect(n8n.canvas.nodeCreator.getNodeItems().nth(1)).toBeVisible();
await n8n.canvas.nodeCreator.getNodeItems().first().click();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
test('should add node with specific operation configuration', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('Slack');
await n8n.canvas.nodeCreator.selectItem('Slack');
await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('Slack');
await n8n.canvas.nodeCreator.getNodeItems().first().click();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
});
});
@@ -0,0 +1,105 @@
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe('Node Creator Categories', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should have "Actions" section collapsed when opening actions view from Trigger root view', async ({
n8n,
}) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('ActiveCampaign');
await n8n.canvas.nodeCreator.selectItem('ActiveCampaign');
await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions')).toBeVisible();
await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers')).toBeVisible();
await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers').locator('..')).toHaveAttribute(
'data-category-collapsed',
'false',
);
await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute(
'data-category-collapsed',
'true',
);
await n8n.canvas.nodeCreator.selectCategoryItem('Actions');
await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute(
'data-category-collapsed',
'false',
);
});
test('should have "Triggers" section collapsed when opening actions view from Regular root view', async ({
n8n,
}) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.nodeCreator.searchFor('n8n');
await n8n.canvas.nodeCreator.getNodeItems().filter({ hasText: 'n8n' }).first().click();
await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute(
'data-category-collapsed',
'false',
);
await n8n.canvas.nodeCreator.selectCategoryItem('Actions');
await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions').locator('..')).toHaveAttribute(
'data-category-collapsed',
'true',
);
await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers').locator('..')).toHaveAttribute(
'data-category-collapsed',
'true',
);
await n8n.canvas.nodeCreator.selectCategoryItem('Triggers');
await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers').locator('..')).toHaveAttribute(
'data-category-collapsed',
'false',
);
});
test('should show callout and two suggested nodes if node has no trigger actions', async ({
n8n,
}) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('Customer Datastore (n8n training)');
await n8n.canvas.nodeCreator.selectItem('Customer Datastore (n8n training)');
await expect(n8n.canvas.nodeCreator.getNoTriggersCallout()).toBeVisible();
await expect(n8n.canvas.nodeCreator.getItem('On a Schedule')).toBeVisible();
await expect(n8n.canvas.nodeCreator.getItem('On a Webhook call')).toBeVisible();
});
test('should show intro callout if user has not made a production execution', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('Customer Datastore (n8n training)');
await n8n.canvas.nodeCreator.selectItem('Customer Datastore (n8n training)');
await expect(n8n.canvas.nodeCreator.getActivationCallout()).toBeVisible();
});
test('should show Trigger and Actions sections during search', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('Customer Datastore (n8n training)');
await n8n.canvas.nodeCreator.selectItem('Customer Datastore (n8n training)');
await n8n.canvas.nodeCreator.searchFor('Non existent action name');
await expect(n8n.canvas.nodeCreator.getCategoryItem('Triggers')).toBeVisible();
await expect(n8n.canvas.nodeCreator.getCategoryItem('Actions')).toBeVisible();
await expect(n8n.canvas.nodeCreator.getNoTriggersCallout()).toBeVisible();
await expect(n8n.canvas.nodeCreator.getItem('On a Schedule')).toBeVisible();
await expect(n8n.canvas.nodeCreator.getItem('On a Webhook call')).toBeVisible();
});
});
@@ -0,0 +1,75 @@
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe('Node Creator Navigation', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should open node creator on trigger tab if no trigger is on canvas', async ({ n8n }) => {
await n8n.canvas.clickCanvasPlusButton();
await expect(n8n.canvas.nodeCreator.getRoot()).toBeVisible();
await expect(n8n.canvas.nodeCreator.getTriggerText()).toBeVisible();
});
test('should navigate subcategory and return', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.navigateToSubcategory('On app event');
await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).toContainText('On app event');
await n8n.canvas.nodeCreator.goBackFromSubcategory();
await expect(n8n.canvas.nodeCreator.getActiveSubcategory()).not.toContainText('On app event');
});
test('should search for nodes with various queries', async ({ n8n }) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('manual');
await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(1);
await n8n.canvas.nodeCreator.clearSearch();
await n8n.canvas.nodeCreator.searchFor('manual123');
await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(0);
await expect(n8n.canvas.nodeCreator.getNoResults()).toBeVisible();
await expect(n8n.canvas.nodeCreator.getNoResults()).toContainText("We didn't make that... yet");
await n8n.canvas.nodeCreator.clearSearch();
await n8n.canvas.nodeCreator.searchFor('edit image');
await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(1);
await n8n.canvas.nodeCreator.clearSearch();
await n8n.canvas.nodeCreator.searchFor('this node totally does not exist');
await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(0);
await n8n.canvas.nodeCreator.clearSearch();
await n8n.canvas.nodeCreator.navigateToSubcategory('On app event');
await n8n.canvas.nodeCreator.searchFor('edit image');
await expect(
n8n.canvas.nodeCreator.getCategoryItem('Results in other categories'),
).toBeVisible();
await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(1);
await expect(n8n.canvas.nodeCreator.getItem('Edit Image')).toBeVisible();
await n8n.canvas.nodeCreator.clearSearch();
await n8n.canvas.nodeCreator.searchFor('edit image123123');
await expect(n8n.canvas.nodeCreator.getNodeItems()).toHaveCount(0);
});
test('should check correct view panels after adding manual trigger', async ({ n8n }) => {
await n8n.canvas.clickCanvasPlusButton();
await expect(n8n.canvas.nodeCreator.getTriggerText()).toBeVisible();
await n8n.canvas.nodeCreator.close();
await n8n.canvas.addNode('Manual Trigger');
await expect(n8n.canvas.getCanvasPlusButton()).toBeHidden();
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await expect(n8n.canvas.nodeCreator.getNextText()).toBeVisible();
});
});
@@ -0,0 +1,53 @@
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe('Node Creator Special Nodes', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should correctly append a No Op node when Loop Over Items node is added (from add button)', async ({
n8n,
}) => {
await n8n.canvas.nodeCreator.open();
await n8n.canvas.nodeCreator.searchFor('Loop Over Items');
await n8n.canvas.nodeCreator.selectItem('Loop Over Items');
await n8n.ndv.close();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(3);
await expect(n8n.canvas.nodeByName('Loop Over Items')).toBeVisible();
await expect(n8n.canvas.nodeByName('Replace Me')).toBeVisible();
});
test('should correctly append a No Op node when Loop Over Items node is added (from connection)', async ({
n8n,
}) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.nodeCreator.searchFor('Loop Over Items');
await n8n.canvas.nodeCreator.selectItem('Loop Over Items');
await n8n.ndv.close();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(3);
await expect(n8n.canvas.nodeByName('Loop Over Items')).toBeVisible();
await expect(n8n.canvas.nodeByName('Replace Me')).toBeVisible();
});
test('should add a Send and Wait for Response node', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.nodeCreator.navigateToSubcategory('Human review');
await n8n.canvas.nodeCreator.selectItem('Slack');
await n8n.ndv.setupHelper.setParameter('operation', 'Send and Wait for Response');
await n8n.ndv.close();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
});
@@ -0,0 +1,50 @@
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe('Node Creator Vector Stores', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode('Manual Trigger');
});
test('should show vector stores actions', async ({ n8n }) => {
const expectedActions = [
'Get ranked documents from vector store',
'Add documents to vector store',
'Retrieve documents for Chain/Tool as Vector Store',
'Retrieve documents for AI Agent as Tool',
];
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.nodeCreator.searchFor('Vector Store');
await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible();
await n8n.canvas.nodeCreator.getItem('Simple Vector Store').click();
for (const action of expectedActions) {
await expect(n8n.canvas.nodeCreator.getItem(action)).toBeVisible();
}
await n8n.canvas.nodeCreator.goBackFromSubcategory();
await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible();
});
test('should find vector store nodes in creator', async ({ n8n }) => {
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.nodeCreator.searchFor('Vector Store');
await expect(n8n.canvas.nodeCreator.getNodeItems().first()).toBeVisible();
});
test('should search for specific vector store nodes', async ({ n8n }) => {
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.nodeCreator.searchFor('Simple Vector Store');
await expect(n8n.canvas.nodeCreator.getItem('Simple Vector Store')).toBeVisible();
});
});
@@ -0,0 +1,36 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Node Creator Workflow Building', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should append manual trigger when adding action node from canvas add button', async ({
n8n,
}) => {
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.nodeCreator.searchFor('n8n');
await n8n.canvas.nodeCreator.selectItem('n8n');
await n8n.canvas.nodeCreator.selectCategoryItem('Actions');
await n8n.canvas.nodeCreator.selectItem('Create a credential');
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
});
test('should append manual trigger when adding action node from plus button', async ({ n8n }) => {
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.nodeCreator.searchFor('n8n');
await n8n.canvas.nodeCreator.selectItem('n8n');
await n8n.canvas.nodeCreator.selectCategoryItem('Actions');
await n8n.canvas.nodeCreator.selectItem('Create a credential');
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
});
@@ -0,0 +1,94 @@
import flatted from 'flatted';
import type { IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { workflow, trigger, node } from '../../../../../@n8n/workflow-sdk/src';
import { test, expect } from '../../../fixtures/base';
const TRIGGER_NAME = 'Manual Trigger';
const ALL_ITEMS_NODE_NAME = 'Code All Items';
const EACH_ITEM_NODE_NAME = 'Code Each Item';
function createCodeNodeWorkflow(): IWorkflowBase {
const manualTrigger = trigger({
type: 'n8n-nodes-base.manualTrigger',
version: 1,
config: {
name: TRIGGER_NAME,
parameters: {},
},
});
const codeAllItems = node({
type: 'n8n-nodes-base.code',
version: 1,
config: {
name: ALL_ITEMS_NODE_NAME,
parameters: {
mode: 'runOnceForAllItems',
jsCode: 'return [{ json: { value: 1 } }, { json: { value: 2 } }];',
},
},
});
const codeEachItem = node({
type: 'n8n-nodes-base.code',
version: 1,
config: {
name: EACH_ITEM_NODE_NAME,
parameters: {
mode: 'runOnceForEachItem',
jsCode: 'return { json: { processed: $json.value * 2 } };',
},
},
});
const wf = workflow(nanoid(), `Code node test ${nanoid()}`)
.add(manualTrigger.to(codeAllItems))
.add(codeAllItems.to(codeEachItem));
const json = wf.toJSON() as IWorkflowBase;
json.settings = { executionOrder: 'v1' };
return json;
}
test.describe(
'Code node API execution @capability:task-runner',
{
annotation: [{ type: 'owner', description: 'NODES' }],
},
() => {
test('should execute runOnceForAllItems and runOnceForEachItem code nodes successfully', async ({
api,
}) => {
const { workflowId } = await api.workflows.createWorkflowFromDefinition(
createCodeNodeWorkflow(),
);
await api.workflows.runManually(workflowId, TRIGGER_NAME);
const execution = await api.workflows.waitForExecution(workflowId, 15_000, 'manual');
expect(execution.status).toBe('success');
const fullExecution = await api.workflows.getExecution(execution.id);
const executionData = flatted.parse(fullExecution.data);
// Verify runOnceForAllItems node produced correct output
const allItemsOutput = executionData.resultData.runData[ALL_ITEMS_NODE_NAME];
expect(allItemsOutput).toBeDefined();
expect(allItemsOutput[0].data.main[0]).toEqual([
expect.objectContaining({ json: { value: 1 } }),
expect.objectContaining({ json: { value: 2 } }),
]);
// Verify runOnceForEachItem node processed each item correctly
const eachItemOutput = executionData.resultData.runData[EACH_ITEM_NODE_NAME];
expect(eachItemOutput).toBeDefined();
expect(eachItemOutput[0].data.main[0]).toEqual([
expect.objectContaining({ json: { processed: 2 } }),
expect.objectContaining({ json: { processed: 4 } }),
]);
});
},
);
@@ -0,0 +1,105 @@
import { MANUAL_TRIGGER_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
import customCredential from '../../../workflows/Custom_credential.json';
import customNodeFixture from '../../../workflows/Custom_node.json';
import customNodeWithCustomCredentialFixture from '../../../workflows/Custom_node_custom_credential.json';
import customNodeWithN8nCredentialFixture from '../../../workflows/Custom_node_n8n_credential.json';
const CUSTOM_NODE_NAME = 'E2E Node';
const CUSTOM_NODE_WITH_N8N_CREDENTIAL = 'E2E Node with native n8n credential';
const CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL = 'E2E Node with custom credential';
test.describe('Community and custom nodes in canvas', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.page.route('/types/nodes.json', async (route) => {
const response = await route.fetch();
const nodes = await response.json();
nodes.push(
customNodeFixture,
customNodeWithN8nCredentialFixture,
customNodeWithCustomCredentialFixture,
);
await route.fulfill({
response,
json: nodes,
headers: { 'cache-control': 'no-cache, no-store' },
});
});
await n8n.page.route('/types/credentials.json', async (route) => {
const response = await route.fetch();
const credentials = await response.json();
credentials.push(customCredential);
await route.fulfill({
response,
json: credentials,
headers: { 'cache-control': 'no-cache, no-store' },
});
});
await n8n.page.route('/community-node-types', async (route) => {
await route.fulfill({ status: 200, json: { data: [] } });
});
await n8n.page.route('**/community-node-types/*', async (route) => {
await route.fulfill({ status: 200, json: null });
});
await n8n.page.route('https://registry.npmjs.org/*', async (route) => {
await route.fulfill({ status: 404, json: {} });
});
});
test('should render and select community node', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickCanvasPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_NAME);
await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_NAME);
await n8n.canvas.clickAddToWorkflowButton();
await expect(n8n.ndv.getNodeParameters()).toBeVisible();
await expect(n8n.ndv.getParameterInputField('testProp')).toHaveValue('Some default');
await expect(n8n.ndv.getParameterInputField('resource')).toHaveValue('option2');
await n8n.ndv.selectOptionInParameterDropdown('resource', 'option4');
await expect(n8n.ndv.getParameterInputField('resource')).toHaveValue('option4');
});
test('should render custom node with n8n credential', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_WITH_N8N_CREDENTIAL);
await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_WITH_N8N_CREDENTIAL);
await n8n.canvas.clickAddToWorkflowButton();
await n8n.page.getByTestId('credentials-label').click();
await n8n.page.getByTestId('node-credentials-select-item-new').click();
await expect(n8n.page.getByTestId('editCredential-modal')).toContainText('Notion API');
});
test('should render custom node with custom credential', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL);
await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL);
await n8n.canvas.clickAddToWorkflowButton();
await n8n.page.getByTestId('credentials-label').click();
await n8n.page.getByTestId('node-credentials-select-item-new').click();
await expect(n8n.page.getByTestId('editCredential-modal')).toContainText(
'Custom E2E Credential',
);
});
});
@@ -0,0 +1,84 @@
import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'email' });
test('EmailSend node sends via SMTP @capability:email', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, async ({ api, n8n, services }) => {
// Sign in to use internal APIs for creating credentials and workflows
const mailpit = services.mailpit;
// Create SMTP credential targeting Mailpit (uses internal hostname in container mode, localhost in local mode)
const smtpCredential = await api.credentials.createCredential({
name: 'SMTP (Test)',
type: 'smtp',
data: {
user: '',
password: '',
host: mailpit.smtpHost,
port: mailpit.smtpPort,
secure: false,
disableStartTls: true,
},
});
// Define a workflow with Manual Trigger -> EmailSend
const toEmail = 'test@recipient.local';
const subject = 'Playwright Mailpit SMTP';
const workflowDefinition = {
name: 'Mailpit EmailSend Workflow',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Email',
type: 'n8n-nodes-base.emailSend',
typeVersion: 2,
position: [300, 0],
parameters: {
fromEmail: 'test@n8n.local',
toEmail,
subject,
emailFormat: 'text',
text: 'Hello from n8n E2E test',
},
credentials: {
smtp: {
id: smtpCredential.id,
name: smtpCredential.name,
},
},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'Email', type: 'main', index: 0 }]],
},
},
active: false,
} as const;
const { workflowId } = await api.workflows.createWorkflowFromDefinition(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflowDefinition as any,
{ makeUnique: true },
);
// Execute the workflow via UI API endpoint by navigating to the canvas and clicking run
await n8n.page.goto(`/workflow/${workflowId}`);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
const msg = await mailpit.waitForMessage({ to: toEmail, subject });
expect(msg).toBeTruthy();
});
@@ -0,0 +1,328 @@
import type { IWorkflowBase } from 'n8n-workflow';
import { test, expect } from '../../../fixtures/base';
test.describe('Form Trigger', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test("add node by clicking on 'On form submission'", async ({ n8n }) => {
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.nodeCreatorItemByName('On form submission').click();
await n8n.ndv.fillParameterInput('Form Title', 'Test Form');
await n8n.ndv.fillParameterInput('Form Description', 'Test Form Description');
await n8n.ndv.clickBackToCanvasButton();
await expect(n8n.canvas.nodeByName('On form submission')).toBeVisible();
await expect(n8n.canvas.nodeIssuesBadge('On form submission')).toBeHidden();
});
test('should fill up form fields', async ({ n8n }) => {
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.nodeCreatorItemByName('On form submission').click();
await n8n.ndv.fillParameterInput('Form Title', 'Test Form');
await n8n.ndv.fillParameterInput('Form Description', 'Test Form Description');
// Add first field - Number type with required flag
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 1');
await n8n.ndv.selectOptionInParameterDropdown('fieldType', 'Number');
await n8n.ndv.addFixedCollectionProperty('Custom Field Name', 0);
await n8n.ndv.fillParameterInputByName('fieldName', 'testField1');
await n8n.ndv.addFixedCollectionProperty('Required Field', 0);
await n8n.ndv.setParameterSwitch('requiredField', true);
// Add second field - Text type
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 2', 1);
await n8n.ndv.addFixedCollectionProperty('Custom Field Name', 1);
await n8n.ndv.fillParameterInputByName('fieldName', 'testField2', 1);
// Add third field - Date type
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 3', 2);
await n8n.ndv.selectOptionInParameterDropdown('fieldType', 'Date', 2);
await n8n.ndv.addFixedCollectionProperty('Custom Field Name', 2);
await n8n.ndv.fillParameterInputByName('fieldName', 'testField3', 2);
// Add fourth field - Dropdown type with options
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 4', 3);
await n8n.ndv.selectOptionInParameterDropdown('fieldType', 'Dropdown', 3);
await n8n.ndv.addFixedCollectionProperty('Custom Field Name', 3);
await n8n.ndv.fillParameterInputByName('fieldName', 'testField4', 3);
// Configure dropdown field options
await n8n.page.getByRole('button', { name: 'Add Field Option' }).click();
await n8n.ndv.fillParameterInputByName('option', 'Option 1');
await n8n.ndv.fillParameterInputByName('option', 'Option 2', 1);
// Add optional submitted message
await n8n.ndv.addParameterOptionByName('Form Response');
await n8n.ndv.fillParameterInput('Text to Show', 'Your test form was successfully submitted');
await n8n.ndv.clickBackToCanvasButton();
await expect(n8n.canvas.nodeByName('On form submission')).toBeVisible();
await expect(n8n.canvas.nodeIssuesBadge('On form submission')).toBeHidden();
});
test('should create and submit a multi-page form', async ({ n8n }) => {
// Add Form Trigger node with first name field
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.nodeCreatorItemByName('On form submission').click();
await n8n.ndv.fillParameterInput('Form Title', 'Multi-Page Form');
await n8n.ndv.fillParameterInput('Form Description', 'A form with multiple pages');
// Add a single field to the Form Trigger node
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'What is your first name?');
await n8n.ndv.clickBackToCanvasButton();
// Add Form node (next page) by selecting the "Next Form Page" action
await n8n.canvas.addNode('n8n Form', { closeNDV: false, action: 'Next Form Page' });
// Add a single field to the Form node
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'What is your last name?');
await n8n.ndv.clickBackToCanvasButton();
// Start the workflow execution so it's waiting for form submissions
// This allows the multi-page form flow to work (continuing to Form node after trigger)
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
// Get the form test URL from the NDV
await n8n.canvas.openNode('On form submission');
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
const formUrl = await formUrlLocator.textContent();
// Open form URL in a new browser tab
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
// Fill first page with a random first name
const firstName = `John${Date.now()}`;
await formPage.getByLabel('What is your first name?').fill(firstName);
await formPage.getByRole('button', { name: 'Submit' }).click();
// Fill second page with a random last name
const lastName = `Doe${Date.now()}`;
await formPage.getByLabel('What is your last name?').fill(lastName);
await formPage.getByRole('button', { name: 'Submit' }).click();
// Verify the form was submitted successfully
await expect(formPage.getByText('Your response has been recorded')).toBeVisible();
// Close the form page
await formPage.close();
});
test.describe('form execution with basic auth', () => {
const password = new Date().toDateString();
test.use({
httpCredentials: {
username: 'test',
password,
},
});
test('form submission works with basic auth', async ({ api, n8n }) => {
const { id, name } = await api.credentials.createCredential({
name: 'Basic Auth test:test',
type: 'httpBasicAuth',
data: {
user: 'test',
password,
},
});
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
authentication: 'basicAuth',
formTitle: 'Test',
options: {
respondWithOptions: {
values: {
formSubmittedText: 'This worked',
},
},
},
},
type: 'n8n-nodes-base.formTrigger',
typeVersion: 2.5,
position: [0, 0],
id: '49b31a69-3fc9-43d0-944e-990783330e7a',
name: 'On form submission',
webhookId: '17eae80c-039e-4779-be68-08cd5afc5f65',
credentials: {
httpBasicAuth: {
id,
name,
},
},
},
],
connections: {},
pinData: {},
meta: {
instanceId: 'acd7615bcc3af421bbd7517e305cc16505176a6a47045dbe39e25d904c940573',
},
};
const { workflowId } = await api.workflows.createWorkflowFromDefinition(workflow, {
makeUnique: true,
});
await n8n.page.goto(`/workflow/${workflowId}`);
// Start the workflow execution so it's waiting for form submissions
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
// Get the form test URL from the NDV
await n8n.canvas.openNode('On form submission');
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
const formUrl = await formUrlLocator.textContent();
// Open form URL in a new browser tab
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
// Submit the form
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('This worked')).toBeVisible();
});
test('multi-step form submission works with basic auth', async ({ api, n8n }) => {
const { id, name } = await api.credentials.createCredential({
name: 'Basic Auth test:test',
type: 'httpBasicAuth',
data: {
user: 'test',
password,
},
});
const workflow: Partial<IWorkflowBase> = {
nodes: [
{
parameters: {
authentication: 'basicAuth',
formTitle: 'Test',
},
type: 'n8n-nodes-base.formTrigger',
typeVersion: 2.5,
position: [0, 0],
id: '49b31a69-3fc9-43d0-944e-990783330e7a',
name: 'On form submission',
webhookId: '17eae80c-039e-4779-be68-08cd5afc5f64',
credentials: {
httpBasicAuth: {
id,
name,
},
},
},
{
parameters: {
options: {
formDescription: 'Step 2',
},
},
type: 'n8n-nodes-base.form',
typeVersion: 2.5,
position: [208, 0],
id: 'e748b959-faeb-4476-aa30-1c7a6434843a',
name: 'Form',
webhookId: '1e1a6d32-d3a4-4150-886b-2119cc4072bc',
},
{
parameters: {
operation: 'completion',
completionTitle: 'Success',
completionMessage: 'This worked',
options: {},
},
type: 'n8n-nodes-base.form',
typeVersion: 2.5,
position: [416, 0],
id: '2e52c834-e08a-4848-bd86-be1f7909a956',
name: 'Form1',
webhookId: '1839391a-f07d-4bee-858f-678c1b45252b',
},
],
connections: {
'On form submission': {
main: [
[
{
node: 'Form',
type: 'main',
index: 0,
},
],
],
},
Form: {
main: [
[
{
node: 'Form1',
type: 'main',
index: 0,
},
],
],
},
},
pinData: {},
meta: {
templateCredsSetupCompleted: true,
instanceId: 'acd7615bcc3af421bbd7517e305cc16505176a6a47045dbe39e25d904c940573',
},
};
const { workflowId } = await api.workflows.createWorkflowFromDefinition(workflow, {
makeUnique: true,
});
await n8n.page.goto(`/workflow/${workflowId}`);
// Start the workflow execution so it's waiting for form submissions
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
// Get the form test URL from the NDV
await n8n.canvas.openNode('On form submission');
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
const formUrl = await formUrlLocator.textContent();
// Open form URL in a new browser tab
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
// Submit first page
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('Step 2')).toBeVisible();
// submit second page
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('This worked')).toBeVisible();
});
});
});
@@ -0,0 +1,40 @@
import { test, expect } from '../../../fixtures/base';
test.describe('HTTP Request node', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should make a request with a URL and receive a response', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request', { closeNDV: false });
await n8n.ndv.setupHelper.httpRequest({
url: 'https://catfact.ninja/fact',
});
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.get()).toContainText('fact');
});
test.describe('Credential-only HTTP Request Node variants', () => {
test('should render a modified HTTP Request Node', async ({ n8n }) => {
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('VirusTotal');
await expect(n8n.ndv.getNodeNameContainer()).toContainText('VirusTotal HTTP Request');
await expect(n8n.ndv.getParameterInputField('url')).toHaveValue(
'https://www.virustotal.com/api/v3/',
);
await expect(n8n.ndv.getParameterInput('authentication')).toBeHidden();
await expect(n8n.ndv.getParameterInput('nodeCredentialType')).toBeHidden();
await expect(n8n.ndv.getCredentialLabel('Credential for VirusTotal')).toBeVisible();
});
});
});
@@ -0,0 +1,52 @@
import { IF_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
const FILTER_PARAM_NAME = 'conditions';
test.describe('If Node (filter component)', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should be able to create and delete multiple conditions', async ({ n8n }) => {
await n8n.canvas.addNode(IF_NODE_NAME, { closeNDV: false });
// Default state
await expect(n8n.ndv.getFilterComponent(FILTER_PARAM_NAME)).toBeVisible();
await expect(n8n.ndv.getFilterConditions(FILTER_PARAM_NAME)).toHaveCount(1);
await expect(n8n.ndv.getFilterConditionOperator(FILTER_PARAM_NAME)).toHaveText('is equal to');
// Add
await n8n.ndv.addFilterCondition(FILTER_PARAM_NAME);
await n8n.ndv.getFilterConditionLeft(FILTER_PARAM_NAME, 0).locator('input').fill('first left');
await n8n.ndv.getFilterConditionLeft(FILTER_PARAM_NAME, 1).locator('input').fill('second left');
await n8n.ndv.addFilterCondition(FILTER_PARAM_NAME);
await expect(n8n.ndv.getFilterConditions(FILTER_PARAM_NAME)).toHaveCount(3);
// Delete
await n8n.ndv.removeFilterCondition(FILTER_PARAM_NAME, 0);
await expect(n8n.ndv.getFilterConditions(FILTER_PARAM_NAME)).toHaveCount(2);
await expect(n8n.ndv.getFilterConditionLeft(FILTER_PARAM_NAME, 0).locator('input')).toHaveValue(
'second left',
);
await n8n.ndv.removeFilterCondition(FILTER_PARAM_NAME, 1);
await expect(n8n.ndv.getFilterConditions(FILTER_PARAM_NAME)).toHaveCount(1);
});
test('should correctly evaluate conditions', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_workflow_filter.json');
await n8n.canvas.clickExecuteWorkflowButton();
await n8n.canvas.openNode('Then');
await expect(n8n.ndv.outputPanel.get()).toContainText('3 items');
await n8n.ndv.close();
await n8n.canvas.openNode('Else');
await expect(n8n.ndv.outputPanel.get()).toContainText('1 item');
});
});
@@ -0,0 +1,179 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'kafka' });
test.describe('Kafka Nodes', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test('Kafka node publishes messages to topic @capability:kafka', async ({
api,
n8n,
services,
}) => {
const kafka = services.kafka;
const topic = `producer-test-${nanoid()}`;
const testPayload = { greeting: 'Hello from n8n Kafka node' };
await kafka.createTopic(topic, 1);
const kafkaCredential = await api.credentials.createCredential({
name: 'Kafka (Test)',
type: 'kafka',
data: {
brokers: 'kafka:9092',
clientId: 'n8n-test-producer',
ssl: false,
authentication: false,
},
});
const workflowDefinition = {
name: 'Kafka Producer Test',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
},
{
id: '2',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 3,
position: [200, 0] as [number, number],
parameters: {
mode: 'raw',
jsonOutput: JSON.stringify(testPayload),
},
},
{
id: '3',
name: 'Kafka',
type: 'n8n-nodes-base.kafka',
typeVersion: 1,
position: [400, 0] as [number, number],
parameters: {
topic,
sendInputData: true,
useKey: true,
key: 'test-key',
options: {},
},
credentials: {
kafka: {
id: kafkaCredential.id,
name: kafkaCredential.name,
},
},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'Set', type: 'main', index: 0 }]],
},
Set: {
main: [[{ node: 'Kafka', type: 'main', index: 0 }]],
},
},
active: false,
};
const { workflowId } = await api.workflows.createWorkflowFromDefinition(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflowDefinition as any,
{ makeUnique: true },
);
await n8n.page.goto(`/workflow/${workflowId}`);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
const messages = await kafka.consume(topic, { maxMessages: 1, timeoutMs: 10000 });
expect(messages).toHaveLength(1);
expect(messages[0].key).toBe('test-key');
expect(JSON.parse(messages[0].value)).toMatchObject(testPayload);
});
test('Kafka Trigger node processes messages @capability:kafka', async ({ api, services }) => {
const kafka = services.kafka;
const topic = `trigger-test-${nanoid()}`;
const groupId = `n8n-test-group-${nanoid()}`;
await kafka.createTopic(topic, 1);
const kafkaCredential = await api.credentials.createCredential({
name: 'Kafka (Test)',
type: 'kafka',
data: {
brokers: 'kafka:9092',
clientId: 'n8n-test',
ssl: false,
authentication: false,
},
});
const workflowDefinition = {
name: 'Kafka Trigger Test',
nodes: [
{
id: '1',
name: 'Kafka Trigger',
type: 'n8n-nodes-base.kafkaTrigger',
typeVersion: 1.1,
position: [0, 0] as [number, number],
parameters: {
topic,
groupId,
options: {
fromBeginning: true,
jsonParseMessage: true,
parallelProcessing: false,
},
},
credentials: {
kafka: {
id: kafkaCredential.id,
name: kafkaCredential.name,
},
},
},
{
id: '2',
name: 'No Operation',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [200, 0] as [number, number],
},
],
connections: {
'Kafka Trigger': {
main: [[{ node: 'No Operation', type: 'main', index: 0 }]],
},
},
active: false,
};
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflowDefinition as any,
{ makeUnique: true },
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
await kafka.waitForConsumerGroup(groupId);
const testPayload = { test: 'message' };
await kafka.publish(topic, testPayload);
const execution = await api.workflows.waitForExecution(workflowId, 10000, 'trigger');
expect(execution.status).toBe('success');
});
});
@@ -0,0 +1,687 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
import type { McpSession } from '../../../services/mcp-api-helper';
/**
* E2E tests for the MCP Server Trigger node.
*
* Tests cover:
* - SSE and Streamable HTTP transports
* - Authentication (none, bearer, header)
* - Tool listing and execution
* - Session management
* - Error handling
*/
test.describe('MCP Trigger Node', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.describe('Streamable HTTP Transport', () => {
test('should initialize session and return mcp-session-id', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
// Get the MCP path from the workflow
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
expect(session.sessionId).toBeTruthy();
expect(session.transport).toBe('streamableHttp');
});
test('should list tools via Streamable HTTP', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const tools = await api.mcp.listTools(session, mcpPath);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('echo');
expect(tools[0].description).toContain('Echoes');
});
test('should call tool via Streamable HTTP', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const result = await api.mcp.callTool(session, mcpPath, 'echo', {
message: 'Hello from E2E test!',
});
expect(result.content).toBeDefined();
expect(result.content.length).toBeGreaterThan(0);
expect(result.content[0].text).toContain('Hello from E2E test!');
});
test('should close session via DELETE', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const deleteResponse = await api.mcp.streamableHttpDelete(session, mcpPath);
// DELETE should return success (200 or 202)
expect(deleteResponse.status()).toBeLessThan(300);
});
});
test.describe('SSE Transport', () => {
test('should establish SSE connection and return session', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.sseSetup(mcpPath);
try {
expect(session.sessionId).toBeTruthy();
expect(session.transport).toBe('sse');
expect(session.postUrl).toBeTruthy();
} finally {
api.mcp.sseClose(session);
}
});
test('should list connected tools via SSE', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.sseSetup(mcpPath);
try {
const tools = await api.mcp.listTools(session, mcpPath);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('echo');
} finally {
api.mcp.sseClose(session);
}
});
test('should call tool and receive response via SSE', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.sseSetup(mcpPath);
try {
const result = await api.mcp.callTool(session, mcpPath, 'echo', {
message: 'SSE test message',
});
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain('SSE test message');
} finally {
api.mcp.sseClose(session);
}
});
});
test.describe('Authentication', () => {
test('should reject unauthenticated request with bearerAuth', async ({ api }) => {
const token = `secret-token-${nanoid()}`;
const credential = await api.credentials.createCredential({
type: 'httpBearerAuth',
name: `mcp-bearer-${nanoid()}`,
data: { token },
});
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-bearer-auth.json',
{
transform: (wf) => {
const mcpNode = wf.nodes?.find((n) => n.type.includes('mcpTrigger'));
if (mcpNode) {
mcpNode.credentials = {
httpBearerAuth: { id: credential.id, name: credential.name },
};
}
return wf;
},
},
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Try without auth - should fail
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: api.mcp.createMessage('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
}),
});
expect(noAuthResponse.status()).toBe(403);
});
test('should accept valid bearer token', async ({ api }) => {
const token = `secret-token-${nanoid()}`;
const credential = await api.credentials.createCredential({
type: 'httpBearerAuth',
name: `mcp-bearer-${nanoid()}`,
data: { token },
});
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-bearer-auth.json',
{
transform: (wf) => {
const mcpNode = wf.nodes?.find((n) => n.type.includes('mcpTrigger'));
if (mcpNode) {
mcpNode.credentials = {
httpBearerAuth: { id: credential.id, name: credential.name },
};
}
return wf;
},
},
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Try with valid auth - should succeed
const session = await api.mcp.streamableHttpInitialize(mcpPath, {
headers: { Authorization: `Bearer ${token}` },
});
expect(session.sessionId).toBeTruthy();
});
test('should accept valid header auth', async ({ api }) => {
const headerName = `X-Auth-${nanoid(8)}`;
const headerValue = `secret-value-${nanoid()}`;
const credential = await api.credentials.createCredential({
type: 'httpHeaderAuth',
name: `mcp-header-${nanoid()}`,
data: { name: headerName, value: headerValue },
});
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-header-auth.json',
{
transform: (wf) => {
const mcpNode = wf.nodes?.find((n) => n.type.includes('mcpTrigger'));
if (mcpNode) {
mcpNode.credentials = {
httpHeaderAuth: { id: credential.id, name: credential.name },
};
}
return wf;
},
},
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Try without auth - should fail
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: api.mcp.createMessage('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
}),
});
expect(noAuthResponse.status()).toBe(403);
// Try with valid auth - should succeed
const session = await api.mcp.streamableHttpInitialize(mcpPath, {
headers: { [headerName]: headerValue },
});
expect(session.sessionId).toBeTruthy();
});
});
test.describe('Tool Operations', () => {
test('should return all connected tools in tools/list', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-multi-tool.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
const tools = await api.mcp.listTools(session, mcpPath);
expect(tools).toHaveLength(3);
const toolNames = tools.map((t) => t.name).sort();
expect(toolNames).toEqual(['add', 'echo', 'multiply']);
});
test('should execute tool with arguments', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-multi-tool.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
// Test echo tool
const echoResult = await api.mcp.callTool(session, mcpPath, 'echo', {
message: 'Multi-tool test',
});
expect(echoResult.content[0].text).toContain('Multi-tool test');
// Test add tool
const addResult = await api.mcp.callTool(session, mcpPath, 'add', { a: 5, b: 3 });
expect(addResult.content[0].text).toContain('8');
// Test multiply tool
const multiplyResult = await api.mcp.callTool(session, mcpPath, 'multiply', { a: 4, b: 7 });
expect(multiplyResult.content[0].text).toContain('28');
});
test('should return error for unknown tool', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
const session = await api.mcp.streamableHttpInitialize(mcpPath);
// Try to call a non-existent tool
const message = api.mcp.createMessage('tools/call', {
name: 'nonexistent_tool',
arguments: {},
});
const response = await api.mcp.streamableHttpSendMessage(session, mcpPath, message);
const body = await response.text();
// Should get an error response
expect(body).toContain('error');
});
});
test.describe('Session Management', () => {
test('should reject requests with invalid session ID', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Create a fake session with an invalid session ID
const fakeSession: McpSession = {
sessionId: 'invalid-session-id-12345',
transport: 'streamableHttp',
};
const message = api.mcp.createMessage('tools/list');
const response = await api.mcp.streamableHttpSendMessage(fakeSession, mcpPath, message);
// Should return an error status (404 or 401)
expect(response.status()).toBeGreaterThanOrEqual(400);
});
test('should cleanup session on DELETE request', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Initialize and then delete session
const session = await api.mcp.streamableHttpInitialize(mcpPath);
await api.mcp.streamableHttpDelete(session, mcpPath);
// Try to use the deleted session - should fail
const message = api.mcp.createMessage('tools/list');
const response = await api.mcp.streamableHttpSendMessage(session, mcpPath, message);
expect(response.status()).toBeGreaterThanOrEqual(400);
});
});
test.describe('Error Handling', () => {
test('should return 404 for non-existent endpoint', async ({ api }) => {
const response = await api.webhooks.trigger('webhook/non-existent-mcp-endpoint-12345', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: api.mcp.createMessage('initialize'),
});
expect(response.status()).toBe(404);
});
test('should handle malformed JSON-RPC messages', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// First establish a valid session
const session = await api.mcp.streamableHttpInitialize(mcpPath);
// Send malformed message (missing required fields)
const malformedMessage = {
// Missing jsonrpc version
id: nanoid(),
method: 'tools/list',
};
const response = await api.mcp.streamableHttpSendMessage(session, mcpPath, malformedMessage);
// Server should handle gracefully (either error response or parse error)
// The exact behavior depends on implementation
const body = await response.text();
expect(body).toBeTruthy(); // Should return some response
});
});
});
// Queue mode tests - tagged with @mode:queue to run only in queue infrastructure
test.describe('MCP Trigger - Queue Mode', () => {
test('@mode:queue should return 202 Accepted for tool call in queue mode', async ({ api }) => {
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// In SSE mode with queue mode enabled, tool calls should return 202 Accepted
// because the execution is queued and response comes via Redis pub/sub
const session = await api.mcp.sseSetup(mcpPath);
try {
const message = api.mcp.createMessage('tools/call', {
name: 'echo',
arguments: { message: 'Queue mode test' },
});
const response = await api.mcp.sseSendMessage(session, message);
// In queue mode, SSE tool calls may return 202 Accepted
// The actual result would come back asynchronously
expect([200, 202]).toContain(response.status());
} finally {
api.mcp.sseClose(session);
}
});
});
// Multi-main tests - tagged with @mode:multi-main to run only in multi-main infrastructure
test.describe('MCP Trigger - Multi-Main', () => {
test.describe('Streamable HTTP Transport', () => {
test('@mode:multi-main should handle tool call on different main than session creator', async ({
api,
mainUrls,
createApiForMain,
}) => {
// This test verifies that MCP sessions work correctly in multi-main setups
// where the session might be created on one main but tool calls routed to another
// Skip if not in multi-main mode (need at least 2 mains)
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
// Create workflow via load balancer (normal flow)
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Initialize session on main-1 (direct access, bypassing load balancer)
const main1Api = await createApiForMain(0);
const session = await main1Api.mcp.streamableHttpInitialize(mcpPath);
expect(session.sessionId).toBeTruthy();
// Send tool call to main-2 (different main than where session was created)
// This tests that session state is properly shared across mains via Redis
const main2Api = await createApiForMain(1);
const result = await main2Api.mcp.callTool(session, mcpPath, 'echo', {
message: 'Cross-main test',
});
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain('Cross-main test');
});
test('@mode:multi-main should handle multiple tool calls across different mains', async ({
api,
mainUrls,
createApiForMain,
}) => {
// Test that multiple tool calls can be distributed across mains
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-multi-tool.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Initialize session on main-1
const main1Api = await createApiForMain(0);
const session = await main1Api.mcp.streamableHttpInitialize(mcpPath);
// Alternate tool calls between mains to simulate load balancer behavior
const main2Api = await createApiForMain(1);
// Call 1: main-1
const echoResult = await main1Api.mcp.callTool(session, mcpPath, 'echo', {
message: 'From main 1',
});
expect(echoResult.content[0].text).toContain('From main 1');
// Call 2: main-2
const addResult = await main2Api.mcp.callTool(session, mcpPath, 'add', { a: 10, b: 20 });
expect(addResult.content[0].text).toContain('30');
// Call 3: main-1 again
const multiplyResult = await main1Api.mcp.callTool(session, mcpPath, 'multiply', {
a: 5,
b: 6,
});
expect(multiplyResult.content[0].text).toContain('30');
// Call 4: main-2 again
const echoResult2 = await main2Api.mcp.callTool(session, mcpPath, 'echo', {
message: 'From main 2',
});
expect(echoResult2.content[0].text).toContain('From main 2');
});
});
test.describe('SSE Transport', () => {
test('@mode:multi-main should handle SSE tool call on different main than session creator', async ({
api,
mainUrls,
createApiForMain,
}) => {
// This test verifies that SSE-based MCP sessions work correctly in multi-main setups
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-basic.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Initialize SSE session on main-1
const main1Api = await createApiForMain(0);
const session = await main1Api.mcp.sseSetup(mcpPath);
try {
expect(session.sessionId).toBeTruthy();
expect(session.transport).toBe('sse');
// Send tool call to main-2 (different main than where SSE session was created)
// This tests that SSE session state is properly shared across mains via Redis
// Use callToolCrossMain to POST to main2's URL while receiving on main1's SSE stream
const main2McpPath = `${mainUrls[1]}/${mcpPath}`;
const result = await main1Api.mcp.callToolCrossMain(session, main2McpPath, 'echo', {
message: 'SSE cross-main test',
});
expect(result.content).toBeDefined();
expect(result.content[0].text).toContain('SSE cross-main test');
} finally {
main1Api.mcp.sseClose(session);
}
});
test('@mode:multi-main should handle multiple SSE tool calls across different mains', async ({
api,
mainUrls,
createApiForMain,
}) => {
// Test that multiple SSE tool calls can be distributed across mains
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-multi-tool.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Initialize SSE session on main-1
const main1Api = await createApiForMain(0);
const session = await main1Api.mcp.sseSetup(mcpPath);
// Construct full URL paths for cross-main calls
const main2McpPath = `${mainUrls[1]}/${mcpPath}`;
try {
// Call 1: main-1 (where SSE connection was established)
const echoResult = await main1Api.mcp.callTool(session, mcpPath, 'echo', {
message: 'SSE from main 1',
});
expect(echoResult.content[0].text).toContain('SSE from main 1');
// Call 2: main-2 (different main, tests Redis pub/sub for response routing)
// Use callToolCrossMain to POST to main2 but receive response on main1's SSE stream
const addResult = await main1Api.mcp.callToolCrossMain(session, main2McpPath, 'add', {
a: 15,
b: 25,
});
expect(addResult.content[0].text).toContain('40');
// Call 3: main-1 again
const multiplyResult = await main1Api.mcp.callTool(session, mcpPath, 'multiply', {
a: 7,
b: 8,
});
expect(multiplyResult.content[0].text).toContain('56');
// Call 4: main-2 again
const echoResult2 = await main1Api.mcp.callToolCrossMain(session, main2McpPath, 'echo', {
message: 'SSE from main 2',
});
expect(echoResult2.content[0].text).toContain('SSE from main 2');
} finally {
main1Api.mcp.sseClose(session);
}
});
test('@mode:multi-main should list tools via SSE from different main', async ({
api,
mainUrls,
createApiForMain,
}) => {
// Test that tools/list works across mains with SSE transport
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
'mcp-trigger/mcp-trigger-multi-tool.json',
);
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
// Initialize SSE session on main-1
const main1Api = await createApiForMain(0);
const session = await main1Api.mcp.sseSetup(mcpPath);
try {
// List tools via main-2 (different main)
// Use listToolsCrossMain to POST to main2 but receive response on main1's SSE stream
const main2McpPath = `${mainUrls[1]}/${mcpPath}`;
const tools = await main1Api.mcp.listToolsCrossMain(session, main2McpPath);
expect(tools).toHaveLength(3);
const toolNames = tools.map((t) => t.name).sort();
expect(toolNames).toEqual(['add', 'echo', 'multiply']);
} finally {
main1Api.mcp.sseClose(session);
}
});
});
});
@@ -0,0 +1,73 @@
import { workflow, trigger, node } from '@n8n/workflow-sdk';
import type { INode, IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
type TriggerEventType = 'activate' | 'update';
const makeN8nTriggerWorkflow = (events: TriggerEventType[]) => {
const n8nTrigger = trigger({
type: 'n8n-nodes-base.n8nTrigger',
version: 1,
config: {
name: 'n8n Trigger',
parameters: { events },
},
});
const noOp = node({
type: 'n8n-nodes-base.noOp',
version: 1,
config: {
name: 'NoOp',
},
});
return workflow(nanoid(), `n8n Trigger Test ${nanoid()}`).add(n8nTrigger.to(noOp));
};
test.describe(
'n8n Trigger node',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test('should fire "activate" event when workflow is published', async ({ api }) => {
const wf = makeN8nTriggerWorkflow(['activate']);
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
wf.toJSON() as IWorkflowBase,
);
// First activation — activationMode = 'activate'
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
const execution = await api.workflows.waitForExecution(workflowId, 15_000, 'trigger');
expect(execution.status).toBe('success');
});
test('should fire "update" event when active workflow is re-published', async ({ api }) => {
const wf = makeN8nTriggerWorkflow(['update']);
const { workflowId, createdWorkflow } = await api.workflows.createWorkflowFromDefinition(
wf.toJSON() as IWorkflowBase,
);
// First activation — activationMode = 'activate', trigger should NOT fire
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
// Update the workflow nodes to create a new version (simulates editing)
const updatedNodes = wf.add(
node({ type: 'n8n-nodes-base.noOp', version: 1, config: { name: 'NoOp2' } }),
);
const updatedWorkflow = await api.workflows.update(workflowId, createdWorkflow.versionId!, {
nodes: updatedNodes.toJSON().nodes as INode[],
});
// Re-activation with new version — activationMode = 'update', trigger should fire
await api.workflows.activate(workflowId, updatedWorkflow.versionId!);
const execution = await api.workflows.waitForExecution(workflowId, 15_000, 'trigger');
expect(execution.status).toBe('success');
});
},
);
@@ -0,0 +1,17 @@
import { expect, test } from '../../../fixtures/base';
test.describe('PDF Test', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test('Can read and write PDF files and extract text', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
await n8n.canvas.clickExecuteWorkflowButton();
// Increased timeout - PDF processing can be slow after recent changes
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible({ timeout: 30000 });
});
});
@@ -0,0 +1,21 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Schedule Trigger node', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should execute schedule trigger node and return timestamp in output', async ({ n8n }) => {
await n8n.canvas.addNode('Schedule Trigger');
await n8n.ndv.execute();
await expect(n8n.ndv.outputPanel.get()).toContainText('timestamp');
await n8n.ndv.clickBackToCanvasButton();
});
});
@@ -0,0 +1,270 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
import type { n8nPage } from '../../../pages/n8nPage';
import { EditFieldsNode } from '../../../pages/nodes/EditFieldsNode';
const cowBase64 =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=';
test.describe('Webhook Trigger node', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should listen for all HTTP methods (GET, POST, DELETE, HEAD, PATCH, PUT)', async ({
n8n,
}) => {
await n8n.canvas.addNode('Webhook');
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
const methods = ['GET', 'POST', 'DELETE', 'HEAD', 'PATCH', 'PUT'] as const;
for (const method of methods) {
await n8n.ndv.setupHelper.webhook({ httpMethod: method });
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, { method });
expect(response.ok(), `${method} request should succeed`).toBe(true);
// Wait for output to appear (confirms execution completed)
await expect(n8n.ndv.outputPanel.getDataContainer()).toBeVisible();
}
});
test('should listen for a GET request and respond with Respond to Webhook node', async ({
n8n,
}) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
responseMode: "Using 'Respond to Webhook' Node",
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.close();
await addEditFieldsNode(n8n);
await n8n.canvas.addNode('Respond to Webhook', { closeNDV: true });
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.json();
expect(responseData.MyValue).toBe(1234);
});
test('should listen for a GET request and respond with custom status code 201', async ({
n8n,
}) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({ httpMethod: 'GET' });
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
// Add the Response Code optional parameter
await n8n.ndv.getAddOptionDropdown().click();
await n8n.page.getByRole('option', { name: 'Response Code' }).click();
// Select 201 from the dropdown
await n8n.ndv.selectOptionInParameterDropdown('responseCode', '201');
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.status()).toBe(201);
});
test('should listen for a GET request and respond with last node', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
responseMode: 'When Last Node Finishes',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.close();
await addEditFieldsNode(n8n);
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.json();
expect(responseData.MyValue).toBe(1234);
});
test('should listen for a GET request and respond with last node binary data', async ({
n8n,
}) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
responseMode: 'When Last Node Finishes',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.selectOptionInParameterDropdown('responseData', 'First Entry Binary');
await n8n.ndv.close();
await n8n.canvas.addNode('Edit Fields (Set)');
const editFieldsNode = new EditFieldsNode(n8n.page);
await editFieldsNode.setSingleFieldValue('data', 'string', cowBase64);
await n8n.ndv.close();
await n8n.canvas.addNode('Convert to File', { action: 'Convert to JSON' });
await n8n.ndv.selectOptionInParameterDropdown('mode', 'Each Item to Separate File');
await n8n.ndv.close();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.json();
expect('data' in responseData).toBe(true);
});
test('should listen for a GET request and respond with an empty body', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
responseMode: 'When Last Node Finishes',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.selectOptionInParameterDropdown('responseData', 'No Response Body');
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.text();
expect(responseData).toBe('');
});
test('should listen for a GET request with Basic Authentication', async ({ n8n }) => {
const credentialName = `test-${nanoid()}`;
const user = `test-${nanoid()}`;
const password = `test-${nanoid()}`;
await n8n.credentialsComposer.createFromApi({
type: 'httpBasicAuth',
name: credentialName,
data: {
user,
password,
},
});
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
authentication: 'Basic Auth',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const failResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
Authorization: 'Basic ' + Buffer.from('wrong:wrong').toString('base64'),
},
});
expect(failResponse.status()).toBe(403);
const successResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
Authorization: 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64'),
},
});
expect(successResponse.ok()).toBe(true);
});
test('should listen for a GET request with Header Authentication', async ({ n8n }) => {
const credentialName = `test-${nanoid()}`;
const name = `test-${nanoid()}`;
const value = `test-${nanoid()}`;
await n8n.credentialsComposer.createFromApi({
type: 'httpHeaderAuth',
name: credentialName,
data: {
name,
value,
},
});
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
authentication: 'Header Auth',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const failResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
test: 'wrong',
},
});
expect(failResponse.status()).toBe(403);
const successResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
[name]: value,
},
});
expect(successResponse.ok()).toBe(true);
});
test('CAT-1253-bug-cant-run-workflow-when-unconnected-nodes-have-errors', async ({ n8n }) => {
// Add Webhook node
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
});
await n8n.ndv.close();
// Add No Operation node - it will connect automatically since Webhook node is in context
await n8n.canvas.nodeByName('Webhook').click();
await n8n.canvas.addNode('No Operation, do nothing', { closeNDV: true });
// Verify connection was created
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
// Add HTTP Request node (unconnected, which will have an error)
await n8n.canvas.deselectAll();
await n8n.canvas.addNode('HTTP Request', { closeNDV: true });
// Verify we now have 3 nodes but still only 1 connection
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
// Execute the workflow
await n8n.canvas.clickExecuteWorkflowButton();
// Assert that webhook is waiting for trigger
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
// Assert that no error toast appeared
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
});
async function addEditFieldsNode(n8n: n8nPage): Promise<void> {
await n8n.canvas.addNode('Edit Fields (Set)');
const editFieldsNode = new EditFieldsNode(n8n.page);
await editFieldsNode.setSingleFieldValue('MyValue', 'number', 1234);
await n8n.ndv.close();
}
@@ -0,0 +1,148 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Folders - Advanced Operations', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe('Duplicate workflows', () => {
test('should duplicate workflow within root folder from personal projects', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const { name: workflowName } = await n8n.api.workflows.createInProject(projectId);
await n8n.navigate.toProject(projectId);
const workflowCard = n8n.workflows.cards.getWorkflow(workflowName);
await n8n.workflows.cards.openCardActions(workflowCard);
await n8n.workflows.cards.getCardAction('duplicate').click();
const duplicatePage = await n8n.start.fromNewPage(async () => {
await n8n.modal.clickButton('Duplicate');
});
const duplicatedName = `${workflowName} copy`;
await duplicatePage.navigate.toProject(projectId);
await expect(duplicatePage.workflows.cards.getWorkflow(duplicatedName)).toBeVisible();
});
test('should duplicate workflow within a folder from personal projects', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const folder = await n8n.api.projects.createFolder(projectId);
const { name: workflowName } = await n8n.api.workflows.createInProject(projectId, {
folder: folder.id,
});
await n8n.navigate.toFolder(folder.id, projectId);
const workflowCard = n8n.workflows.cards.getWorkflow(workflowName);
await n8n.workflows.cards.openCardActions(workflowCard);
await n8n.workflows.cards.getCardAction('duplicate').click();
const duplicatePage = await n8n.start.fromNewPage(async () => {
await n8n.modal.clickButton('Duplicate');
});
await duplicatePage.navigate.toFolder(folder.id);
const duplicatedName = `${workflowName} copy`;
await expect(duplicatePage.workflows.cards.getWorkflow(duplicatedName)).toBeVisible();
});
test('should duplicate workflow within a folder from workflow page', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const folder = await n8n.api.projects.createFolder(projectId);
const { name: workflowName, id: workflowId } = await n8n.api.workflows.createInProject(
projectId,
{
folder: folder.id,
},
);
await n8n.navigate.toCanvas(workflowId);
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.getDuplicateMenuItem().click();
const duplicatePage = await n8n.start.fromNewPage(async () => {
await n8n.modal.clickButton('Duplicate');
});
const duplicatedName = `${workflowName} copy`;
await duplicatePage.navigate.toFolder(folder.id, projectId);
await expect(duplicatePage.workflows.cards.getWorkflow(duplicatedName)).toBeVisible();
});
});
test.describe('Drag and drop', () => {
test('should drag and drop folders into folders', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject('Drag and Drop Test');
await n8n.navigate.toProject(projectId);
const targetFolder = await n8n.api.projects.createFolder(projectId, 'Drag me');
const destinationFolder = await n8n.api.projects.createFolder(
projectId,
'Folder Destination',
);
const sourceFolderCard = n8n.workflows.cards.getFolder(targetFolder.name);
const destinationFolderCard = n8n.workflows.cards.getFolder(destinationFolder.name);
await n8n.interactions.precisionDragToTarget(sourceFolderCard, destinationFolderCard);
await expect(
n8n.notifications.getNotificationByTitleOrContent(
`${targetFolder.name} has been moved to ${destinationFolder.name}`,
),
).toBeVisible();
await expect(n8n.workflows.cards.getFolders()).toHaveCount(1);
await n8n.workflows.cards.openFolder(destinationFolder.name);
await expect(n8n.workflows.cards.getFolder(targetFolder.name)).toBeVisible();
});
test('should drag and drop folders into project root breadcrumb', async ({ n8n }) => {
const project = await n8n.api.projects.createProject('Drag to root test');
await n8n.navigate.toProject(project.id);
const parentFolder = await n8n.api.projects.createFolder(project.id, 'Parent Folder');
const targetFolder = await n8n.api.projects.createFolder(
project.id,
'To Project root',
parentFolder.id,
);
await n8n.navigate.toFolder(parentFolder.id, project.id);
const sourceFolderCard = n8n.workflows.cards.getFolder(targetFolder.name);
const projectBreadcrumb = n8n.breadcrumbs.getHomeProjectBreadcrumb();
await n8n.interactions.precisionDragToTarget(sourceFolderCard, projectBreadcrumb);
await expect(
n8n.notifications.getNotificationByTitleOrContent(
`${targetFolder.name} has been moved to ${project.name}`,
),
).toBeVisible();
await expect(n8n.workflows.cards.getFolders()).toHaveCount(0);
await n8n.navigate.toProject(project.id);
await expect(n8n.workflows.cards.getFolder(targetFolder.name)).toBeVisible();
});
test('should drag and drop workflows into folders', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject('Drag and Drop WF Test');
const { name: workflowName } = await n8n.api.workflows.createInProject(projectId, {});
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.navigate.toProject(projectId);
const sourceWorkflowCard = n8n.workflows.cards.getWorkflow(workflowName);
const destinationFolderCard = n8n.workflows.cards.getFolder(destinationFolder.name);
await n8n.interactions.precisionDragToTarget(sourceWorkflowCard, destinationFolderCard);
await expect(
n8n.notifications.getNotificationByTitleOrContent(
`${workflowName} has been moved to ${destinationFolder.name}`,
),
).toBeVisible();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
await n8n.workflows.cards.openFolder(destinationFolder.name);
await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeVisible();
});
});
});
@@ -0,0 +1,187 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Folders - Basic Operations', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
const FOLDER_CREATED_NOTIFICATION = 'Folder created';
test('should create folder from the workflows page using addResource dropdown', async ({
n8n,
}) => {
await n8n.start.fromNewProject();
const folderName = await n8n.workflows.addFolder();
await expect(n8n.workflows.cards.getFolder(folderName)).toBeVisible();
await expect(n8n.workflows.cards.getFolders()).toHaveCount(1);
});
test('should create folder from inside a folder', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const folder = await n8n.api.projects.createFolder(projectId);
const folderName = folder.name;
await n8n.workflows.cards.openFolder(folderName);
const childFolderName = await n8n.workflows.addFolder();
await expect(n8n.workflows.cards.getFolder(childFolderName)).toBeVisible();
});
test('should create a folder from breadcrumbs', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const folder = await n8n.api.projects.createFolder(projectId);
const folderName = folder.name;
await n8n.workflows.cards.openFolder(folderName);
// This opens the folder actions menu
await n8n.workflows.getFolderBreadcrumbsActions().click();
await n8n.workflows.getFolderBreadcrumbsAction('create').click();
const childFolderName = 'My Child Folder';
await n8n.workflows.fillFolderModal(childFolderName);
await expect(n8n.workflows.cards.getFolder(childFolderName)).toBeVisible();
});
test('should create a folder from the list header button', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
await n8n.api.projects.createFolder(projectId);
await n8n.workflows.addFolderButton().click();
const childFolderName = 'My Child Folder';
await n8n.workflows.fillFolderModal(childFolderName);
await expect(n8n.workflows.cards.getFolder(childFolderName)).toBeVisible();
});
test('should create a folder from the card dropdown', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const folder = await n8n.api.projects.createFolder(projectId);
const folderName = folder.name;
const folderCard = n8n.workflows.cards.getFolder(folderName);
await n8n.workflows.cards.openCardActions(folderCard);
await n8n.workflows.cards.getCardAction('create').click();
const childFolderName = 'My Child Folder';
await n8n.workflows.fillFolderModal(childFolderName);
await expect(n8n.workflows.cards.getFolder(childFolderName)).toBeVisible();
});
test('should navigate from nested folder back to project root via breadcrumbs', async ({
n8n,
}) => {
const projectId = await n8n.start.fromNewProject();
const parentFolder = await n8n.api.projects.createFolder(projectId);
const childFolder = await n8n.api.projects.createFolder(
projectId,
'Child Folder',
parentFolder.id,
);
const grandChildFolder = await n8n.api.projects.createFolder(
projectId,
'Grand Child Folder',
childFolder.id,
);
await n8n.navigate.toFolder(grandChildFolder.id, projectId);
await expect(n8n.breadcrumbs.getCurrentBreadcrumb()).toContainText(grandChildFolder.name);
// Hidden breadcrumb should be visible because not all breadcrumbs can fit in the UI
await n8n.breadcrumbs.getHiddenBreadcrumbs().click();
await expect(n8n.breadcrumbs.getActionToggleDropdown(parentFolder.id)).toBeVisible();
await n8n.breadcrumbs.getBreadcrumb(childFolder.name).click();
await expect(n8n.workflows.cards.getFolder(grandChildFolder.name)).toBeVisible();
await n8n.breadcrumbs.getBreadcrumb(parentFolder.name).click();
await expect(n8n.workflows.cards.getFolder(childFolder.name)).toBeVisible();
await n8n.breadcrumbs.getHomeProjectBreadcrumb().click();
await expect(n8n.workflows.cards.getFolder(parentFolder.name)).toBeVisible();
});
test('should find nested folders through search from project root', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const rootFolder = await n8n.api.projects.createFolder(projectId, 'Root Test Folder');
const childFolder = await n8n.api.projects.createFolder(
projectId,
'Child Test Folder',
rootFolder.id,
);
const grandChildFolder = await n8n.api.projects.createFolder(
projectId,
'Grand Child Test Folder',
childFolder.id,
);
// Start at project root
await n8n.navigate.toProject(projectId);
// Search for "Grand Child" from root - should find the deeply nested folder
await n8n.workflows.search('Grand Child');
// Verify the grandchild folder appears in search results
await expect(n8n.workflows.cards.getFolder(grandChildFolder.name)).toBeVisible();
// Verify other folders are filtered out
await expect(n8n.workflows.cards.getFolder(rootFolder.name)).toBeHidden();
await expect(n8n.workflows.cards.getFolder(childFolder.name)).toBeHidden();
// Clear search and verify all folders are shown again
await n8n.workflows.clearSearch();
await expect(n8n.workflows.cards.getFolder(rootFolder.name)).toBeVisible();
await expect(n8n.workflows.cards.getFolder(childFolder.name)).toBeHidden(); // Child is inside root
await expect(n8n.workflows.cards.getFolder(grandChildFolder.name)).toBeHidden(); // Grandchild is inside child
});
test('should create workflow in a folder', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const folder = await n8n.api.projects.createFolder(projectId);
await n8n.navigate.toFolder(folder.id, projectId);
await n8n.workflows.addResource.workflow();
// Change name to trigger save
await n8n.canvas.setWorkflowName('Workflow in Folder');
await n8n.page.keyboard.press('Enter');
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.navigate.toFolder(folder.id, projectId);
await expect(n8n.workflows.cards.getWorkflows()).toBeVisible();
});
test('should not create folders with invalid names in the UI', async ({ n8n }) => {
await n8n.start.fromNewProject();
const invalidNames = ['folder[test]', 'folder/test'];
const errorMessage = 'Folder name cannot contain the following characters';
const emptyErrorMessage = 'Folder name cannot be empty';
const tooLongErrorMessage = 'Folder name cannot be longer than 128 characters';
const dotsErrorMessage = 'Folder name cannot contain only dots';
await n8n.workflows.addResource.folder();
for (const invalidName of invalidNames) {
await n8n.modal.fillInput(invalidName);
await expect(n8n.modal.container.getByText(errorMessage, { exact: false })).toBeVisible();
}
await n8n.modal.fillInput('');
await expect(n8n.modal.container.getByText(emptyErrorMessage)).toBeVisible();
await n8n.modal.fillInput('a'.repeat(129));
await expect(n8n.modal.container.getByText(tooLongErrorMessage)).toBeVisible();
await n8n.modal.fillInput('...');
await expect(n8n.modal.container.getByText(dotsErrorMessage)).toBeVisible();
});
test('should navigate to a folder using card actions', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const folder = await n8n.api.projects.createFolder(projectId);
const folderName = folder.name;
const folderCard = n8n.workflows.cards.getFolder(folderName);
await n8n.workflows.cards.openCardActions(folderCard);
await n8n.workflows.cards.getCardAction('open').click();
await expect(n8n.breadcrumbs.getCurrentBreadcrumb()).toContainText(folderName);
});
test('should navigate to a folder using notification', async ({ n8n }) => {
await n8n.start.fromNewProject();
const folderName = await n8n.workflows.addFolder();
await n8n.notifications
.getNotificationByTitleOrContent(FOLDER_CREATED_NOTIFICATION)
.getByText('Open folder')
.click();
await expect(n8n.breadcrumbs.getCurrentBreadcrumb()).toContainText(folderName);
});
});
@@ -0,0 +1,294 @@
import { test, expect } from '../../../fixtures/base';
test.describe('Folders - Operations', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe('Rename and delete folders', () => {
test('should rename folder from breadcrumb dropdown', async ({ n8n }) => {
await n8n.start.fromNewProject();
const folderName = await n8n.workflows.addFolder();
const folderCard = n8n.workflows.cards.getFolder(folderName);
await n8n.workflows.cards.openCardActions(folderCard);
await n8n.workflows.cards.getCardAction('open').click();
await n8n.breadcrumbs.renameCurrentBreadcrumb('Renamed');
await n8n.breadcrumbs.getHomeProjectBreadcrumb().click();
await expect(n8n.workflows.cards.getFolder('Renamed')).toBeVisible();
});
test('should rename folder from card dropdown', async ({ n8n }) => {
await n8n.start.fromNewProject();
const folderName = await n8n.workflows.addFolder();
const folderCard = n8n.workflows.cards.getFolder(folderName);
await n8n.workflows.cards.openCardActions(folderCard);
await n8n.workflows.cards.getCardAction('rename').click();
await n8n.workflows.fillFolderModal('Renamed', 'Rename');
await expect(n8n.workflows.cards.getFolder('Renamed')).toBeVisible();
});
test('should delete empty folder from card dropdown', async ({ n8n }) => {
await n8n.start.fromNewProject();
const folderName = await n8n.workflows.addFolder();
await n8n.workflows.cards.deleteFolder(folderName);
await expect(n8n.workflows.cards.getFolder(folderName)).toBeHidden();
});
test('should delete empty folder from breadcrumb dropdown', async ({ n8n }) => {
await n8n.start.fromNewProject();
const folderName = await n8n.workflows.addFolder();
await n8n.workflows.cards.openFolder(folderName);
await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click();
await n8n.breadcrumbs.getActionToggleDropdown('delete').click();
await expect(n8n.workflows.cards.getFolder(folderName)).toBeHidden();
});
test('should warn before deleting non-empty folder from breadcrumb dropdown', async ({
n8n,
}) => {
const { id: projectId } = await n8n.api.projects.createProject();
const folder = await n8n.api.projects.createFolder(projectId);
await n8n.api.workflows.createInProject(projectId, {
folder: folder.id,
});
await n8n.navigate.toFolder(folder.id, projectId);
await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click();
await n8n.breadcrumbs.getActionToggleDropdown('delete').click();
await expect(n8n.workflows.deleteFolderModal()).toBeVisible();
await expect(n8n.workflows.deleteModalConfirmButton()).toBeDisabled();
});
test('should warn before deleting non-empty folder from card dropdown', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const folder = await n8n.api.projects.createFolder(projectId);
await n8n.api.workflows.createInProject(projectId, {
folder: folder.id,
});
await n8n.navigate.toProject(projectId);
const folderCard = n8n.workflows.cards.getFolder(folder.name);
await n8n.workflows.cards.openCardActions(folderCard);
await n8n.workflows.cards.getCardAction('delete').click();
await expect(n8n.workflows.deleteFolderModal()).toBeVisible();
await expect(n8n.workflows.deleteModalConfirmButton()).toBeDisabled();
});
test('should transfer contents when deleting non-empty folder - from card dropdown', async ({
n8n,
}) => {
const { id: projectId } = await n8n.api.projects.createProject();
const folderToDelete = await n8n.api.projects.createFolder(projectId);
await n8n.api.workflows.createInProject(projectId, {
folder: folderToDelete.id,
});
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.navigate.toProject(projectId);
const folderCard = n8n.workflows.cards.getFolder(folderToDelete.name);
await n8n.workflows.cards.openCardActions(folderCard);
await n8n.workflows.cards.getCardAction('delete').click();
await n8n.workflows.deleteModalTransferRadioButton().click();
await n8n.workflows.transferFolderDropdown().click();
await n8n.workflows.transferFolderOption(destinationFolder.name).click();
await n8n.workflows.deleteModalConfirmButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Folder deleted'),
).toBeVisible();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getWorkflows()).toBeVisible();
});
});
test.describe('Move folders and workflows', () => {
test('should move empty folder to another folder - from folder card action', async ({
n8n,
}) => {
const { id: projectId } = await n8n.api.projects.createProject();
const sourceFolder = await n8n.api.projects.createFolder(projectId);
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.navigate.toProject(projectId);
const sourceFolderCard = n8n.workflows.cards.getFolder(sourceFolder.name);
await n8n.workflows.cards.openCardActions(sourceFolderCard);
await n8n.workflows.cards.getCardAction('move').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
await n8n.workflows.moveFolderOption(destinationFolder.name).click();
await n8n.workflows.moveFolderConfirmButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Successfully moved folder'),
).toBeVisible();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible();
});
test('should move folder with contents to another folder - from folder card action', async ({
n8n,
}) => {
const { id: projectId } = await n8n.api.projects.createProject();
const sourceFolder = await n8n.api.projects.createFolder(projectId);
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.api.workflows.createInProject(projectId, {
folder: sourceFolder.id,
});
await n8n.navigate.toProject(projectId);
const sourceFolderCard = n8n.workflows.cards.getFolder(sourceFolder.name);
await n8n.workflows.cards.openCardActions(sourceFolderCard);
await n8n.workflows.cards.getCardAction('move').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
await n8n.workflows.moveFolderOption(destinationFolder.name).click();
await n8n.workflows.moveFolderConfirmButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Successfully moved folder'),
).toBeVisible();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible();
await n8n.workflows.cards.openFolder(sourceFolder.name);
await expect(n8n.workflows.cards.getWorkflows()).toBeVisible();
});
test('should move empty folder to another folder - from list breadcrumbs', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const sourceFolder = await n8n.api.projects.createFolder(projectId);
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.navigate.toFolder(sourceFolder.id, projectId);
await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click();
await n8n.breadcrumbs.getActionToggleDropdown('move').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
await n8n.workflows.moveFolderOption(destinationFolder.name).click();
await n8n.workflows.moveFolderConfirmButton().click();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible();
});
test('should move folder with contents to another folder - from list dropdown', async ({
n8n,
}) => {
const { id: projectId } = await n8n.api.projects.createProject();
const sourceFolder = await n8n.api.projects.createFolder(projectId);
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.api.workflows.createInProject(projectId, {
folder: sourceFolder.id,
});
await n8n.navigate.toFolder(sourceFolder.id, projectId);
await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click();
await n8n.breadcrumbs.getActionToggleDropdown('move').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
await n8n.workflows.moveFolderOption(destinationFolder.name).click();
await n8n.workflows.moveFolderConfirmButton().click();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible();
await n8n.workflows.cards.openFolder(sourceFolder.name);
await expect(n8n.workflows.cards.getWorkflows()).toBeVisible();
});
test('should move folder to project root - from folder card action', async ({ n8n }) => {
const project = await n8n.api.projects.createProject();
const parentFolder = await n8n.api.projects.createFolder(project.id);
const childFolderName = 'Child Folder';
const childFolder = await n8n.api.projects.createFolder(
project.id,
childFolderName,
parentFolder.id,
);
await n8n.navigate.toFolder(parentFolder.id, project.id);
const childFolderCard = n8n.workflows.cards.getFolder(childFolder.name);
await n8n.workflows.cards.openCardActions(childFolderCard);
await n8n.workflows.cards.getCardAction('move').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
const rootOption = 'No folder (project root)';
await n8n.workflows.moveFolderOption(rootOption).click();
await n8n.workflows.moveFolderConfirmButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Successfully moved folder'),
).toBeVisible();
await n8n.navigate.toProject(project.id);
await expect(n8n.workflows.cards.getFolder(childFolder.name)).toBeVisible();
});
test('should move workflow from project root to folder', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const destinationFolder = await n8n.api.projects.createFolder(projectId);
await n8n.api.workflows.createInProject(projectId);
await n8n.navigate.toProject(projectId);
const workflowCard = n8n.workflows.cards.getWorkflows().first();
await n8n.workflows.cards.openCardActions(workflowCard);
await n8n.workflows.cards.getCardAction('moveToFolder').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
await n8n.workflows.moveFolderOption(destinationFolder.name).click();
await n8n.workflows.moveFolderConfirmButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Successfully moved workflow'),
).toBeVisible();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getWorkflows()).toBeVisible();
});
test('should move workflow to another folder', async ({ n8n }) => {
const { id: projectId } = await n8n.api.projects.createProject();
const sourceFolder = await n8n.api.projects.createFolder(projectId);
const destinationFolder = await n8n.api.projects.createFolder(projectId);
const { name: workflowName } = await n8n.api.workflows.createInProject(projectId, {
folder: sourceFolder.id,
});
await n8n.navigate.toFolder(sourceFolder.id, projectId);
const workflowCard = n8n.workflows.cards.getWorkflow(workflowName);
await n8n.workflows.cards.openCardActions(workflowCard);
await n8n.workflows.cards.getCardAction('moveToFolder').click();
await expect(n8n.workflows.moveFolderModal()).toBeVisible();
await n8n.workflows.moveFolderDropdown().click();
await n8n.workflows.moveFolderOption(destinationFolder.name).click();
await n8n.workflows.moveFolderConfirmButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Successfully moved workflow'),
).toBeVisible();
await n8n.navigate.toFolder(destinationFolder.id, projectId);
await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeVisible();
await n8n.navigate.toFolder(sourceFolder.id, projectId);
await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeHidden();
});
});
});
@@ -0,0 +1,222 @@
import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.describe('Project Settings - Member Management', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
});
test('should display project settings page with correct layout @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `UI Test ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Verify basic project settings form elements are visible (inner controls)
await expect(n8n.projectSettings.getNameInput()).toBeVisible();
await expect(n8n.projectSettings.getDescriptionTextarea()).toBeVisible();
await n8n.projectSettings.expectMembersSelectIsVisible();
// Verify members table is visible when there are members
await n8n.projectSettings.expectTableIsVisible();
// Initially should have only the owner (current user)
await n8n.projectSettings.expectTableHasMemberCount(1);
// Verify save/cancel buttons are disabled initially (no changes)
await expect(n8n.projectSettings.getSaveButton()).toBeDisabled();
await expect(n8n.projectSettings.getCancelButton()).toBeDisabled();
// Delete button should always be visible
await expect(n8n.projectSettings.getDeleteButton()).toBeVisible();
});
test('should allow editing project name and description @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `Edit Test ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Update project name
const newName = 'Updated Project Name';
await n8n.projectSettings.fillProjectName(newName);
// Update project description
const newDescription = 'This is an updated project description.';
await n8n.projectSettings.fillProjectDescription(newDescription);
// Save changes
await n8n.projectSettings.clickSaveButton();
// Wait for success notification
await expect(
n8n.page.getByText('Project Updated Project Name saved successfully', { exact: false }),
).toBeVisible();
// Verify the form shows the updated values
await n8n.projectSettings.expectProjectNameValue(newName);
await n8n.projectSettings.expectProjectDescriptionValue(newDescription);
});
test('should display members table with correct structure @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `Table Structure ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
const table = n8n.projectSettings.getMembersTable();
// Verify table headers are present
await expect(table.getByText('User')).toBeVisible();
await expect(table.getByText('Role')).toBeVisible();
// Verify the owner is displayed in the table
const memberRows = table.locator('tbody tr');
await expect(memberRows).toHaveCount(1);
// Verify owner cannot change their own role
const ownerRow = memberRows.first();
const roleDropdown = ownerRow.getByTestId('project-member-role-dropdown');
await expect(roleDropdown).toHaveCount(0);
});
test('should display role dropdown for members but not for current user @auth:owner', async ({
n8n,
}) => {
// Create a new project
const projectName = `Role Dropdown ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Current user (owner) should not have a role dropdown
const currentUserRow = n8n.page.locator('tbody tr').first();
await expect(currentUserRow.getByTestId('project-member-role-dropdown')).toHaveCount(0);
// The role should be displayed as static text for the current user
await expect(currentUserRow.getByText('Admin')).toBeVisible();
});
test('should show project settings form validation @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `Validation ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Clear the project name (required field)
await n8n.projectSettings.fillProjectName('');
// Save button should be disabled when required field is empty
await expect(n8n.projectSettings.getSaveButton()).toBeDisabled();
// Fill in a valid name
await n8n.projectSettings.fillProjectName('Valid Project Name');
// Save button should now be enabled
await expect(n8n.projectSettings.getSaveButton()).toBeEnabled();
});
test('should handle unsaved changes state @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `Unsaved Changes ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Initially, save and cancel buttons should be disabled (no changes)
await expect(n8n.projectSettings.getSaveButton()).toBeDisabled();
await expect(n8n.projectSettings.getCancelButton()).toBeDisabled();
// Make a change to the project name
await n8n.projectSettings.fillProjectName('Modified Name');
// Save and cancel buttons should now be enabled
await expect(n8n.projectSettings.getSaveButton()).toBeEnabled();
await expect(n8n.projectSettings.getCancelButton()).toBeEnabled();
// Cancel changes
await n8n.projectSettings.clickCancelButton();
// Buttons should be disabled again (no changes)
await expect(n8n.projectSettings.getSaveButton()).toBeDisabled();
await expect(n8n.projectSettings.getCancelButton()).toBeDisabled();
});
test('should display delete project section with warning @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `Delete Test ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Scroll to bottom to see delete section
await n8n.projectSettings.getDeleteButton().scrollIntoViewIfNeeded();
// Verify danger section is visible with warning
// Copy was updated in UI to use sentence case and expanded description
await expect(n8n.page.getByText('Danger zone')).toBeVisible();
await expect(
n8n.page.getByText(
'When deleting a project, you can also choose to move all workflows and credentials to another project.',
),
).toBeVisible();
await expect(n8n.projectSettings.getDeleteButton()).toBeVisible();
});
test('should persist settings after page reload @auth:owner', async ({ n8n }) => {
// Create a new project
const projectName = `Persistence ${nanoid(8)}`;
const { projectId } = await n8n.projectComposer.createProject(projectName);
// Navigate to project settings
await n8n.navigate.toProjectSettings(projectId);
await expect(n8n.projectSettings.getTitle()).toHaveText(projectName);
// Update project details
const newProjectName = 'Persisted Project Name';
const projectDescription = 'This description should persist after reload';
await n8n.projectSettings.fillProjectName(newProjectName);
await n8n.projectSettings.fillProjectDescription(projectDescription);
await n8n.projectSettings.clickSaveButton();
// Wait for save confirmation (partial match to include project name)
await expect(
n8n.page.getByText('Project Persisted Project Name saved successfully', { exact: false }),
).toBeVisible();
// Reload the page
await n8n.page.reload();
await expect(n8n.projectSettings.getTitle()).toHaveText('Persisted Project Name');
// Verify data persisted
await n8n.projectSettings.expectProjectNameValue(newProjectName);
await n8n.projectSettings.expectProjectDescriptionValue(projectDescription);
// Verify table still shows the owner
await n8n.projectSettings.expectTableHasMemberCount(1);
});
});
@@ -0,0 +1,151 @@
import {
INSTANCE_ADMIN_CREDENTIALS,
INSTANCE_MEMBER_CREDENTIALS,
INSTANCE_OWNER_CREDENTIALS,
} from '../../../config/test-users';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'projects-move-resources' } } });
test.describe('Projects - Moving Resources @db:reset', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
// Enable features required for project workflows and moving resources
await n8n.api.enableFeature('sharing');
await n8n.api.enableFeature('folders');
await n8n.api.enableFeature('advancedPermissions');
await n8n.api.enableFeature('projectRole:admin');
await n8n.api.enableFeature('projectRole:editor');
await n8n.api.setMaxTeamProjectsQuota(-1);
// Create workflow + credential in Home/Personal project
await n8n.api.workflows.createWorkflow({
name: 'Workflow in Home project',
nodes: [],
connections: {},
active: false,
});
await n8n.api.credentials.createCredential({
name: 'Credential in Home project',
type: 'notionApi',
data: { apiKey: '1234567890' },
});
// Create Project 1 with resources
const project1 = await n8n.api.projects.createProject('Project 1');
await n8n.api.workflows.createInProject(project1.id, {
name: 'Workflow in Project 1',
});
await n8n.api.credentials.createCredential({
name: 'Credential in Project 1',
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId: project1.id,
});
// Create Project 2 with resources
const project2 = await n8n.api.projects.createProject('Project 2');
await n8n.api.workflows.createInProject(project2.id, {
name: 'Workflow in Project 2',
});
await n8n.api.credentials.createCredential({
name: 'Credential in Project 2',
type: 'notionApi',
data: { apiKey: '1234567890' },
projectId: project2.id,
});
// Navigate to home to load sidebar with new projects
await n8n.goHome();
});
test('should move the workflow to expected projects @auth:owner', async ({ n8n }) => {
// Move workflow from Personal to Project 2
await n8n.sideBar.clickPersonalMenuItem();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('Workflow in Home project', 'Project 2');
// Verify Personal has 0 workflows
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
// Move workflow from Project 1 to Project 2
await n8n.sideBar.clickProjectMenuItem('Project 1');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('Workflow in Project 1', 'Project 2');
// Move workflow from Project 2 to member user
await n8n.sideBar.clickProjectMenuItem('Project 2');
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(3);
await n8n.workflowComposer.moveToProject(
'Workflow in Home project',
INSTANCE_MEMBER_CREDENTIALS[0].email,
null,
);
// Verify Project 2 has 2 workflows remaining
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(2);
});
test('should move the credential to expected projects @auth:owner', async ({ n8n }) => {
// Move credential from Project 1 to Project 2
await n8n.sideBar.clickProjectMenuItem('Project 1');
await n8n.sideBar.clickCredentialsLink();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
const credentialCard1 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard1);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption('Project 2');
await n8n.resourceMoveModal.clickMoveCredentialButton();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(0);
// Move credential from Project 2 to admin user
await n8n.sideBar.clickProjectMenuItem('Project 2');
await n8n.sideBar.clickCredentialsLink();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(2);
const credentialCard2 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard2);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption(INSTANCE_ADMIN_CREDENTIALS.email);
await n8n.resourceMoveModal.clickMoveCredentialButton();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
// Move credential from admin user (Home) back to owner user
await n8n.sideBar.clickHomeMenuItem();
await n8n.navigate.toCredentials();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(3);
const credentialCard3 = n8n.credentials.cards.getCredential('Credential in Project 1');
await n8n.credentials.cards.openCardActions(credentialCard3);
await n8n.credentials.cards.getCardAction('move').click();
await expect(n8n.resourceMoveModal.getMoveCredentialButton()).toBeDisabled();
await n8n.resourceMoveModal.getProjectSelectCredential().locator('input').click();
await expect(n8n.page.getByRole('option')).toHaveCount(6);
await n8n.resourceMoveModal.selectProjectOption(INSTANCE_OWNER_CREDENTIALS.email);
await n8n.resourceMoveModal.clickMoveCredentialButton();
// Verify final state: 3 credentials total, 2 with Personal badge
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(3);
await expect(
n8n.credentials.cards.getCredentials().filter({ hasText: 'Personal' }),
).toHaveCount(2);
});
});
@@ -0,0 +1,264 @@
import { nanoid } from 'nanoid';
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../config/test-users';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'projects' } } });
const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Sub-workflow';
const NOTION_NODE_NAME = 'Notion';
const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields (Set)';
const NOTION_API_KEY = 'abc123Playwright';
test.describe('Projects @db:reset', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
// Enable features required for project workflows and moving resources
await n8n.api.enableFeature('sharing');
await n8n.api.enableFeature('folders');
await n8n.api.enableFeature('advancedPermissions');
await n8n.api.enableFeature('projectRole:admin');
await n8n.api.enableFeature('projectRole:editor');
await n8n.api.setMaxTeamProjectsQuota(-1);
});
test.describe('when starting from scratch', () => {
test('should not show project add button and projects to a member if not invited to any project @auth:member', async ({
n8n,
}) => {
await n8n.sideBar.universalAdd();
await expect(n8n.sideBar.getProjectButtonInUniversalAdd()).toContainClass('is-disabled');
await expect(n8n.sideBar.getProjectMenuItems()).toHaveCount(0);
});
// This test needs empty credentials list - must run before tests that create credentials
test('should allow changing an inaccessible credential when the workflow was moved to a team project @auth:owner', async ({
n8n,
}) => {
await n8n.navigate.toCredentials();
await n8n.credentials.emptyListCreateCredentialButton.click();
await n8n.credentials.createCredentialFromCredentialPicker(
'Notion API',
{
apiKey: NOTION_API_KEY,
},
{
name: 'Credential in Home project',
},
);
await n8n.navigate.toWorkflows();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
const { projectId, projectName } = await n8n.projectComposer.createProject('Project 1');
await n8n.api.projects.addUserToProjectByEmail(
projectId,
INSTANCE_MEMBER_CREDENTIALS[0].email,
'project:editor',
);
await n8n.sideBar.clickPersonalMenuItem();
await n8n.sideBar.clickWorkflowsLink();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflowComposer.moveToProject('My workflow', projectName);
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
await n8n.sideBar.clickProjectMenuItem(projectName);
await n8n.navigate.toWorkflows();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await expect(
n8n.workflows.cards.getWorkflow('My workflow').getByText('Personal'),
).toBeHidden();
await n8n.sideBar.clickSignout();
await n8n.page.waitForURL(/\/signin/);
await n8n.signIn.loginWithEmailAndPassword(
INSTANCE_MEMBER_CREDENTIALS[0].email,
INSTANCE_MEMBER_CREDENTIALS[0].password,
);
await expect(n8n.workflows.getProjectName()).toBeVisible();
await n8n.sideBar.clickProjectMenuItem(projectName);
await n8n.navigate.toWorkflows();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await n8n.workflows.cards.clickWorkflowCard('My workflow');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await n8n.canvas.openNode('Append a block');
await expect(n8n.ndv.getCredentialSelectInput()).toBeEnabled();
});
test('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', async ({
n8n,
}) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await n8n.api.credentials.getCredentialsByProject(projectId);
expect(credentials).toHaveLength(1);
const { projectId: project2Id } = await n8n.projectComposer.createProject();
const credentials2 = await n8n.api.credentials.getCredentialsByProject(project2Id);
expect(credentials2).toHaveLength(0);
});
test('should create sub-workflow and credential in the sub-workflow in the same project @auth:owner', async ({
n8n,
}) => {
const { projectName } = await n8n.projectComposer.createProject();
await n8n.sideBar.addWorkflowFromUniversalAdd(projectName);
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.canvas.addNode(EXECUTE_WORKFLOW_NODE_NAME, { action: 'Execute A Sub Workflow' });
const subn8n = await n8n.start.fromNewPage(() =>
n8n.ndv.selectWorkflowResource(`Create a Sub-Workflow in '${projectName}'`),
);
await subn8n.ndv.clickBackToCanvasButton();
await subn8n.canvas.deleteNodeByName('Replace me with your logic');
await subn8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block' });
await subn8n.credentialsComposer.createFromNdv({
apiKey: NOTION_API_KEY,
});
await subn8n.ndv.clickBackToCanvasButton();
await subn8n.canvas.waitForSaveWorkflowCompleted();
await subn8n.navigate.toWorkflows();
await subn8n.sideBar.clickProjectMenuItem(projectName);
await subn8n.navigate.toWorkflows();
await expect(subn8n.workflows.cards.getWorkflows()).toHaveCount(2);
await expect(subn8n.page.getByRole('heading', { name: 'My Sub-Workflow' })).toBeVisible();
await subn8n.navigate.toCredentials();
await expect(subn8n.credentials.cards.getCredentials()).toHaveCount(1);
await expect(subn8n.page.getByRole('heading', { name: 'Notion account' })).toBeVisible();
});
test('should create credential from workflow in the correct project after editor page refresh @auth:owner', async ({
n8n,
}) => {
const { projectName } = await n8n.projectComposer.createProject(`Dev ${nanoid(8)}`);
await n8n.sideBar.clickProjectMenuItem(projectName);
await n8n.navigate.toWorkflows();
await n8n.workflows.clickNewWorkflowButtonFromOverview();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.waitForSaveWorkflowCompleted();
// Wait for URL to update with workflow ID after save
await n8n.page.waitForURL(/\/workflow\/[^/]+$/);
await n8n.page.reload();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block' });
await n8n.credentialsComposer.createFromNdv({
apiKey: NOTION_API_KEY,
});
await n8n.ndv.close();
await n8n.canvas.waitForSaveWorkflowCompleted();
await n8n.sideBar.clickProjectMenuItem(projectName);
await n8n.navigate.toCredentials();
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(1);
});
test('should set and update project icon @auth:admin', async ({ n8n }) => {
const DEFAULT_ICON = 'layers';
const NEW_PROJECT_NAME = `Test Project ${nanoid(8)}`;
await n8n.projectComposer.createProject(NEW_PROJECT_NAME);
await expect(n8n.projectSettings.getIconPickerButton().locator('svg')).toHaveAttribute(
'data-icon',
DEFAULT_ICON,
);
await n8n.projectSettings.clickIconPickerButton();
await n8n.projectSettings.selectIconTab('Emojis');
await n8n.projectSettings.selectFirstEmoji();
await expect(
n8n.notifications.getNotificationByTitle('Project icon updated successfully'),
).toBeVisible();
await expect(n8n.projectSettings.getIconPickerButton()).toContainText('😀');
await n8n.sideBar.expand();
await expect(
n8n.sideBar.getProjectMenuItems().filter({ hasText: NEW_PROJECT_NAME }),
).toContainText('😀');
});
test('should be able to create a workflow when in the workflow editor @auth:owner', async ({
n8n,
}) => {
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
// Wait for URL to be updated (new=true removed after save)
await n8n.page.waitForURL(/\/workflow\/[^?]+$/);
const savedWorkflowUrl = n8n.page.url();
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
// Close dropdown/menu
await n8n.page.locator('body').click();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await n8n.page.goBack();
expect(n8n.page.url()).toBe(savedWorkflowUrl);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
// New workflows redirect to /workflow/<id>?new=true
await n8n.page.waitForURL(/\/workflow\/[a-zA-Z0-9_-]+\?.*new=true/);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
});
});
});
@@ -0,0 +1,28 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const requirements: TestRequirements = {
workflow: {
'Test_ado_1338.json': 'Test Workflow ADO-1338',
},
};
test.describe('ADO-1338-ndv-missing-input-panel', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should show the input and output panels when node is missing input and output data', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow successfully executed',
);
await n8n.canvas.openNode('Discourse1');
await expect(n8n.ndv.inputPanel.get()).toBeVisible();
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
});
});
@@ -0,0 +1,38 @@
import { test, expect } from '../../../fixtures/base';
test.describe('ADO-2230 NDV Pagination Reset', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should reset pagination if data size changes to less than current page', async ({
n8n,
}) => {
await n8n.start.fromImportedWorkflow('NDV-debug-generate-data.json');
await n8n.canvas.openNode('DebugHelper');
await n8n.ndv.execute();
await n8n.notifications.quickCloseAll();
const outputPagination = n8n.ndv.getOutputPagination();
await expect(outputPagination).toBeVisible();
await expect(n8n.ndv.getOutputPaginationPages()).toHaveCount(5);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).not.toBeEmpty();
const firstPageContent = await n8n.ndv.outputPanel.getTbodyCell(0, 0).textContent();
await n8n.ndv.navigateToOutputPage(4);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).not.toHaveText(firstPageContent ?? '');
await n8n.ndv.setParameterInputValue('randomDataCount', '50');
await n8n.ndv.execute();
await n8n.notifications.quickCloseAll();
await expect(n8n.ndv.getOutputPaginationPages()).toHaveCount(2);
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).not.toBeEmpty();
});
});
@@ -0,0 +1,71 @@
import { test, expect } from '../../../fixtures/base';
test.describe('ADO-2362 ADO-2350 NDV Prevent clipping long parameters and scrolling to expression', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should show last parameters and open at scroll top of parameters', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test-workflow-with-long-parameters.json');
await n8n.canvas.openNode('Schedule Trigger');
await expect(n8n.ndv.getInlineExpressionEditorInput().first()).toBeVisible();
await n8n.ndv.close();
await n8n.canvas.openNode('Edit Fields1');
await expect(n8n.ndv.getInputLabel().nth(0)).toContainText('Mode');
await expect(n8n.ndv.getInputLabel().nth(0)).toBeVisible();
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveCount(2);
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toHaveText('should be visible!');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toBeVisible();
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(1)).toHaveText('not visible');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(1)).toBeVisible();
await n8n.ndv.close();
await n8n.canvas.openNode('Schedule Trigger');
await expect(n8n.ndv.getNthParameter(0)).toContainText(
'This workflow will run on the schedule ',
);
await expect(n8n.ndv.getInputLabel().nth(0)).toBeVisible();
await expect(n8n.ndv.getInlineExpressionEditorInput()).toHaveCount(2);
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toHaveText('should be visible');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toBeVisible();
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(1)).toHaveText('not visible');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(1)).not.toBeInViewport();
await n8n.ndv.close();
await n8n.canvas.openNode('Slack');
await expect(n8n.ndv.getCredentialsLabel()).toBeVisible();
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toHaveText('should be visible');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toBeVisible();
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(1)).toHaveText('not visible');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(1)).not.toBeInViewport();
});
test('NODE-1272 ensure expressions scrolled to top, not middle', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test-workflow-with-long-parameters.json');
await n8n.canvas.openNode('With long expression');
await expect(n8n.ndv.getInlineExpressionEditorInput().nth(0)).toBeVisible();
const editor = n8n.ndv.getInlineExpressionEditorInput().nth(0);
await expect(editor.locator('.cm-line').nth(0)).toHaveText('1 visible!');
await expect(editor.locator('.cm-line').nth(0)).toBeVisible();
await expect(editor.locator('.cm-line').nth(6)).toHaveText('7 not visible!');
await expect(editor.locator('.cm-line').nth(6)).not.toBeInViewport();
});
});
@@ -0,0 +1,24 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const requirements: TestRequirements = {
workflow: {
'Switch_node_with_null_connection.json': 'Switch Node with Null Connection',
},
};
test.describe('ADO-2929 can load Switch nodes', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('can load workflows with Switch nodes with null at connection index @auth:owner', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await n8n.canvas.deleteNodeByName('Switch');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
});
});
@@ -0,0 +1,96 @@
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-setup-experiment' } } });
const TEMPLATE_HOSTNAME = 'custom.template.host';
const TEMPLATE_HOST = `https://${TEMPLATE_HOSTNAME}/api`;
const TEMPLATE_ID = 1205;
const testTemplate = JSON.parse(
readFileSync(resolveFromRoot('workflows', 'Test_Template_1.json'), 'utf8'),
);
function createTemplateRequirements(): TestRequirements {
return {
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({
'055_template_setup_experience': 'variant',
'069_setup_panel': 'control',
}),
},
config: {
settings: {
templates: {
enabled: true,
host: TEMPLATE_HOST,
},
},
},
intercepts: {
health: {
url: `${TEMPLATE_HOST}/health`,
response: { status: 'OK' },
},
categories: {
url: `${TEMPLATE_HOST}/templates/categories`,
response: { categories: [] },
},
getTemplatePreview: {
url: `${TEMPLATE_HOST}/templates/workflows/${TEMPLATE_ID}`,
response: testTemplate,
},
getTemplate: {
url: `${TEMPLATE_HOST}/workflows/templates/${TEMPLATE_ID}`,
response: {
id: TEMPLATE_ID,
name: testTemplate.workflow.name,
workflow: testTemplate.workflow.workflow,
},
},
},
};
}
test.describe('Template credentials setup @db:reset', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test.beforeEach(async ({ setupRequirements, n8n }) => {
await setupRequirements(createTemplateRequirements());
await n8n.goHome();
});
test('Should take users to canvas when importing template', async ({ n8n }) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await expect(n8n.canvas.getLoadingMask()).toBeHidden({ timeout: 30000 });
await expect(n8n.page).toHaveURL(/\/workflow\/.+\?templateId=.+&new=true/);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.templateCredentialSetup.getCanvasSetupButton()).toBeVisible();
});
test('Loads template setup modal correctly', async ({ n8n }) => {
await n8n.navigate.toTemplateCredentialSetup(TEMPLATE_ID);
await expect(n8n.canvas.getLoadingMask()).toBeHidden({ timeout: 30000 });
await expect(n8n.page).toHaveURL(/\/workflow\/.+\?templateId=.+&new=true/);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.templateCredentialSetup.getCanvasSetupButton()).toBeVisible();
// Open modal via button click
await n8n.templateCredentialSetup.getCanvasSetupButton().click();
await expect(n8n.templateCredentialSetup.getCanvasCredentialModal()).toBeVisible();
const modalSteps = n8n.templateCredentialSetup.getSetupCredentialModalSteps();
await expect(modalSteps).toHaveCount(3);
await expect(modalSteps.nth(0)).toContainText('Shopify');
await expect(modalSteps.nth(1)).toContainText('X (Formerly Twitter)');
await expect(modalSteps.nth(2)).toContainText('Telegram');
});
});
@@ -0,0 +1,33 @@
import { test, expect } from '../../../fixtures/base';
test.describe('AI-1401 AI sub-nodes show node output with no path back in input', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test('should show correct root node for nested sub-nodes in input panel', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_ai_1401.json');
// Execute the workflow first to generate data
await n8n.canvas.executeNode('Edit Fields');
await n8n.notifications.waitForNotification('Node executed successfully');
for (const node of ['hackernews_top', 'hackernews_sub']) {
await n8n.canvas.openNode(node);
await expect(n8n.ndv.getContainer()).toBeVisible();
await expect(n8n.ndv.inputPanel.get()).toBeVisible();
// Switch to JSON mode within the mapping view
await n8n.ndv.inputPanel.switchDisplayMode('json');
// Verify the input node dropdown shows the correct parent nodes
const inputNodeSelect = n8n.ndv.inputPanel.get().locator('[data-test-id*="input-select"]');
await expect(inputNodeSelect).toBeVisible();
await inputNodeSelect.click();
await expect(n8n.page.getByRole('option', { name: 'Edit Fields' })).toBeVisible();
await expect(n8n.page.getByRole('option', { name: 'Manual Trigger' })).toBeVisible();
await expect(n8n.page.getByRole('option', { name: 'No Operation, do nothing' })).toBeHidden();
await n8n.ndv.clickBackToCanvasButton();
}
});
});
@@ -0,0 +1,25 @@
import { test, expect } from '../../../fixtures/base';
test.describe('AI-716 Correctly set up agent model shows error', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test('should not show error when adding a sub-node with credential set-up', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode('AI Agent');
await n8n.page.keyboard.press('Escape');
await n8n.canvas.addNode('OpenAI Chat Model');
await n8n.credentialsComposer.createFromNdv({
apiKey: 'sk-123',
});
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getNodeIssuesByName('OpenAI Chat Model')).toHaveCount(0);
});
});
@@ -0,0 +1,63 @@
import { test, expect } from '../../../fixtures/base';
test.describe('AI-812-partial-execs-broken-when-using-chat-trigger', {
annotation: [
{ type: 'owner', description: 'AI' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Test_chat_partial_execution.json');
await n8n.notifications.quickCloseAll();
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.deselectAll();
});
test.afterEach(async ({ n8n }) => {
await n8n.notifications.quickCloseAll();
await n8n.canvas.logsPanel.clearExecutionData();
await n8n.canvas.logsPanel.sendManualChatMessage('Test Full Execution');
await expect(n8n.canvas.logsPanel.getManualChatMessages()).toHaveCount(4);
await expect(n8n.canvas.logsPanel.getManualChatMessages().last()).toContainText(
'Set 3 with chatInput: Test Full Execution',
);
});
test('should do partial execution when using chat trigger and clicking NDV execute node', async ({
n8n,
}) => {
await n8n.canvas.openNode('Edit Fields1');
await n8n.ndv.execute();
await expect(n8n.canvas.logsPanel.getManualChatModal()).toBeVisible();
await n8n.canvas.logsPanel.sendManualChatMessage('Test Partial Execution');
await expect(n8n.canvas.logsPanel.getManualChatMessages()).toHaveCount(2);
await expect(n8n.canvas.logsPanel.getManualChatMessages().first()).toContainText(
'Test Partial Execution',
);
await expect(n8n.canvas.logsPanel.getManualChatMessages().last()).toContainText(
'Set 2 with chatInput: Test Partial Execution',
);
});
test('should do partial execution when using chat trigger and context-menu execute node', async ({
n8n,
}) => {
// Workaround to prevent the context menu be blocked by the tabbar
await n8n.canvas.dragNodeToRelativePosition('Edit Fields', 0, -100);
await n8n.canvas.executeNodeFromContextMenu('Edit Fields');
await expect(n8n.canvas.logsPanel.getManualChatModal()).toBeVisible();
await n8n.canvas.logsPanel.sendManualChatMessage('Test Partial Execution');
await expect(n8n.canvas.logsPanel.getManualChatMessages()).toHaveCount(2);
await expect(n8n.canvas.logsPanel.getManualChatMessages().first()).toContainText(
'Test Partial Execution',
);
await expect(n8n.canvas.logsPanel.getManualChatMessages().last()).toContainText(
'Set 1 with chatInput: Test Partial Execution',
);
});
});
@@ -0,0 +1,37 @@
import { EDIT_FIELDS_SET_NODE_NAME } from '../../../config/constants';
import { test, expect } from '../../../fixtures/base';
test.describe('CAT-726 Node connectors not rendered when nodes inserted on the canvas', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('should correctly append a No Op node when Loop Over Items node is added (from add button)', async ({
n8n,
}) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.addNodeBetweenNodes(
'When clicking Execute workflow',
'Edit Fields',
'Loop Over Items (Split in Batches)',
);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(4);
await expect(n8n.canvas.nodeConnections()).toHaveCount(4);
await expect
.soft(n8n.canvas.connectionBetweenNodes('Loop Over Items', 'Replace Me'))
.toBeVisible();
await expect
.soft(n8n.canvas.connectionBetweenNodes('Loop Over Items', 'Edit Fields'))
.toBeVisible();
await expect
.soft(n8n.canvas.connectionBetweenNodes('Replace Me', 'Loop Over Items'))
.toBeVisible();
});
});
@@ -0,0 +1,131 @@
import { test, expect } from '../../../fixtures/base';
/**
* PAY-4367: Node shifting in cyclic workflows
*
* Bug: When inserting a node, ALL downstream nodes were shifted right,
* including nodes to the LEFT of the insertion point (reachable via cycle).
*
* Workflow structure:
* Trigger(-220) Start(0) Middle(220) End(440)
* ___________________________| (cycle)
*
* When inserting between Start and Middle:
* - insertX 158 (midpoint between Start's right edge at 96 and Middle at 220)
* - Start is "downstream" via cycle: Middle End Start
* - But Start (x=0, rightEdge=96) is LEFT of insertX
*
* Fix filters downstream nodes by position:
* overlapsOrIsToTheRight = (rightEdge > insertX) || (position >= insertX)
*
* Expected behavior:
* - Start: should NOT shift (rightEdge 96 < insertX ~158)
* - Middle, End: should shift right (position >= insertX)
*/
test.describe('PAY-4367: Node shifting in cyclic workflows', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should not shift nodes to the left of insertion point in cyclic workflow', async ({
n8n,
}) => {
// Workflow: Trigger → Start(x=0) → Middle(x=220) → End(x=440) → Start (cycle)
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow('Cyclic_workflow_for_insertion_test.json', 'Cyclic Test');
// Record positions BEFORE insertion
const posStartBefore = await n8n.canvas.getNodePosition('Start');
const posMiddleBefore = await n8n.canvas.getNodePosition('Middle');
const posEndBefore = await n8n.canvas.getNodePosition('End');
// ACT: Insert node between Start and Middle
await n8n.canvas.addNodeBetweenNodes('Start', 'Middle', 'HTTP Request');
// Record positions AFTER insertion
const posStartAfter = await n8n.canvas.getNodePosition('Start');
const posMiddleAfter = await n8n.canvas.getNodePosition('Middle');
const posEndAfter = await n8n.canvas.getNodePosition('End');
// ASSERT: Start should NOT have shifted (it's to the left of insertion)
// This was the bug - Start would shift because it's "downstream" via the cycle
expect(posStartAfter.x).toBe(posStartBefore.x);
// ASSERT: Middle and End should have shifted right
expect(posMiddleAfter.x).toBeGreaterThan(posMiddleBefore.x);
expect(posEndAfter.x).toBeGreaterThan(posEndBefore.x);
// Verify the new node was added (Trigger + Start + Middle + End + HTTP Request = 5)
await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
});
test('should stretch sticky note when inserting node in front of it', async ({ n8n }) => {
// Workflow with a pink sticky note ("Sticky Note14") between Edit Fields and A node
// The sticky should stretch to encompass the new node when inserted close to it
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow('Bug_node_insertions_sticky.json', 'Sticky Insert Test');
const pinkSticky = n8n.canvas.sticky.getStickies().filter({ hasText: 'Insert here' });
await expect(pinkSticky).toBeVisible();
const stickyBefore = await pinkSticky.boundingBox();
// ACT: Insert node between Edit Fields and A (in front of the pink sticky)
await n8n.canvas.addNodeBetweenNodes('Edit Fields', 'A', 'HTTP Request');
const stickyAfter = await pinkSticky.boundingBox();
// ASSERT: Sticky should have stretched (width increased) to encompass the new node
await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible();
expect(stickyAfter?.width).toBeGreaterThan(stickyBefore?.width ?? 0);
const newNode = await n8n.canvas.nodeByName('HTTP Request').boundingBox();
// The new node should be horizontally between the sticky's left and right edges
// (with some tolerance for padding/stretching)
expect(newNode?.x).toBeGreaterThanOrEqual((stickyAfter?.x ?? 0) - 50);
expect((newNode?.x ?? 0) + (newNode?.width ?? 0)).toBeLessThanOrEqual(
(stickyAfter?.x ?? 0) + (stickyAfter?.width ?? 0) + 50,
);
});
test('should not associate node with stickies when inserting between two separate sticky notes', async ({
n8n,
}) => {
// Workflow with two sticky notes: "Sticky Note20" (pink) and "Note for A5" (yellow)
// Inserting a node between "Get a post3" and "A5" should place it in the gap between stickies
// The node should NOT be associated with either sticky (no stretching)
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow(
'Bug_node_insertions_between_stickies.json',
'Between Stickies Test',
);
const pinkSticky = n8n.canvas.sticky.getStickies().filter({ hasText: 'Insert here' });
const yellowSticky = n8n.canvas.sticky.getStickies().filter({ hasText: 'Note for A' });
await expect(pinkSticky).toBeVisible();
await expect(yellowSticky).toBeVisible();
const pinkStickyBefore = await pinkSticky.boundingBox();
const yellowStickyBefore = await yellowSticky.boundingBox();
// ACT: Insert node between "Get a post3" and "A5" (in the gap between the two stickies)
await n8n.canvas.addNodeBetweenNodes('Get a post3', 'A5', 'HTTP Request');
await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible();
const pinkStickyAfter = await pinkSticky.boundingBox();
const yellowStickyAfter = await yellowSticky.boundingBox();
//Stickies should both maintain their width (not stretch to include the new node)
expect(pinkStickyAfter?.width).toBe(pinkStickyBefore?.width);
expect(yellowStickyAfter?.width).toBe(yellowStickyBefore?.width);
const newNode = await n8n.canvas.nodeByName('HTTP Request').boundingBox();
// The new node should be between the pink and yellow stickies
expect(newNode?.x).toBeGreaterThan((pinkStickyAfter?.x ?? 0) + (pinkStickyAfter?.width ?? 0));
expect((newNode?.x ?? 0) + (newNode?.width ?? 0)).toBeLessThan(yellowStickyAfter?.x ?? 0);
});
});
@@ -0,0 +1,32 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const requirements: TestRequirements = {
workflow: 'Test_workflow_1.json',
storage: {
N8N_EXPERIMENT_OVERRIDES: JSON.stringify({ ndv_in_focus_panel: 'variant' }),
},
};
test.describe('SUG-121 Fields reset after closing NDV', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test('should preserve changes to parameters after closing NDV when focus panel is open', async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await n8n.canvas.clickZoomToFitButton();
await n8n.canvas.toggleFocusPanelButton().click();
await n8n.canvas.canvasPane().click();
await n8n.canvas.nodeByName('Code').dblclick();
await n8n.ndv.getParameterByLabel('JavaScript').getByRole('textbox').fill('alert(1)');
await n8n.ndv.close();
await n8n.canvas.nodeByName('Code').dblclick();
await expect(n8n.ndv.getParameterByLabel('JavaScript').getByRole('textbox')).toHaveText(
'alert(1)',
);
});
});
@@ -0,0 +1,33 @@
import { test, expect } from '../../../fixtures/base';
import type { TestRequirements } from '../../../Types';
const requirements: TestRequirements = {
workflow: {
'Test_9999_SUG_38.json': 'SUG_38_Test_Workflow',
},
};
test.describe('SUG-38 Inline expression previews are not displayed in NDV', {
annotation: [
{ type: 'owner', description: 'Adore' },
],
}, () => {
test("should show resolved inline expression preview in NDV if the node's input data is populated", async ({
n8n,
setupRequirements,
}) => {
await setupRequirements(requirements);
await n8n.canvas.clickZoomToFitButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
'Workflow executed successfully',
);
await n8n.canvas.openNode('Repro1');
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toBeVisible();
await expect(n8n.ndv.getParameterExpressionPreviewValue()).toHaveText('hello there');
});
});
@@ -0,0 +1,78 @@
import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'kent' });
test.beforeEach(async ({ n8nContainer }) => {
await n8nContainer.services.kent.clear();
});
test.describe('Sentry baseline', {
annotation: [
{ type: 'owner', description: 'Catalysts' },
],
}, () => {
test('frontend error is captured', async ({ n8n, n8nContainer }) => {
const kent = n8nContainer.services.kent;
await n8n.navigate.toHome();
n8n.page.on('pageerror', () => {});
await n8n.page.evaluate(() => {
setTimeout(() => {
throw new Error('Test frontend error');
}, 0);
});
await expect
.poll(
async () =>
await kent.getEvents({
source: 'frontend',
type: 'error',
messageContains: 'Test frontend error',
}),
{ timeout: 10000 },
)
.toHaveLength(1);
});
test('backend transaction is captured', async ({ n8n, n8nContainer }) => {
const kent = n8nContainer.services.kent;
await n8n.navigate.toHome();
await expect
.poll(async () => await kent.getEvents({ source: 'backend', type: 'transaction' }), {
timeout: 10000,
})
.not.toHaveLength(0);
});
test('events have deployment identification via server_name tag', async ({
n8n,
n8nContainer,
}) => {
const kent = n8nContainer.services.kent;
await n8n.navigate.toHome();
n8n.page.on('pageerror', () => {});
await n8n.page.evaluate(() => {
setTimeout(() => {
throw new Error('Deployment test error');
}, 0);
});
await expect
.poll(
async () =>
await kent.getEvents({ source: 'frontend', messageContains: 'Deployment test error' }),
{ timeout: 10000 },
)
.toHaveLength(1);
const [frontendError] = await kent.getEvents({
source: 'frontend',
messageContains: 'Deployment test error',
});
expect(kent.getTags(frontendError)?.server_name).toBe('e2e-test-deployment');
});
});
@@ -0,0 +1,74 @@
import { test, expect } from '../../../../fixtures/base';
const MOCK_PACKAGE = {
createdAt: '2024-07-22T19:08:06.505Z',
updatedAt: '2024-07-22T19:08:06.505Z',
packageName: 'n8n-nodes-chatwork',
installedVersion: '1.0.0',
authorName: null,
authorEmail: null,
installedNodes: [
{
name: 'Chatwork',
type: 'n8n-nodes-chatwork.chatwork',
latestVersion: 1,
},
],
updateAvailable: '1.1.2',
};
test.describe('Community nodes management', {
annotation: [
{ type: 'owner', description: 'NODES' },
],
}, () => {
test('can install, update and uninstall community nodes', async ({ n8n }) => {
await n8n.page.route('**/api.npms.io/v2/search*', async (route) => {
await route.fulfill({ status: 200, json: {} });
});
await n8n.page.route('/rest/community-packages', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: { data: [] } });
}
});
await n8n.navigate.toCommunityNodes();
await n8n.page.route('/rest/community-packages', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 200, json: { data: MOCK_PACKAGE } });
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: { data: [MOCK_PACKAGE] } });
}
});
await n8n.communityNodes.installPackage('n8n-nodes-chatwork@1.0.0');
await expect(n8n.communityNodes.getCommunityCards()).toHaveCount(1);
await expect(n8n.communityNodes.getCommunityCards().first()).toContainText('v1.0.0');
const updatedPackage = {
...MOCK_PACKAGE,
installedVersion: '1.2.0',
updateAvailable: undefined,
};
await n8n.page.route('/rest/community-packages', async (route) => {
if (route.request().method() === 'PATCH') {
await route.fulfill({ status: 200, json: { data: updatedPackage } });
}
});
await n8n.communityNodes.updatePackage();
await expect(n8n.communityNodes.getCommunityCards()).toHaveCount(1);
await expect(n8n.communityNodes.getCommunityCards().first()).not.toContainText('v1.0.0');
await n8n.page.route('/rest/community-packages*', async (route) => {
if (route.request().method() === 'DELETE') {
await route.fulfill({ status: 204 });
}
});
await n8n.communityNodes.uninstallPackage();
await expect(n8n.communityNodes.getActionBox()).toBeVisible();
});
});
@@ -0,0 +1,145 @@
import { expect, test } from '../../../../fixtures/base';
import type { n8nPage } from '../../../../pages/n8nPage';
import {
buildRepoUrl,
generateUniqueRepoName,
initSourceControl,
} from '../../../../utils/source-control-helper';
test.use({ capability: 'source-control' });
async function saveSettings(n8n: n8nPage) {
await n8n.settingsEnvironment.getSaveButton().click();
await n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/source-control/preferences') &&
response.request().method() === 'PATCH',
);
}
// Skipped: These tests are flaky. Re-enable when PAY-4365 is resolved.
// https://linear.app/n8n/issue/PAY-4365/bug-source-control-operations-fail-in-multi-main-deployment
test.describe(
'Source Control Settings @capability:source-control',
{
annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }],
},
() => {
test.fixme();
let repoUrl: string;
let repoName: string;
test.beforeEach(async ({ n8n, services }) => {
await n8n.api.enableFeature('sourceControl');
const gitea = services.gitea;
await initSourceControl({ n8n, gitea });
// Create unique repo with branches via API (not UI)
repoName = generateUniqueRepoName();
await gitea.createRepo(repoName);
repoUrl = buildRepoUrl(repoName);
});
test('should connect to Git repository using SSH', async ({ n8n }) => {
// Test UI connection flow with unique repo
await n8n.navigate.toEnvironments();
await n8n.settingsEnvironment.fillRepoUrl(repoUrl);
await expect(n8n.settingsEnvironment.getConnectButton()).toBeEnabled();
await n8n.settingsEnvironment.getConnectButton().click();
await expect(n8n.settingsEnvironment.getDisconnectButton()).toBeVisible();
await expect(n8n.settingsEnvironment.getBranchSelect()).toBeVisible();
await n8n.settingsEnvironment.getBranchSelect().click();
await expect(n8n.page.getByRole('option', { name: 'main' })).toBeVisible();
// Verify source control connected indicator is visible
await n8n.navigate.toHome();
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).toBeVisible();
});
test('should switch between branches', async ({ n8n, services }) => {
const gitea = services.gitea;
await gitea.createBranch(repoName, 'development');
await gitea.createBranch(repoName, 'staging');
await gitea.createBranch(repoName, 'production');
await n8n.api.sourceControl.connect({ repositoryUrl: repoUrl });
await n8n.navigate.toEnvironments();
// Switch to 'development' branch
await n8n.settingsEnvironment.getBranchSelect().click();
await expect(n8n.page.getByRole('option', { name: 'main' })).toBeVisible();
await expect(n8n.page.getByRole('option', { name: 'development' })).toBeVisible();
await expect(n8n.page.getByRole('option', { name: 'staging' })).toBeVisible();
await expect(n8n.page.getByRole('option', { name: 'production' })).toBeVisible();
await n8n.page.getByRole('option', { name: 'development' }).click();
await saveSettings(n8n);
// Verify branch switched by checking preferences
let preferencesResponse = await n8n.page.request.get('/rest/source-control/preferences');
let preferences = await preferencesResponse.json();
expect(preferences.data.branchName).toBe('development');
// Switch back to 'main'
await n8n.settingsEnvironment.selectBranch('main');
await saveSettings(n8n);
// Verify switched back
preferencesResponse = await n8n.page.request.get('/rest/source-control/preferences');
preferences = await preferencesResponse.json();
expect(preferences.data.branchName).toBe('main');
});
test('should enable read-only mode and restrict operations', async ({ n8n }) => {
await n8n.api.sourceControl.connect({ repositoryUrl: repoUrl });
await n8n.navigate.toEnvironments();
await n8n.settingsEnvironment.enableReadOnlyMode();
await saveSettings(n8n);
// Verify push button is disabled in read-only mode
await n8n.navigate.toHome();
await expect(n8n.sideBar.getSourceControlPushButton()).toBeDisabled();
await expect(n8n.sideBar.getSourceControlPullButton()).toBeEnabled();
await n8n.navigate.toEnvironments();
await n8n.settingsEnvironment.disableReadOnlyMode();
await saveSettings(n8n);
// Verify push button is enabled again
await n8n.navigate.toHome();
await expect(n8n.sideBar.getSourceControlPushButton()).toBeEnabled();
await expect(n8n.sideBar.getSourceControlPullButton()).toBeEnabled();
});
test('should disconnect and reconnect with existing keys', async ({ n8n }) => {
await n8n.api.sourceControl.connect({ repositoryUrl: repoUrl });
await n8n.navigate.toEnvironments();
await n8n.settingsEnvironment.disconnect();
// check that source control is disconnected
await n8n.navigate.toHome();
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).toBeHidden();
// Reconnect
await n8n.navigate.toEnvironments();
await n8n.settingsEnvironment.fillRepoUrl(repoUrl);
await expect(n8n.settingsEnvironment.getConnectButton()).toBeEnabled();
await n8n.settingsEnvironment.getConnectButton().click();
await expect(n8n.settingsEnvironment.getDisconnectButton()).toBeVisible();
await expect(n8n.settingsEnvironment.getBranchSelect()).toBeVisible();
// check that source control is connected
await n8n.navigate.toHome();
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).toBeVisible();
});
},
);
@@ -0,0 +1,157 @@
import { customAlphabet } from 'nanoid';
import { test, expect } from '../../../../fixtures/base';
const generateValidId = customAlphabet(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_',
8,
);
test.describe('Variables', {
annotation: [
{ type: 'owner', description: 'Lifecycle & Governance' },
],
}, () => {
// These tests are serial since it's at an instance level and they interact with the same variables
test.describe.configure({ mode: 'serial' });
test.describe('unlicensed', () => {
test('should show the unlicensed action box when the feature is disabled', async ({ n8n }) => {
await n8n.api.disableFeature('variables');
await n8n.navigate.toVariables();
await expect(n8n.variables.getUnavailableResourcesList()).toBeVisible();
await expect(n8n.variables.getResourcesList()).toBeHidden();
});
});
test.describe('licensed', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('variables');
await n8n.api.variables.deleteAllVariables();
await n8n.navigate.toVariables();
});
test('should create a new variable using empty state', async ({ n8n }) => {
const key = `ENV_VAR_${generateValidId()}`;
const value = 'test_value';
await n8n.variables.createVariableFromEmptyState(key, value);
const variableRow = n8n.variables.getVariableRow(key);
await expect(variableRow).toContainText(value);
await expect(variableRow).toBeVisible();
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
});
test('should create multiple variables', async ({ n8n }) => {
const key1 = `ENV_VAR_NEW_${generateValidId()}`;
const value1 = 'test_value_1';
await n8n.variables.createVariableFromEmptyState(key1, value1);
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
const key2 = `ENV_EXAMPLE_${generateValidId()}`;
const value2 = 'test_value_2';
await n8n.variables.createVariable(key2, value2);
await expect(n8n.variables.getVariablesRows()).toHaveCount(2);
const variableRow1 = n8n.variables.getVariableRow(key1);
await expect(variableRow1).toContainText(value1);
await expect(variableRow1).toBeVisible();
const variableRow2 = n8n.variables.getVariableRow(key2);
await expect(variableRow2).toContainText(value2);
await expect(variableRow2).toBeVisible();
});
test('should get validation errors and cancel variable creation', async ({ n8n }) => {
await n8n.variables.createVariableFromEmptyState(
`ENV_BASE_${generateValidId()}`,
'base_value',
);
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
const initialCount = await n8n.variables.getVariablesRows().count();
const key = `ENV_VAR_INVALID_${generateValidId()}$`; // Invalid key with special character
const value = 'test_value';
await n8n.variables.createVariable(key, value, { shouldSave: false });
const saveButton = n8n.variables.variableModal.getSaveButton();
await expect(saveButton).toBeDisabled();
await n8n.variables.variableModal.close();
await expect(n8n.variables.getVariablesRows()).toHaveCount(initialCount);
});
test('should edit a variable', async ({ n8n }) => {
const key = `ENV_VAR_EDIT_${generateValidId()}`;
const initialValue = 'initial_value';
await n8n.variables.createVariableFromEmptyState(key, initialValue);
const newValue = 'updated_value';
await n8n.variables.editVariable(key, newValue, { shouldSave: true });
const variableRow = n8n.variables.getVariableRow(key);
await expect(variableRow).toContainText(newValue);
await expect(variableRow).toBeVisible();
});
test('should delete a variable', async ({ n8n }) => {
const key = `TO_DELETE_${generateValidId()}`;
const value = 'delete_test_value';
await n8n.variables.createVariableFromEmptyState(key, value);
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
const initialCount = await n8n.variables.getVariablesRows().count();
await n8n.variables.deleteVariable(key);
await expect(n8n.variables.getVariablesRows()).toHaveCount(initialCount - 1);
await expect(n8n.variables.getVariableRow(key)).toBeHidden();
});
test('should search for a variable', async ({ n8n }) => {
const uniqueId = generateValidId();
const key1 = `SEARCH_VAR_${uniqueId}`;
const key2 = `SEARCH_VAR_NEW_${uniqueId}`;
const key3 = `SEARCH_EXAMPLE_${uniqueId}`;
await n8n.variables.createVariableFromEmptyState(key1, 'search_value_1');
await n8n.variables.createVariable(key2, 'search_value_2');
await n8n.variables.createVariable(key3, 'search_value_3');
await n8n.variables.getSearchBar().fill('NEW_');
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toHaveCount(1);
await expect(n8n.variables.getVariableRow(key2)).toBeVisible();
await expect(n8n.page).toHaveURL(new RegExp('search=NEW_'));
await n8n.variables.getSearchBar().clear();
await n8n.variables.getSearchBar().fill('SEARCH_VAR_');
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toHaveCount(2);
await expect(n8n.variables.getVariableRow(key1)).toBeVisible();
await expect(n8n.variables.getVariableRow(key2)).toBeVisible();
await expect(n8n.page).toHaveURL(new RegExp('search=SEARCH_VAR_'));
await n8n.variables.getSearchBar().clear();
await n8n.variables.getSearchBar().fill('SEARCH_');
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toHaveCount(3);
await expect(n8n.variables.getVariableRow(key1)).toBeVisible();
await expect(n8n.variables.getVariableRow(key2)).toBeVisible();
await expect(n8n.variables.getVariableRow(key3)).toBeVisible();
await expect(n8n.page).toHaveURL(new RegExp('search=SEARCH_'));
await n8n.variables.getSearchBar().clear();
await n8n.variables.getSearchBar().fill(`NonExistent_${generateValidId()}`);
await n8n.variables.getSearchBar().press('Enter');
await expect(n8n.variables.getVariablesRows()).toBeHidden();
await expect(n8n.page).toHaveURL(/search=NonExistent_/);
await expect(n8n.variables.getNoVariablesFoundMessage()).toBeVisible();
});
});
});
@@ -0,0 +1,45 @@
import { expect, test } from '../../../../fixtures/base';
test.use({ capability: 'external-secrets' });
test.setTimeout(180_000);
test.describe(
'AWS Secrets Manager with LocalStack @capability:external-secrets @licensed',
{
annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }],
},
() => {
const PROVIDER_NAME = 'awsSecretsManager';
const PROVIDER_SETTINGS = {
region: 'us-east-1',
authMethod: 'iamUser',
accessKeyId: 'test',
secretAccessKey: 'test',
};
test.beforeEach(async ({ n8n, services }) => {
await services.localstack.secretsManager.clear();
await n8n.api.enableFeature('externalSecrets');
});
test('can configure, connect, and sync secrets from LocalStack', async ({ n8n, services }) => {
const { secretsManager } = services.localstack;
await secretsManager.createSecret('api-key', 'secret-123');
await n8n.api.externalSecrets.saveProviderSettings(PROVIDER_NAME, PROVIDER_SETTINGS);
await n8n.api.externalSecrets.testProvider(PROVIDER_NAME, PROVIDER_SETTINGS);
await n8n.api.externalSecrets.connectProvider(PROVIDER_NAME);
await n8n.api.externalSecrets.updateProvider(PROVIDER_NAME);
expect(await n8n.api.externalSecrets.getSecrets(PROVIDER_NAME)).toContain('api-key');
await secretsManager.createSecret('new-secret', 'value-2');
await n8n.api.externalSecrets.updateProvider(PROVIDER_NAME);
const secrets = await n8n.api.externalSecrets.getSecrets(PROVIDER_NAME);
expect(secrets).toContain('api-key');
expect(secrets).toContain('new-secret');
});
},
);
@@ -0,0 +1,71 @@
import { expect, test } from '../../../../fixtures/base';
test.use({ capability: 'external-secrets' });
// LocalStack can take time to start up
test.setTimeout(180_000);
test.describe(
'Secret Providers Connections with LocalStack @capability:external-secrets @licensed',
{
annotation: [{ type: 'owner', description: 'Lifecycle & Governance' }],
},
() => {
const PROVIDER_KEY = 'aws-localstack-e2e';
const PROVIDER_TYPE = 'awsSecretsManager';
test.beforeEach(async ({ n8n, services }) => {
// N8N_ENV_FEAT_EXTERNAL_SECRETS_FOR_PROJECTS is set at container startup
// via the external-secrets capability config
// Enable the external secrets license feature
await n8n.api.enableFeature('externalSecrets');
// Clear any existing secrets from previous tests
await services.localstack.secretsManager.clear();
});
test.afterEach(async ({ n8n }) => {
// Clean up: delete the test connection if it exists
try {
await n8n.api.externalSecrets.deleteConnection(PROVIDER_KEY);
} catch {
// Ignore errors if connection doesn't exist
}
});
test('can create a connection pointing to LocalStack', async ({ n8n, services }) => {
// Arrange: Seed secrets in LocalStack
await services.localstack.secretsManager.createSecret('e2e-api-key', 'secret-123');
await services.localstack.secretsManager.createSecret(
'e2e-db-credentials',
JSON.stringify({ username: 'admin', password: 'hunter2' }),
);
// Verify secrets exist in LocalStack
const secrets = await services.localstack.secretsManager.listSecrets();
expect(secrets).toContain('e2e-api-key');
expect(secrets).toContain('e2e-db-credentials');
// Act: Create a connection with settings that would work with LocalStack
// (n8n container has AWS_ENDPOINT_URL set to point to LocalStack)
const created = await n8n.api.externalSecrets.createConnection({
providerKey: PROVIDER_KEY,
type: PROVIDER_TYPE,
projectIds: [],
settings: {
region: 'us-east-1',
authMethod: 'iamUser',
accessKeyId: 'test',
secretAccessKey: 'test',
},
});
// Assert: Connection created successfully
expect(created.name).toBe(PROVIDER_KEY);
expect(created.type).toBe(PROVIDER_TYPE);
// TODO - this test should verify that the secrets are loaded - but that functionality is not there yet
});
},
);
@@ -0,0 +1,89 @@
/**
* E2E tests for log streaming to VictoriaLogs via syslog.
*
* These tests verify that n8n log streaming events are correctly
* sent to VictoriaLogs and can be queried using LogsQL.
*
* Prerequisites:
* - Log streaming feature enabled (enterprise license)
* - @capability:observability tag to bring up VictoriaLogs
*/
import { test, expect } from '../../../../fixtures/base';
// Worker-scoped fixtures must be at top level
test.use({ capability: 'observability' });
test.describe('Log Streaming to VictoriaLogs @capability:observability', {
annotation: [
{ type: 'owner', description: 'Lifecycle & Governance' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
// Enable log streaming feature for the test
await n8n.api.enableFeature('logStreaming');
});
test('should configure syslog destination and send test message', async ({ api, services }) => {
const obs = services.observability;
// Configure syslog destination pointing to VictoriaLogs
// syslog contains: host, port, protocol, facility, appName
const destination = await api.createSyslogDestination({
host: obs.syslog.host,
port: obs.syslog.port,
protocol: obs.syslog.protocol,
facility: obs.syslog.facility,
app_name: obs.syslog.appName,
label: 'VictoriaLogs Test Destination',
});
expect(destination.id).toBeDefined();
console.log(`Created syslog destination with ID: ${destination.id}`);
// Send test message to the destination
const testResult = await api.testLogStreamingDestination(destination.id);
expect(testResult).toBe(true);
// Wait for the test message to appear in VictoriaLogs
// Use wildcard - LogsQL interprets dots as word separators
const logEntry = await obs.logs.waitForLog('*destination.test*', {
timeoutMs: 30000,
start: '-1m',
});
expect(logEntry).toBeTruthy();
// Clean up - delete the destination
await api.deleteLogStreamingDestination(destination.id);
});
test('should query metrics from VictoriaMetrics', async ({ api, services }) => {
const obs = services.observability;
// Import and activate a webhook workflow to generate metrics
const { webhookPath, workflowId } = await api.workflows.importWorkflowFromFile(
'simple-webhook-test.json',
);
// Trigger the workflow via webhook to generate metrics
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
data: { test: 'metrics' },
});
expect(webhookResponse.ok()).toBe(true);
// Wait for workflow execution to complete
const execution = await api.workflows.waitForExecution(workflowId, 10000);
expect(execution.status).toBe('success');
// Wait for metrics to be scraped (VictoriaMetrics scrapes every 5s)
// Query for n8n version info metric (always present)
const versionMetric = await obs.metrics.waitForMetric('n8n_version_info', {
timeoutMs: 30000,
});
expect(versionMetric).toBeTruthy();
console.log('n8n version metric:', versionMetric?.labels);
});
});
@@ -0,0 +1,51 @@
/**
* End-to-end UI test for log streaming feature.
*
* This test verifies:
* 1. Log streaming can be configured via the UI
* 2. Test events are streamed to VictoriaLogs via syslog
* 3. Events can be queried from VictoriaLogs
*/
import { test, expect } from '../../../../fixtures/base';
test.use({ capability: 'observability' });
test.describe('Log Streaming UI E2E @capability:observability', {
annotation: [
{ type: 'owner', description: 'Lifecycle & Governance' },
],
}, () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('logStreaming');
});
test('should configure syslog destination via UI and send test event', async ({
n8n,
services,
}) => {
const obs = services.observability;
// ========== STEP 1: Configure Log Streaming via UI ==========
await n8n.navigate.toLogStreaming();
await expect(n8n.settingsLogStreaming.getActionBoxLicensed()).toBeVisible();
// Create syslog destination pointing to VictoriaLogs
await n8n.settingsLogStreaming.createSyslogDestination({
name: 'VictoriaLogs E2E Test',
host: obs.syslog.host,
port: obs.syslog.port,
});
// Send test event
await n8n.settingsLogStreaming.sendTestEvent();
// ========== STEP 3: Verify Event in VictoriaLogs ==========
// Use wildcard search - LogsQL interprets dots as word separators
const testEvent = await obs.logs.waitForLog('*destination.test*', {
timeoutMs: 30000,
start: '-2m',
});
expect(testEvent).toBeTruthy();
});
});
@@ -0,0 +1,91 @@
import { test, expect } from '../../../../fixtures/base';
const DESTINATION_NAMES = {
FIRST: 'Destination 0',
SECOND: 'Destination 1',
} as const;
const MODAL_MAX_WIDTH = 500;
test.describe('Log Streaming Settings', {
annotation: [
{ type: 'owner', description: 'Lifecycle & Governance' },
],
}, () => {
test.describe.configure({ mode: 'serial' });
test.describe('unlicensed', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.disableFeature('logStreaming');
});
test('should show the unlicensed view when the feature is disabled', async ({ n8n }) => {
await n8n.navigate.toLogStreaming();
await expect(n8n.settingsLogStreaming.getActionBoxUnlicensed()).toBeVisible();
await expect(n8n.settingsLogStreaming.getContactUsButton()).toBeVisible();
await expect(n8n.settingsLogStreaming.getActionBoxLicensed()).not.toBeAttached();
});
});
// @licensed - requires enterprise license (module routes only exist with license at startup)
test.describe('licensed @licensed', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('logStreaming');
await n8n.api.deleteAllLogStreamingDestinations();
await n8n.navigate.toLogStreaming();
});
test('should show the licensed view when the feature is enabled', async ({ n8n }) => {
await expect(n8n.settingsLogStreaming.getActionBoxLicensed()).toBeVisible();
await expect(n8n.settingsLogStreaming.getAddFirstDestinationButton()).toBeVisible();
await expect(n8n.settingsLogStreaming.getActionBoxUnlicensed()).not.toBeAttached();
});
test('should show the add destination modal', async ({ n8n }) => {
await n8n.settingsLogStreaming.addDestination();
await expect(n8n.settingsLogStreaming.getDestinationModal()).toBeVisible();
await expect(n8n.settingsLogStreaming.getSelectDestinationType()).toBeVisible();
await expect(n8n.settingsLogStreaming.getSelectDestinationButton()).toBeVisible();
await expect(n8n.settingsLogStreaming.getSelectDestinationButton()).toBeDisabled();
const modal = n8n.settingsLogStreaming.getDestinationModal();
const width = await modal.evaluate((element) => {
return parseInt(window.getComputedStyle(element).width.replace('px', ''));
});
expect(width).toBeLessThan(MODAL_MAX_WIDTH);
await n8n.settingsLogStreaming.clickSelectDestinationType();
await n8n.settingsLogStreaming.selectDestinationType(0);
await expect(n8n.settingsLogStreaming.getSelectDestinationButton()).toBeEnabled();
await n8n.settingsLogStreaming.closeModalByClickingOverlay();
await expect(n8n.settingsLogStreaming.getDestinationModal()).not.toBeAttached();
});
test('should create a destination and delete it', async ({ n8n }) => {
await n8n.settingsLogStreaming.createDestination(DESTINATION_NAMES.FIRST);
await n8n.page.reload();
await n8n.settingsLogStreaming.clickDestinationCard(0);
await expect(n8n.settingsLogStreaming.getDestinationDeleteButton()).toBeVisible();
await n8n.settingsLogStreaming.deleteDestination();
await expect(n8n.settingsLogStreaming.getConfirmationDialog()).toBeVisible();
await n8n.settingsLogStreaming.cancelDialog();
await n8n.settingsLogStreaming.deleteDestination();
await expect(n8n.settingsLogStreaming.getConfirmationDialog()).toBeVisible();
await n8n.settingsLogStreaming.confirmDialog();
});
test('should create a destination and delete it via card actions', async ({ n8n }) => {
await n8n.settingsLogStreaming.createDestination(DESTINATION_NAMES.SECOND);
await n8n.page.reload();
await n8n.settingsLogStreaming.clickDestinationCardDropdown(0);
await n8n.settingsLogStreaming.clickDropdownMenuItem(0);
await expect(n8n.settingsLogStreaming.getDestinationSaveButton()).not.toBeAttached();
await n8n.settingsLogStreaming.closeModalByClickingOverlay();
await n8n.settingsLogStreaming.clickDestinationCardDropdown(0);
await n8n.settingsLogStreaming.clickDropdownMenuItem(1);
await expect(n8n.settingsLogStreaming.getConfirmationDialog()).toBeVisible();
await n8n.settingsLogStreaming.confirmDialog();
});
});
});
@@ -0,0 +1,61 @@
import { test, expect } from '../../../../fixtures/base';
const INVALID_NAMES = [
'https://n8n.io',
'http://n8n.io',
'www.n8n.io',
'n8n.io',
'n8n.бг',
'n8n.io/home',
'n8n.io/home?send=true',
'<a href="#">Jack</a>',
'<script>alert("Hello")</script>',
];
const VALID_NAMES = [
['a', 'a'],
['alice', 'alice'],
['Robert', 'Downey Jr.'],
['Mia', 'Mia-Downey'],
['Mark', "O'neil"],
['Thomas', 'Müler'],
['ßáçøñ', 'ßáçøñ'],
['أحمد', 'فلسطين'],
['Милорад', 'Филиповић'],
];
test.describe(
'Personal Settings',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test('should allow to change first and last name', async ({ n8n }) => {
await n8n.settingsPersonal.goto();
for (const name of VALID_NAMES) {
await n8n.settingsPersonal.fillPersonalData(name[0], name[1]);
await n8n.settingsPersonal.saveSettings();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Personal details updated'),
).toBeVisible();
await n8n.notifications.closeNotificationByText('Personal details updated');
}
});
test('should not allow malicious values for personal data', async ({ n8n }) => {
await n8n.settingsPersonal.goto();
for (const name of INVALID_NAMES) {
await n8n.settingsPersonal.fillPersonalData(name, name);
await n8n.settingsPersonal.saveSettings();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Problem updating your details'),
).toBeVisible();
await n8n.notifications.closeNotificationByText('Problem updating your details');
}
});
},
);
@@ -0,0 +1,112 @@
import { authenticator } from 'otplib';
import { INSTANCE_OWNER_CREDENTIALS } from '../../../../config/test-users';
import { test, expect } from '../../../../fixtures/base';
test.use({ capability: { env: { TEST_ISOLATION: 'two-factor-auth' } } });
const TEST_DATA = {
NEW_EMAIL: 'newemail@test.com',
NEW_FIRST_NAME: 'newFirstName',
NEW_LAST_NAME: 'newLastName',
};
const NOTIFICATIONS = {
PERSONAL_DETAILS_UPDATED: 'Personal details updated',
};
const { email, password, mfaSecret, mfaRecoveryCodes } = INSTANCE_OWNER_CREDENTIALS;
const RECOVERY_CODE = mfaRecoveryCodes![0];
test.describe(
'Two-factor authentication @auth:none @db:reset',
{
annotation: [{ type: 'owner', description: 'Identity & Access' }],
},
() => {
test.describe.configure({ mode: 'serial' });
test('Should be able to login with MFA code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
await expect(n8n.page).toHaveURL(/workflows/);
});
test('Should be able to login with MFA recovery code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaRecoveryCode(email, password, RECOVERY_CODE);
await expect(n8n.page).toHaveURL(/workflows/);
});
test('Should be able to disable MFA in account with MFA code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
const disableToken = authenticator.generate(mfaSecret!);
await n8n.settingsPersonal.triggerDisableMfa();
await n8n.settingsPersonal.fillMfaCodeAndSave(disableToken);
await expect(n8n.settingsPersonal.getEnableMfaButton()).toBeVisible();
});
test('Should prompt for MFA code when email changes', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settingsPersonal.goto();
await n8n.settingsPersonal.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settingsPersonal.pressEnterOnEmail();
const mfaCode = authenticator.generate(mfaSecret!);
await n8n.settingsPersonal.fillMfaCodeAndSave(mfaCode);
await expect(
n8n.notifications.getNotificationByTitleOrContent(NOTIFICATIONS.PERSONAL_DETAILS_UPDATED),
).toBeVisible();
});
test('Should prompt for MFA recovery code when email changes', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settingsPersonal.goto();
await n8n.settingsPersonal.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settingsPersonal.pressEnterOnEmail();
await expect(n8n.settingsPersonal.getMfaCodeOrRecoveryCodeInput()).toBeVisible();
});
test('Should not prompt for MFA code or recovery code when first name or last name changes', async ({
n8n,
}) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settingsPersonal.updateFirstAndLastName(
TEST_DATA.NEW_FIRST_NAME,
TEST_DATA.NEW_LAST_NAME,
);
await expect(
n8n.notifications.getNotificationByTitleOrContent(NOTIFICATIONS.PERSONAL_DETAILS_UPDATED),
).toBeVisible();
});
test('Should be able to disable MFA in account with recovery code', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.sideBar.signOutFromWorkflows();
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
await n8n.settingsPersonal.triggerDisableMfa();
await n8n.settingsPersonal.fillMfaCodeAndSave(RECOVERY_CODE);
await expect(n8n.settingsPersonal.getEnableMfaButton()).toBeVisible();
});
},
);
@@ -0,0 +1,57 @@
import { INSTANCE_OWNER_CREDENTIALS } from '../../../../config/test-users';
import { test, expect } from '../../../../fixtures/base';
test.describe('Users Settings', {
annotation: [
{ type: 'owner', description: 'Identity & Access' },
],
}, () => {
test('should prevent non-owners to access UM settings', async ({ n8n }) => {
// This creates a new user in the same context, so the cookies are refreshed and owner is no longer logged in
await n8n.api.users.create();
await n8n.navigate.toUsers();
await expect.poll(() => n8n.page.url()).not.toContain('/settings/users');
});
test('should allow instance owner to access UM settings', async ({ n8n }) => {
await n8n.navigate.toUsers();
expect(n8n.page.url()).toContain('/settings/users');
});
test('should be able to change user role to Admin and back', async ({ n8n, api }) => {
const user = await api.users.create();
await n8n.navigate.toUsers();
await n8n.settingsUsers.search(user.email);
await n8n.settingsUsers.selectAccountType(user.email, 'Admin');
await expect(n8n.settingsUsers.getAccountType(user.email)).toHaveText('Admin');
await n8n.settingsUsers.selectAccountType(user.email, 'Member');
await expect(n8n.settingsUsers.getAccountType(user.email)).toHaveText('Member');
});
test('should delete user and their data', async ({ n8n, api }) => {
const user = await api.users.create();
await n8n.navigate.toUsers();
await n8n.page.reload();
await n8n.settingsUsers.search(user.email);
await expect(n8n.settingsUsers.getRow(user.email)).toBeVisible();
await n8n.settingsUsers.clickDeleteUser(user.email);
await n8n.settingsUsers.deleteData();
await expect(n8n.notifications.getNotificationByTitleOrContent('User deleted')).toBeVisible();
});
test('should delete user and transfer their data', async ({ n8n, api }) => {
const ownerEmail = INSTANCE_OWNER_CREDENTIALS.email;
const user = await api.users.create();
await n8n.navigate.toUsers();
await n8n.page.reload();
await n8n.settingsUsers.search(user.email);
await n8n.settingsUsers.getRow(user.email).isVisible();
await n8n.settingsUsers.clickDeleteUser(user.email);
await n8n.settingsUsers.transferData(ownerEmail);
await expect(n8n.notifications.getNotificationByTitleOrContent('User deleted')).toBeVisible();
});
});
@@ -0,0 +1,46 @@
import { test, expect } from '../../../../fixtures/base';
test.describe
.serial('Worker View', () => {
test.describe(
'unlicensed',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.disableFeature('workerView');
await n8n.api.disableFeature('workerView');
await n8n.api.setQueueMode(false);
});
test('should not show up in the menu sidebar', async ({ n8n }) => {
await n8n.workerView.goto();
await expect(n8n.workerView.getWorkerMenuItem()).toBeHidden();
});
test('should show action box', async ({ n8n }) => {
await n8n.workerView.goto();
await expect(n8n.workerView.getWorkerViewUnlicensed()).toBeVisible();
});
},
);
test.describe('licensed', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('workerView');
await n8n.api.setQueueMode(true);
});
test('should show up in the menu sidebar', async ({ n8n }) => {
await n8n.goHome();
await n8n.workerView.goto();
await expect(n8n.workerView.getWorkerMenuItem()).toBeVisible();
});
test('should show worker list view', async ({ n8n }) => {
await n8n.workerView.goto();
await expect(n8n.workerView.getWorkerViewLicensed()).toBeVisible();
});
});
});

Some files were not shown because too many files have changed in this diff Show More