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,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();
|
||||
}
|
||||
Reference in New Issue
Block a user