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,105 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
test.use(chatHubTestConfig);
test.describe('File attachment @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
let tmpDir: string;
let testImagePath: string;
let testTextPath: string;
test.beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-hub-test-'));
testImagePath = path.join(tmpDir, 'test-image.png');
testTextPath = path.join(tmpDir, 'test-file.txt');
// 100x100 solid red square PNG
const pngBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAkElEQVR42u3QMQ0AAAjAsPk3DRb4eJpUQZviSIEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFmyZMlCgSxZsmTJQoEsWbJkyUKBLFnvFp4t6yugc3LNAAAAAElFTkSuQmCC',
'base64',
);
await fs.writeFile(testImagePath, pngBuffer);
// Simple text file
await fs.writeFile(testTextPath, 'I am a file');
});
test.afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test('image attachment', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // auto-select a model
await page.getFileInput().setInputFiles(testImagePath);
await page.getChatInput().fill('What color is this image? Reply with just the color name.');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What color is this image?');
await expect(page.getAttachmentsAt(0)).toHaveCount(1);
await expect(page.getAttachmentsAt(0).nth(0)).toBeVisible();
await expect(page.getChatMessages().nth(1)).toContainText(/red/i);
// Verify image is persisted and can be opened in new tab
await n8n.page.reload();
const newPage = await page.openAttachmentAt(0, 0);
await expect(newPage.locator('img')).toBeVisible();
await expect(newPage.locator('img')).toHaveJSProperty('naturalWidth', 100);
await expect(newPage.locator('img')).toHaveJSProperty('naturalHeight', 100);
await newPage.close();
});
test('text file attachment', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i);
await page.getFileInput().setInputFiles(testTextPath);
await page.getChatInput().fill('What is the exact content of this file?');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What is the exact content');
await expect(page.getAttachmentsAt(0)).toHaveCount(1);
await expect(page.getChatMessages().nth(1)).toContainText('I am a file');
});
test('reference attachment in subsequent message', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i);
// Send initial message with attachment
await page.getFileInput().setInputFiles(testImagePath);
await page.getChatInput().fill('What color is this image? Reply with just the color name.');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What color is this image?');
await expect(page.getChatMessages().nth(1)).toContainText(/red/i);
// Send follow-up question referencing the image
await page.getChatInput().fill('Does the image include text? Reply just yes or no.');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(2)).toContainText('Does the image include text?');
await expect(page.getChatMessages().nth(3)).toContainText(/no/i);
});
});
@@ -0,0 +1,107 @@
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { CredentialModal } from '../../../pages/components/CredentialModal';
test.use(chatHubTestConfig);
test.describe('Basic conversation @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('new chat with pre-configured credentials', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getGreetingMessage()).toContainText('Start a chat with');
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // pre-selected
await page.getChatInput().fill('Hello');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('Hello');
await expect(page.getChatMessages().nth(1)).toContainText('Hello! How can I help you today?');
await expect(page.sidebar.getConversations().first()).toHaveAccessibleName(/greeting/i); // verify auto-generated title
});
// Test with a different user to avoid race condition on credentials
test('new chat without pre-configured credentials @auth:member', async ({
n8n,
anthropicApiKey,
}) => {
const page = new ChatHubChatPage(n8n.page);
const credModal = new CredentialModal(n8n.page.getByTestId('editCredential-modal'));
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getGreetingMessage()).toContainText('Select a model to start chatting');
await page.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
await page.getVisiblePopoverMenuItem('Configure credentials', { exact: true }).click();
await credModal.fillField('apiKey', anthropicApiKey);
await credModal.save();
await credModal.close();
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // auto-select a model
await page.getChatInput().fill('Hello from e2e');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('Hello from e2e');
await expect(page.getChatMessages().nth(1)).toContainText('Hello!');
await expect(page.sidebar.getConversations().first()).toHaveAccessibleName(/greeting/i); // verify auto-generated title
});
test('conversation flow', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // auto-select a model
// STEP: send first prompt
await page.getChatInput().fill('Hi');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toHaveText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages()).toHaveCount(2);
// STEP: send 2nd prompt
await page.getChatInput().fill('How are you?');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages().nth(2)).toContainText('How are you?');
await expect(page.getChatMessages().nth(3)).toContainText("I'm doing well");
await expect(page.getChatMessages()).toHaveCount(4);
// STEP: regenerate response to first prompt
await page.clickRegenerateButtonAt(1);
await expect(page.getChatMessages().nth(1)).toContainText('Hello!');
await expect(page.getChatMessages()).toHaveCount(2);
// STEP: switch to previous alternative
await page.clickPrevAlternativeButtonAt(1);
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages()).toHaveCount(4);
// STEP: edit 2nd prompt
await page.clickEditButtonAt(2);
await page.getEditorAt(2).fill('Hola');
await page.getSendButtonAt(2).click();
await expect(page.getChatMessages().nth(3)).toContainText('¡Hola!');
await expect(page.getChatMessages()).toHaveCount(4);
// STEP: reload page and verify persistence
await n8n.page.reload();
await expect(page.getChatMessages()).toHaveCount(4);
await expect(page.getChatMessages().nth(0)).toContainText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages().nth(2)).toContainText('Hola');
await expect(page.getChatMessages().nth(3)).toContainText('¡Hola!');
});
});
@@ -0,0 +1,56 @@
import { chatHubTestConfig, expect, test } from './fixtures';
import { INSTANCE_OWNER_CREDENTIALS } from '../../../config/test-users';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
test.use(chatHubTestConfig);
test.describe('Chat user role @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('use chat as chat user @auth:chat', async ({ n8n, anthropicApiKey }) => {
const ownerN8n = await n8n.start.withUser(INSTANCE_OWNER_CREDENTIALS);
// Create global credential as owner
const cred = await ownerN8n.api.credentials.createCredential({
name: 'Global Anthropic API key',
type: 'anthropicApi',
isGlobal: true,
data: {
apiKey: anthropicApiKey,
},
});
await n8n.goHome();
await n8n.page.waitForURL('/home/chat'); // home is chat UI for chat users
const page = new ChatHubChatPage(n8n.page);
// Verify global credential is available and pre-selected
await expect(page.getModelSelectorButton()).toContainText(/claude/i); // pre-selected
await expect(page.getSelectedCredentialName()).toHaveText('Global Anthropic API key');
await page.getModelSelectorButton().click();
await page.getVisiblePopoverMenuItem('Anthropic').click();
await page.getVisiblePopoverMenuItem('Configure credentials', { exact: true }).click();
// Verify other credentials are not available and cannot be created
await page.credModal.getCredentialSelector().click();
await expect(page.credModal.getCreateButton()).toBeDisabled();
await expect(page.credModal.getVisiblePopoverOption()).toHaveCount(1);
await expect(page.credModal.getVisiblePopoverOption().nth(0)).toContainText(
'Global Anthropic API key',
);
await page.credModal.getCloseButton().click();
// Send chat message
await page.getChatInput().fill('Hi');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toHaveText('Hi');
await expect(page.getChatMessages().nth(1)).toContainText('Hi there!');
await expect(page.getChatMessages()).toHaveCount(2);
await ownerN8n.api.credentials.deleteCredential(cred.id);
});
});
@@ -0,0 +1,95 @@
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { ChatHubPersonalAgentsPage } from '../../../pages/ChatHubPersonalAgentsPage';
test.use(chatHubTestConfig);
test.describe('Personal agent @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('create personal agent and start conversation @auth:owner', async ({
n8n,
anthropicCredential: _,
}) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await page.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.getVisiblePopoverMenuItem('Personal agents').hover({ force: true });
await page.getVisiblePopoverMenuItem('New Agent', { exact: true }).click();
await page.personalAgentModal.getNameField().fill('e2e agent');
await page.personalAgentModal.getDescriptionField().fill('just for testing');
await page.personalAgentModal.getSystemPromptField().fill('reply in Chinese');
await page.personalAgentModal.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
await page.getVisiblePopoverMenuItem('Claude Opus 4.5', { exact: true }).click();
await page.personalAgentModal.getSaveButton().click();
await expect(page.personalAgentModal.getRoot()).not.toBeInViewport(); // wait for modal to close
await expect(page.getModelSelectorButton()).toContainText('e2e agent');
await page.getChatInput().fill('Hello');
await page.getSendButton().click();
await expect(page.getChatMessages().last()).toContainText('你好');
});
test('manage personal agents @auth:admin', async ({ n8n, anthropicCredential: _ }) => {
const page = new ChatHubPersonalAgentsPage(n8n.page);
const chatPage = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHubPersonalAgents();
await page.getNewAgentButton().click();
// STEP: create an agent
await page.editModal.getNameField().fill('e2e agent');
await page.editModal.getDescriptionField().fill('just for testing');
await page.editModal.getSystemPromptField().fill('reply in Chinese');
await page.editModal.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500); // to reliably hover intended menu item
await page.editModal.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
await page.editModal.getVisiblePopoverMenuItem('Claude Opus 4.5', { exact: true }).click();
await page.editModal.getSaveButton().click();
await expect(page.editModal.getRoot()).not.toBeInViewport(); // wait for modal to close
await expect(page.getAgentCards()).toHaveCount(1);
await expect(page.getAgentCards().nth(0)).toContainText('e2e agent');
// STEP: send message to the created agent
await page.getAgentCards().nth(0).click();
await chatPage.dismissWelcomeScreen();
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText('你好');
// STEP: update instructions for the agent
await chatPage.sidebar.getPersonalAgentButton().click();
await page.getEditButtonAt(0).click();
await page.editModal.getSystemPromptField().fill('reply in Japanese');
await page.editModal.getSaveButton().click();
await expect(page.editModal.getRoot()).not.toBeInViewport(); // wait for modal to close
// STEP: send message to the updated agent
await page.getAgentCards().nth(0).click();
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText('こんにちは');
// STEP: delete the agent
await chatPage.sidebar.getPersonalAgentButton().click();
await page.getMenuAt(0).click();
await page.getVisiblePopoverMenuItem('Delete').click();
await n8n.page.getByRole('dialog').getByText('Delete', { exact: true }).click(); // confirmation dialog
await expect(page.getAgentCards()).toHaveCount(0);
});
});
@@ -0,0 +1,121 @@
import { chatHubTestConfig, expect, test } from './fixtures';
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../config/test-users';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { ChatHubSettingsPage } from '../../../pages/ChatHubSettingsPage';
test.use(chatHubTestConfig);
test.describe('Settings @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('set global credentials for a provider', async ({ n8n, anthropicCredential }) => {
const page = new ChatHubSettingsPage(n8n.page);
await n8n.navigate.toChatHubSettings();
// Open Anthropic settings
await page.getProviderActionToggle('Anthropic').click();
await page.getVisiblePopoverMenuItem('Edit provider').click();
// Configure default credential
await page.providerModal.getCredentialPicker().click();
await page.providerModal.getVisiblePopoverOption(anthropicCredential.name).click();
// Open credential modal and make it globally shared
await page.providerModal.getEditCredentialButton().click();
await page.credentialModal.changeTab('Sharing');
await page.credentialModal.getUsersSelect().click();
await page.credentialModal.getVisiblePopoverOption('All users and projects').click();
await page.credentialModal.save();
await page.credentialModal.close();
// Save settings
await page.providerModal.getConfirmButton().click();
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(1);
const memberN8n = await n8n.start.withUser(INSTANCE_MEMBER_CREDENTIALS[0]);
const chatPage = new ChatHubChatPage(memberN8n.page);
await memberN8n.navigate.toChatHub();
await chatPage.dismissWelcomeScreen();
await expect(chatPage.getSelectedCredentialName()).toHaveText(anthropicCredential.name);
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().nth(0)).toContainText('Hello');
await expect(chatPage.getChatMessages().nth(1)).toContainText(
'Hello! How can I help you today?',
);
await memberN8n.page.close();
});
test('restrict available LLM providers and models', async ({
n8n,
anthropicCredential,
anthropicApiKey,
}) => {
const page = new ChatHubSettingsPage(n8n.page);
await n8n.navigate.toChatHubSettings();
// Open Anthropic settings
await page.getProviderActionToggle('Anthropic').click();
await page.getVisiblePopoverMenuItem('Edit provider').click();
// Anthropic: configure default credential
await page.providerModal.getCredentialPicker().click();
await page.providerModal.getVisiblePopoverOption(anthropicCredential.name).click();
// Anthropic: enable limit models toggle
await page.providerModal.getLimitModelsToggle().click();
// Anthropic: select only Claude Opus 4.5
await page.providerModal.getModelSelector().click();
await page.providerModal.getVisiblePopoverOption('Claude Opus 4.5').click();
// Anthropic: save settings
await page.providerModal.getConfirmButton().click();
await expect(page.providerModal.getRoot()).toBeHidden();
// Open OpenAI settings
await page.getProviderActionToggle('OpenAI').click();
await page.getVisiblePopoverMenuItem('Edit provider').click();
// OpenAI: disable provider and save
await expect(page.providerModal.getEnabledToggle()).toBeChecked();
await page.providerModal.getEnabledToggle().click();
await page.providerModal.getConfirmButton().click();
await expect(page.providerModal.getRoot()).toBeHidden();
await n8n.page.close();
// Log in as member and verify only selected model is available
const memberN8n = await n8n.start.withUser(INSTANCE_MEMBER_CREDENTIALS[0]);
const chatPage = new ChatHubChatPage(memberN8n.page);
const cred = await memberN8n.api.credentials.createCredential({
name: 'Member API key',
type: 'anthropicApi',
data: {
apiKey: anthropicApiKey,
},
});
await memberN8n.navigate.toChatHub();
await chatPage.dismissWelcomeScreen();
await chatPage.getModelSelectorButton().click();
await expect(chatPage.getVisiblePopoverMenuItem('Anthropic')).toBeVisible();
await expect(chatPage.getVisiblePopoverMenuItem('OpenAI')).toBeHidden();
await chatPage.getVisiblePopoverMenuItem('Anthropic').hover({ force: true });
const anthropicModels = chatPage.getVisiblePopoverMenuItem(/^Claude/);
await expect(anthropicModels).toHaveText(['Claude Opus 4.5']);
await memberN8n.api.credentials.deleteCredential(cred.id);
});
});
@@ -0,0 +1,57 @@
import { test, expect, chatHubTestConfig } from './fixtures';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
test.use(chatHubTestConfig);
test.describe('Tools usage @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('use web search tool in conversation', async ({
n8n,
anthropicCredential: _,
jinaCredential,
}) => {
const page = new ChatHubChatPage(n8n.page);
await n8n.navigate.toChatHub();
await page.dismissWelcomeScreen();
await expect(page.getModelSelectorButton()).toContainText(/claude/i);
// Open tools manager modal
await page.getToolsButton().click();
await expect(page.toolsModal.getRoot()).toBeVisible();
// Add Jina AI tool from available tools list
await page.toolsModal.getAddButton('Jina AI').click();
// Select credential in settings view
await page.toolsModal.getCredentialSelect().click();
await page.getVisiblePopoverOption(jinaCredential.name).click();
// Change Operation from "Read" to "Search"
await page.toolsModal.getParameterInput('operation').click();
await page.getVisiblePopoverOption('Search').click();
// Set search query and simplify to "defined automatically by the model"
await page.toolsModal.getFromAiOverrideButton('searchQuery').click();
await page.toolsModal.getFromAiOverrideButton('simplify').click();
// Save and close
await page.toolsModal.getSaveButton().click();
await page.toolsModal.getCloseButton().click();
await expect(page.toolsModal.getRoot()).toBeHidden();
// Verify tool is shown
await expect(page.getToolsButton()).toContainText('Search web in Jina AI');
// Send message and check response
await page.getChatInput().fill('What is n8n?');
await page.getSendButton().click();
await expect(page.getChatMessages().nth(0)).toContainText('What is n8n?');
await expect(page.getChatMessages().nth(1)).toContainText(/automation/i, {
timeout: 60000,
});
});
});
@@ -0,0 +1,113 @@
import { expect, test, chatHubTestConfig } from './fixtures';
import { INSTANCE_MEMBER_CREDENTIALS } from '../../../config/test-users';
import { CanvasPage } from '../../../pages/CanvasPage';
import { ChatHubChatPage } from '../../../pages/ChatHubChatPage';
import { ChatHubWorkflowAgentsPage } from '../../../pages/ChatHubWorkflowAgentsPage';
import { NodeDetailsViewPage } from '../../../pages/NodeDetailsViewPage';
test.use(chatHubTestConfig);
test.describe('Workflow agent @capability:proxy', {
annotation: [
{ type: 'owner', description: 'Chat' },
],
}, () => {
test('manage workflow agents @auth:admin', async ({ n8n, agentWorkflow }) => {
const agentsPage = new ChatHubWorkflowAgentsPage(n8n.page);
const chatPage = new ChatHubChatPage(n8n.page);
// STEP: Navigate to workflow agents page and verify agent is listed
await n8n.navigate.toChatHubWorkflowAgents();
await expect(agentsPage.getAgentCards()).toHaveCount(1);
await expect(agentsPage.getAgentCards().nth(0)).toContainText(agentWorkflow.name);
// STEP: Click agent card to start conversation
await agentsPage.getAgentCards().nth(0).click();
await chatPage.dismissWelcomeScreen();
await expect(chatPage.getModelSelectorButton()).toContainText(agentWorkflow.name);
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText(/Bonjour/i);
// STEP: Open workflow in new tab and update system prompt
const tab1 = await chatPage.clickOpenWorkflowButton();
const canvas = new CanvasPage(tab1);
const ndv = new NodeDetailsViewPage(tab1);
await canvas.openNode('AI Agent');
await ndv.fillParameterInput('System Message', 'Reply in Finnish');
await ndv.close();
await canvas.publishWorkflow();
await tab1.close();
// STEP: Select the workflow agent for new conversation
await n8n.navigate.toChatHub();
await chatPage.getModelSelectorButton().click();
await n8n.page.waitForTimeout(500);
await chatPage.getVisiblePopoverMenuItem('Workflow agents').hover({ force: true });
await chatPage.getVisiblePopoverMenuItem(agentWorkflow.name, { exact: true }).click();
// STEP: Send message again
await chatPage.getChatInput().fill('Hello');
await chatPage.getSendButton().click();
await expect(chatPage.getChatMessages().last()).toContainText(/Hei|Moi/i);
// STEP: Open workflow in new tab and disable ChatHub
const tab2 = await chatPage.clickOpenWorkflowButton();
const canvas2 = new CanvasPage(tab2);
const ndv2 = new NodeDetailsViewPage(tab2);
await canvas2.openNode('When chat message received');
await ndv2.getParameterSwitch('availableInChat').click();
await ndv2.close();
await canvas2.publishWorkflow();
await tab2.close();
await n8n.navigate.toChatHubWorkflowAgents();
await expect(agentsPage.getEmptyText()).toBeVisible();
});
test('sharing workflow agent with project chat user', async ({
n8n,
anthropicCredential,
agentWorkflow,
project,
}) => {
const memberN8n = await n8n.start.withUser(INSTANCE_MEMBER_CREDENTIALS[0]);
const memberEmail = INSTANCE_MEMBER_CREDENTIALS[0].email;
// Transfer workflow and credential to it
await n8n.api.credentials.transferCredential(anthropicCredential.id, project.id);
await n8n.api.workflows.transfer(agentWorkflow.id, project.id);
// Verify that the agent is visible to owner
const ownerWorkflowAgentsPage = new ChatHubWorkflowAgentsPage(n8n.page);
await n8n.navigate.toChatHubWorkflowAgents();
await expect(ownerWorkflowAgentsPage.getAgentCards()).toContainText([agentWorkflow.name]);
// Verify that the agent is not visible to member before sharing
const memberWorkflowAgentsPage = new ChatHubWorkflowAgentsPage(memberN8n.page);
await memberN8n.navigate.toChatHubWorkflowAgents();
await expect(memberWorkflowAgentsPage.getEmptyText()).toBeVisible();
// Add member user to the project with project chat user role
await n8n.navigate.toProjectSettings(project.id);
await n8n.projectSettings.getMembersSearchInput().click();
await n8n.projectSettings.getVisiblePopoverOption(memberEmail).click();
await expect(n8n.projectSettings.getMembersTable()).toContainText(memberEmail);
await n8n.projectSettings.getRoleDropdownFor(memberEmail).click();
await n8n.projectSettings.getVisiblePopoverOption('Project Chat User').click();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
// Verify that the agent is visible and usable to member after sharing
await memberN8n.page.reload();
await expect(memberWorkflowAgentsPage.getAgentCards()).toContainText([agentWorkflow.name]);
await memberWorkflowAgentsPage.getAgentCards().nth(0).click();
const memberChatPage = new ChatHubChatPage(memberN8n.page);
await memberChatPage.dismissWelcomeScreen();
await memberChatPage.getChatInput().fill('Hello');
await memberChatPage.getSendButton().click();
await expect(memberChatPage.getChatMessages().last()).toContainText(/Bonjour/i);
});
});
@@ -0,0 +1,122 @@
import type { Project } from '@n8n/db';
import type { IWorkflowBase } from 'n8n-workflow';
import { test as base, expect as baseExpect } from '../../../fixtures/base';
import type { CredentialResponse } from '../../../services/credential-api-helper';
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'mock-anthropic-api-key';
const JINA_API_KEY = process.env.JINA_API_KEY ?? 'mock-jina-api-key';
type ChatHubFixtures = {
project: Project;
anthropicCredential: CredentialResponse;
anthropicApiKey: string;
jinaCredential: CredentialResponse;
jinaApiKey: string;
chatHubProxySetup: undefined;
agentWorkflow: IWorkflowBase;
};
export const chatHubTestConfig = {
timezoneId: 'America/New_York',
capability: {
services: ['proxy'],
env: {
N8N_COMMUNITY_PACKAGES_ENABLED: 'false',
},
},
} as const;
export const test = base.extend<ChatHubFixtures>({
anthropicApiKey: async ({}, use) => {
await use(ANTHROPIC_API_KEY);
},
jinaApiKey: async ({}, use) => {
await use(JINA_API_KEY);
},
chatHubProxySetup: [
async ({ services }, use) => {
// Setup
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('chat-hub', { strictBodyMatching: true });
await use(undefined);
// Teardown
if (!process.env.CI) {
await services.proxy.recordExpectations('chat-hub', {
dedupe: true,
transform: (expectation) => {
const response = expectation.httpResponse as {
headers?: Record<string, string[]>;
};
if (response?.headers) {
delete response.headers['anthropic-organization-id'];
}
return expectation;
},
});
}
},
{ auto: true },
],
project: async ({ n8n }, use) => {
const project = await n8n.api.projects.createProject('ChatHub test project');
await use(project);
await n8n.api.projects.deleteProject(project.id);
},
agentWorkflow: async ({ n8n, anthropicCredential, project: _ }, use) => {
const res = await n8n.api.workflows.importWorkflowFromFile('chat-hub-workflow-agent.json', {
transform: (workflow) => {
const anthropicNode = workflow.nodes?.find((n) => n.type.includes('lmChatAnthropic'));
anthropicNode!.credentials!.anthropicApi = anthropicCredential;
return workflow;
},
});
await use(res.createdWorkflow);
await n8n.api.workflows.archive(res.workflowId);
await n8n.api.workflows.delete(res.workflowId);
},
anthropicCredential: async ({ n8n, anthropicApiKey, project: _ }, use) => {
const res = await n8n.api.credentials.createCredential({
name: `Anthropic cred ${crypto.randomUUID().slice(0, 8)}`,
type: 'anthropicApi',
data: {
apiKey: anthropicApiKey,
},
});
await use(res);
await n8n.api.credentials.deleteCredential(res.id);
},
jinaCredential: async ({ n8n, jinaApiKey }, use) => {
const res = await n8n.api.credentials.createCredential({
name: `Jina AI cred ${crypto.randomUUID().slice(0, 8)}`,
type: 'jinaAiApi',
data: {
apiKey: jinaApiKey,
},
});
await use(res);
await n8n.api.credentials.deleteCredential(res.id);
},
});
export const expect = baseExpect;