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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,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();
}