first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
+92
@@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
+208
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+28
@@ -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();
|
||||
});
|
||||
});
|
||||
+38
@@ -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();
|
||||
});
|
||||
});
|
||||
+71
@@ -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();
|
||||
});
|
||||
});
|
||||
+24
@@ -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);
|
||||
});
|
||||
});
|
||||
+96
@@ -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');
|
||||
});
|
||||
});
|
||||
+33
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
+63
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+37
@@ -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();
|
||||
});
|
||||
});
|
||||
+131
@@ -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);
|
||||
});
|
||||
});
|
||||
+32
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
+33
@@ -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');
|
||||
});
|
||||
});
|
||||
+74
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
@@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
+71
@@ -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
|
||||
});
|
||||
},
|
||||
);
|
||||
+89
@@ -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);
|
||||
});
|
||||
});
|
||||
+51
@@ -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');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
+112
@@ -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
Reference in New Issue
Block a user