mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
1061 lines
33 KiB
TypeScript
1061 lines
33 KiB
TypeScript
import { generateKeyPairSync } from 'crypto';
|
|
import type { IWorkflowBase } from 'n8n-workflow';
|
|
|
|
import { test, expect, instanceAiTestConfig } from './fixtures';
|
|
|
|
test.use(instanceAiTestConfig);
|
|
|
|
const { privateKey: GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY } = generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
});
|
|
|
|
const MIXED_GROUPING_WORKFLOW_NAME = 'B3 Workflow Setup Apply Credentials';
|
|
const MIXED_INITIAL_HTTP_CREDENTIAL_NAME = 'B3 Mixed Initial Basic Auth';
|
|
const MIXED_SHARED_HTTP_CREDENTIAL_NAME = 'B3 Mixed Shared Basic Auth';
|
|
const MIXED_OTHER_HTTP_CREDENTIAL_NAME = 'B3 Mixed Other Basic Auth';
|
|
const MIXED_INITIAL_GOOGLE_CREDENTIAL_NAME = 'B3 Mixed Initial Google Sheets';
|
|
const MIXED_GOOGLE_CREDENTIAL_NAME = 'B3 Mixed Google Sheets';
|
|
|
|
const DEFER_WORKFLOW_NAME = 'B3 Workflow Setup Defer Credentials';
|
|
|
|
const PARTIAL_WORKFLOW_NAME = 'B3 Workflow Setup Partial Apply Credentials';
|
|
const PARTIAL_BASIC_CREDENTIAL_NAME = 'B3 Partial Basic Auth';
|
|
|
|
const SKIP_BADGE_WORKFLOW_NAME = 'B3 Workflow Setup Skip Badge';
|
|
const SKIP_BADGE_BASIC_CREDENTIAL_NAME = 'B3 Skip Badge Basic Auth';
|
|
|
|
const ROUTE_BACK_WORKFLOW_NAME = 'B3 Workflow Setup Route Back To Earlier Card';
|
|
|
|
const SLACK_WORKFLOW_NAME = 'B3 Workflow Setup Slack Credential Test';
|
|
const SLACK_CREDENTIAL_NAME = 'B3 Slack Credential Test';
|
|
|
|
const SELECT_EXISTING_WORKFLOW_NAME = 'B3 Workflow Setup Select Existing Credential';
|
|
const SELECT_EXISTING_INITIAL_CREDENTIAL_NAME = 'B3 Slack Trigger Initial Credential';
|
|
const SELECT_EXISTING_TARGET_CREDENTIAL_NAME = 'B3 Slack Trigger Target Credential';
|
|
|
|
const PARAMETER_ISSUE_WORKFLOW_NAME = 'B3 Workflow Setup Required Parameter';
|
|
const PARAMETER_APPLY_WORKFLOW_NAME = 'B3 Full Wizard Apply';
|
|
const PARAMETER_CREDENTIAL_NAME = 'B3 Parameter Header Auth';
|
|
|
|
const GROUPING_WORKFLOW_NAME = 'B3 Workflow Setup Subnode Grouping';
|
|
const GROUPING_OPENAI_CREDENTIAL_NAME = 'B3 Grouping OpenAI';
|
|
const GROUPING_LINEAR_CREDENTIAL_NAME = 'B3 Grouping Linear';
|
|
const GROUPING_TELEGRAM_CREDENTIAL_NAME = 'B3 Grouping Telegram';
|
|
|
|
function createParameterOnlyWorkflow(name: string): Partial<IWorkflowBase> {
|
|
return {
|
|
name,
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'trigger',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
{
|
|
id: 'http',
|
|
name: 'HTTP Request',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [220, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: '',
|
|
authentication: 'none',
|
|
},
|
|
},
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
settings: {},
|
|
};
|
|
}
|
|
|
|
function createParameterAndCredentialWorkflow(name: string): Partial<IWorkflowBase> {
|
|
const workflow = createParameterOnlyWorkflow(name);
|
|
const httpNode = workflow.nodes?.find((node) => node.name === 'HTTP Request');
|
|
if (httpNode) {
|
|
httpNode.parameters = {
|
|
method: 'GET',
|
|
url: '',
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpHeaderAuth',
|
|
};
|
|
}
|
|
return workflow;
|
|
}
|
|
|
|
function createMixedGroupedCredentialWorkflow(name: string): Partial<IWorkflowBase> {
|
|
const sharedUrl = 'https://example.com/shared-api';
|
|
|
|
return {
|
|
name,
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'trigger',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
{
|
|
id: 'http-shared-a',
|
|
name: 'HTTP Request Shared A',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [220, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: sharedUrl,
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBasicAuth',
|
|
},
|
|
},
|
|
{
|
|
id: 'google-leads',
|
|
name: 'Google Sheets Leads',
|
|
type: 'n8n-nodes-base.googleSheets',
|
|
typeVersion: 4.7,
|
|
position: [440, -120],
|
|
parameters: {
|
|
authentication: 'serviceAccount',
|
|
resource: 'sheet',
|
|
operation: 'read',
|
|
documentId: { __rl: true, mode: 'id', value: 'spreadsheet123' },
|
|
sheetName: { __rl: true, mode: 'name', value: 'Leads' },
|
|
},
|
|
},
|
|
{
|
|
id: 'http-shared-b',
|
|
name: 'HTTP Request Shared B',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [660, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: sharedUrl,
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBasicAuth',
|
|
},
|
|
},
|
|
{
|
|
id: 'google-contacts',
|
|
name: 'Google Sheets Contacts',
|
|
type: 'n8n-nodes-base.googleSheets',
|
|
typeVersion: 4.7,
|
|
position: [880, -120],
|
|
parameters: {
|
|
authentication: 'serviceAccount',
|
|
resource: 'sheet',
|
|
operation: 'read',
|
|
documentId: { __rl: true, mode: 'id', value: 'spreadsheet456' },
|
|
sheetName: { __rl: true, mode: 'name', value: 'Contacts' },
|
|
},
|
|
},
|
|
{
|
|
id: 'http-other',
|
|
name: 'HTTP Request Other URL',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [1100, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: 'https://example.com/other-api',
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBasicAuth',
|
|
},
|
|
},
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'HTTP Request Shared A', type: 'main', index: 0 }]],
|
|
},
|
|
'HTTP Request Shared A': {
|
|
main: [[{ node: 'Google Sheets Leads', type: 'main', index: 0 }]],
|
|
},
|
|
'Google Sheets Leads': {
|
|
main: [[{ node: 'HTTP Request Shared B', type: 'main', index: 0 }]],
|
|
},
|
|
'HTTP Request Shared B': {
|
|
main: [[{ node: 'Google Sheets Contacts', type: 'main', index: 0 }]],
|
|
},
|
|
'Google Sheets Contacts': {
|
|
main: [[{ node: 'HTTP Request Other URL', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
settings: {},
|
|
};
|
|
}
|
|
|
|
function createTwoCardWorkflow(name: string): Partial<IWorkflowBase> {
|
|
return {
|
|
name,
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'trigger',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
{
|
|
id: 'basic',
|
|
name: 'HTTP Request Basic',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [220, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: 'https://example.com/basic',
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBasicAuth',
|
|
},
|
|
},
|
|
{
|
|
id: 'basic-copy',
|
|
name: 'HTTP Request Basic Copy',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [440, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: 'https://example.com/basic',
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpBasicAuth',
|
|
},
|
|
},
|
|
{
|
|
id: 'header',
|
|
name: 'HTTP Request Header',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.2,
|
|
position: [660, 0],
|
|
parameters: {
|
|
method: 'GET',
|
|
url: 'https://example.com/header',
|
|
authentication: 'genericCredentialType',
|
|
genericAuthType: 'httpHeaderAuth',
|
|
},
|
|
},
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'HTTP Request Basic', type: 'main', index: 0 }]],
|
|
},
|
|
'HTTP Request Basic': {
|
|
main: [[{ node: 'HTTP Request Basic Copy', type: 'main', index: 0 }]],
|
|
},
|
|
'HTTP Request Basic Copy': {
|
|
main: [[{ node: 'HTTP Request Header', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
settings: {},
|
|
};
|
|
}
|
|
|
|
function createSlackWorkflow(name: string): Partial<IWorkflowBase> {
|
|
return {
|
|
name,
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'trigger',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
{
|
|
id: 'slack',
|
|
name: 'Slack',
|
|
type: 'n8n-nodes-base.slack',
|
|
typeVersion: 2.2,
|
|
position: [220, 0],
|
|
parameters: {
|
|
authentication: 'accessToken',
|
|
resource: 'message',
|
|
operation: 'send',
|
|
channelId: {
|
|
__rl: true,
|
|
mode: 'id',
|
|
value: 'C01234567',
|
|
},
|
|
messageType: 'text',
|
|
text: 'Hello from instance AI workflow setup!',
|
|
},
|
|
},
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'Slack', type: 'main', index: 0 }]],
|
|
},
|
|
},
|
|
settings: {},
|
|
};
|
|
}
|
|
|
|
function createSlackTriggerWorkflow(name: string): Partial<IWorkflowBase> {
|
|
return {
|
|
name,
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'slack-trigger',
|
|
name: 'Slack Trigger',
|
|
type: 'n8n-nodes-base.slackTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {
|
|
authentication: 'accessToken',
|
|
trigger: ['message'],
|
|
watchWorkspace: false,
|
|
channelId: {
|
|
__rl: true,
|
|
mode: 'id',
|
|
value: 'C01234567',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
connections: {},
|
|
settings: {},
|
|
};
|
|
}
|
|
|
|
function createAgentWithSubnodesWorkflow(name: string): Partial<IWorkflowBase> {
|
|
return {
|
|
name,
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'trigger',
|
|
name: 'Manual Trigger',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
},
|
|
{
|
|
id: 'agent',
|
|
name: 'AI Agent',
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
typeVersion: 3.1,
|
|
position: [220, 0],
|
|
parameters: { options: {} },
|
|
},
|
|
{
|
|
id: 'openai',
|
|
name: 'OpenAI Chat Model',
|
|
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
|
typeVersion: 1.3,
|
|
position: [120, 220],
|
|
parameters: {
|
|
model: {
|
|
__rl: true,
|
|
mode: 'list',
|
|
value: 'gpt-4o-mini',
|
|
cachedResultName: 'gpt-4o-mini',
|
|
},
|
|
options: {},
|
|
},
|
|
},
|
|
{
|
|
id: 'linear-get',
|
|
name: 'Get an issue in Linear',
|
|
type: 'n8n-nodes-base.linearTool',
|
|
typeVersion: 1.1,
|
|
position: [280, 220],
|
|
parameters: { operation: 'get' },
|
|
},
|
|
{
|
|
id: 'linear-update',
|
|
name: 'Update an issue in Linear',
|
|
type: 'n8n-nodes-base.linearTool',
|
|
typeVersion: 1.1,
|
|
position: [440, 220],
|
|
parameters: { operation: 'update', updateFields: {} },
|
|
},
|
|
{
|
|
id: 'telegram',
|
|
name: 'Send a text message',
|
|
type: 'n8n-nodes-base.telegram',
|
|
typeVersion: 1.2,
|
|
position: [600, 0],
|
|
parameters: {
|
|
chatId: '5134203310',
|
|
text: '={{ $json.text }}',
|
|
additionalFields: {},
|
|
},
|
|
},
|
|
],
|
|
connections: {
|
|
'Manual Trigger': {
|
|
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]],
|
|
},
|
|
'AI Agent': {
|
|
main: [[{ node: 'Send a text message', type: 'main', index: 0 }]],
|
|
},
|
|
'OpenAI Chat Model': {
|
|
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]],
|
|
},
|
|
'Get an issue in Linear': {
|
|
ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]],
|
|
},
|
|
'Update an issue in Linear': {
|
|
ai_tool: [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]],
|
|
},
|
|
},
|
|
settings: {},
|
|
};
|
|
}
|
|
|
|
function getNode(workflow: IWorkflowBase, nodeName: string) {
|
|
const node = workflow.nodes.find((candidate) => candidate.name === nodeName);
|
|
expect(node).toBeDefined();
|
|
return node;
|
|
}
|
|
|
|
function expectAssignedCredentialName(
|
|
workflow: IWorkflowBase,
|
|
nodeName: string,
|
|
credentialType: string,
|
|
credentialName: string,
|
|
) {
|
|
const node = getNode(workflow, nodeName);
|
|
expect(node?.credentials?.[credentialType]).toEqual(
|
|
expect.objectContaining({
|
|
name: credentialName,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function expectNodeParameter(
|
|
workflow: IWorkflowBase,
|
|
nodeName: string,
|
|
parameterName: string,
|
|
value: unknown,
|
|
) {
|
|
const node = getNode(workflow, nodeName);
|
|
expect(node?.parameters?.[parameterName]).toEqual(value);
|
|
}
|
|
|
|
test.describe(
|
|
'Instance AI workflow setup @capability:proxy @db:reset',
|
|
{
|
|
annotation: [{ type: 'owner', description: 'Instance AI' }],
|
|
},
|
|
() => {
|
|
test.describe.configure({ timeout: 180_000 });
|
|
|
|
test.beforeEach(({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name.includes('multi-main'),
|
|
'Setup confirmation replay is not yet stable in multi-main mode',
|
|
);
|
|
});
|
|
|
|
test.beforeEach(async ({ n8n }) => {
|
|
await n8n.page.route('**/rest/credentials/test', async (route) => {
|
|
if (route.request().method() === 'POST') {
|
|
await route.fulfill({
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: { status: 'OK', message: 'Tested successfully' } }),
|
|
});
|
|
} else {
|
|
await route.continue();
|
|
}
|
|
});
|
|
});
|
|
|
|
test(
|
|
'should group mixed credential cards by node-specific rules and persist each group',
|
|
{
|
|
annotation: [
|
|
{
|
|
type: 'expectation-slug',
|
|
description:
|
|
'should-create-new-credentials-across-two-setup-cards-and-persist-them-on-apply',
|
|
},
|
|
],
|
|
},
|
|
async ({ n8n, n8nContainer }) => {
|
|
test.skip(
|
|
!n8nContainer,
|
|
'Requires proxy service to mock Google service account token exchange',
|
|
);
|
|
|
|
await n8nContainer.services.proxy.createExpectation({
|
|
httpRequest: { method: 'POST', path: '/token' },
|
|
httpResponse: {
|
|
statusCode: 200,
|
|
headers: { 'Content-Type': ['application/json'] },
|
|
body: JSON.stringify({
|
|
access_token: 'mock-google-access-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}),
|
|
},
|
|
times: { unlimited: true },
|
|
});
|
|
|
|
await n8n.api.credentials.createCredential({
|
|
name: MIXED_INITIAL_HTTP_CREDENTIAL_NAME,
|
|
type: 'httpBasicAuth',
|
|
data: { user: 'initial-http-user', password: 'initial-http-password' },
|
|
});
|
|
const sharedHttpCredential = await n8n.api.credentials.createCredential({
|
|
name: MIXED_SHARED_HTTP_CREDENTIAL_NAME,
|
|
type: 'httpBasicAuth',
|
|
data: { user: 'shared-http-user', password: 'shared-http-password' },
|
|
});
|
|
const otherHttpCredential = await n8n.api.credentials.createCredential({
|
|
name: MIXED_OTHER_HTTP_CREDENTIAL_NAME,
|
|
type: 'httpBasicAuth',
|
|
data: { user: 'other-http-user', password: 'other-http-password' },
|
|
});
|
|
await n8n.api.credentials.createCredential({
|
|
name: MIXED_INITIAL_GOOGLE_CREDENTIAL_NAME,
|
|
type: 'googleApi',
|
|
data: {
|
|
email: 'initial@project.iam.gserviceaccount.com',
|
|
privateKey: GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY,
|
|
},
|
|
});
|
|
const googleCredential = await n8n.api.credentials.createCredential({
|
|
name: MIXED_GOOGLE_CREDENTIAL_NAME,
|
|
type: 'googleApi',
|
|
data: {
|
|
email: 'sheets@project.iam.gserviceaccount.com',
|
|
privateKey: GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY,
|
|
},
|
|
});
|
|
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createMixedGroupedCredentialWorkflow(MIXED_GROUPING_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(
|
|
`Set up the workflow named "${MIXED_GROUPING_WORKFLOW_NAME}".`,
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 3')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getUsedByNodesHint()).toHaveText(
|
|
'Used by 2 nodes',
|
|
);
|
|
await n8n.instanceAi.workflowSetup.selectCredentialById(sharedHttpCredential.id);
|
|
await expect
|
|
.poll(async () => await n8n.instanceAi.workflowSetup.getSelectedCredentialLabel())
|
|
.toBe(MIXED_SHARED_HTTP_CREDENTIAL_NAME);
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('2 of 3')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getUsedByNodesHint()).toHaveText(
|
|
'Used by 2 nodes',
|
|
);
|
|
await n8n.instanceAi.workflowSetup.selectCredentialById(googleCredential.id);
|
|
await expect
|
|
.poll(async () => await n8n.instanceAi.workflowSetup.getSelectedCredentialLabel())
|
|
.toBe(MIXED_GOOGLE_CREDENTIAL_NAME);
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('3 of 3')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getUsedByNodesHint()).toBeHidden();
|
|
await n8n.instanceAi.workflowSetup.selectCredentialById(otherHttpCredential.id);
|
|
await expect
|
|
.poll(async () => await n8n.instanceAi.workflowSetup.getSelectedCredentialLabel())
|
|
.toBe(MIXED_OTHER_HTTP_CREDENTIAL_NAME);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'HTTP Request Shared A',
|
|
'httpBasicAuth',
|
|
MIXED_SHARED_HTTP_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'HTTP Request Shared B',
|
|
'httpBasicAuth',
|
|
MIXED_SHARED_HTTP_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'Google Sheets Leads',
|
|
'googleApi',
|
|
MIXED_GOOGLE_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'Google Sheets Contacts',
|
|
'googleApi',
|
|
MIXED_GOOGLE_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'HTTP Request Other URL',
|
|
'httpBasicAuth',
|
|
MIXED_OTHER_HTTP_CREDENTIAL_NAME,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('should defer all setup when user skips every card without persisting credentials', async ({
|
|
n8n,
|
|
}) => {
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createTwoCardWorkflow(DEFER_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(`Set up the workflow named "${DEFER_WORKFLOW_NAME}".`);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
|
|
// Skip card 1 — wizard should advance to card 2 without resolving.
|
|
await n8n.instanceAi.workflowSetup.getLaterButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('2 of 2')).toBeVisible();
|
|
|
|
// Skip card 2 — every card is now skipped; wizard resolves as deferred.
|
|
await n8n.instanceAi.workflowSetup.getLaterButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeHidden();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expect(getNode(persisted, 'HTTP Request Basic')?.credentials?.httpBasicAuth).toBeUndefined();
|
|
expect(
|
|
getNode(persisted, 'HTTP Request Basic Copy')?.credentials?.httpBasicAuth,
|
|
).toBeUndefined();
|
|
expect(
|
|
getNode(persisted, 'HTTP Request Header')?.credentials?.httpHeaderAuth,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test(
|
|
'should partially apply credentials when user completes one card and skips the last',
|
|
{
|
|
annotation: [
|
|
{
|
|
type: 'expectation-slug',
|
|
description:
|
|
'should-partially-apply-credentials-when-user-completes-one-card-and-skips-the-last',
|
|
},
|
|
],
|
|
},
|
|
async ({ n8n }) => {
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createTwoCardWorkflow(PARTIAL_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(`Set up the workflow named "${PARTIAL_WORKFLOW_NAME}".`);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
|
|
await n8n.instanceAi.workflowSetup.getSetupCredentialButton().click();
|
|
await n8n.instanceAi.credentialModal.waitForModal();
|
|
await n8n.instanceAi.credentialModal.addCredential(
|
|
{
|
|
user: 'partial-basic-user',
|
|
password: 'partial-basic-password',
|
|
},
|
|
{ name: PARTIAL_BASIC_CREDENTIAL_NAME },
|
|
);
|
|
|
|
// Card 1 is now complete; continue explicitly to card 2.
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('2 of 2')).toBeVisible();
|
|
|
|
// Skip card 2 — terminal action with one completion → partial apply.
|
|
await n8n.instanceAi.workflowSetup.getLaterButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeHidden();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'HTTP Request Basic',
|
|
'httpBasicAuth',
|
|
PARTIAL_BASIC_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'HTTP Request Basic Copy',
|
|
'httpBasicAuth',
|
|
PARTIAL_BASIC_CREDENTIAL_NAME,
|
|
);
|
|
expect(
|
|
getNode(persisted, 'HTTP Request Header')?.credentials?.httpHeaderAuth,
|
|
).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
test('should mark a skipped card and keep the wizard open while other cards are unhandled', async ({
|
|
n8n,
|
|
}) => {
|
|
await n8n.api.workflows.createWorkflow(createTwoCardWorkflow(SKIP_BADGE_WORKFLOW_NAME));
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(`Set up the workflow named "${SKIP_BADGE_WORKFLOW_NAME}".`);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
|
|
// Skip card 1 — wizard stays open and advances to card 2.
|
|
await n8n.instanceAi.workflowSetup.getLaterButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('2 of 2')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible();
|
|
|
|
// Navigate back to card 1 — the Skipped badge should be visible.
|
|
await n8n.instanceAi.workflowSetup.getPrevButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getCardSkipped()).toBeVisible();
|
|
|
|
// Selecting a credential on the skipped card auto-clears the badge
|
|
// and marks it complete.
|
|
await n8n.instanceAi.workflowSetup.getSetupCredentialButton().click();
|
|
await n8n.instanceAi.credentialModal.waitForModal();
|
|
await n8n.instanceAi.credentialModal.addCredential(
|
|
{
|
|
user: 'skip-badge-user',
|
|
password: 'skip-badge-password',
|
|
},
|
|
{ name: SKIP_BADGE_BASIC_CREDENTIAL_NAME },
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCardSkipped()).toBeHidden();
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
});
|
|
|
|
test('should route back to an earlier unhandled card when user skips a later one', async ({
|
|
n8n,
|
|
}) => {
|
|
await n8n.api.workflows.createWorkflow(createTwoCardWorkflow(ROUTE_BACK_WORKFLOW_NAME));
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(`Set up the workflow named "${ROUTE_BACK_WORKFLOW_NAME}".`);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
|
|
// Manually navigate forward without handling card 1.
|
|
await n8n.instanceAi.workflowSetup.getNextButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('2 of 2')).toBeVisible();
|
|
|
|
// Skip card 2 — card 1 is still unhandled, wizard should route back.
|
|
await n8n.instanceAi.workflowSetup.getLaterButton().click();
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible();
|
|
});
|
|
|
|
test('should clear required parameter issue indicator when the field is filled', async ({
|
|
n8n,
|
|
}) => {
|
|
await n8n.api.workflows.createWorkflow(
|
|
createParameterOnlyWorkflow(PARAMETER_ISSUE_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(
|
|
`Set up the workflow named "${PARAMETER_ISSUE_WORKFLOW_NAME}".`,
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getApplyButton()).toBeDisabled();
|
|
await expect(n8n.instanceAi.workflowSetup.getParameterIssues('url')).toBeHidden();
|
|
|
|
await n8n.instanceAi.workflowSetup.getParameterInput('url').click();
|
|
await n8n.instanceAi.workflowSetup.getParameterInput('url').blur();
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getParameterIssues('url')).toBeVisible();
|
|
|
|
await n8n.instanceAi.workflowSetup.fillParameter('url', 'https://example.com/api');
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getParameterIssues('url')).toBeHidden();
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getApplyButton()).toBeEnabled();
|
|
});
|
|
|
|
test(
|
|
'should apply parameter and credential edits and persist them to the workflow',
|
|
{
|
|
annotation: [
|
|
{
|
|
type: 'expectation-slug',
|
|
description:
|
|
'should-apply-parameter-and-credential-edits-and-persist-them-to-the-workflow',
|
|
},
|
|
],
|
|
},
|
|
async ({ n8n }) => {
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createParameterAndCredentialWorkflow(PARAMETER_APPLY_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(
|
|
`Set up the workflow named "${PARAMETER_APPLY_WORKFLOW_NAME}".`,
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
|
|
await n8n.instanceAi.workflowSetup.fillParameter('url', 'https://example.com/api');
|
|
await n8n.instanceAi.workflowSetup.getSetupCredentialButton().click();
|
|
await n8n.instanceAi.credentialModal.waitForModal();
|
|
await n8n.instanceAi.credentialModal.addCredential(
|
|
{
|
|
name: 'x-parameter-token',
|
|
value: 'parameter-header-value',
|
|
},
|
|
{ name: PARAMETER_CREDENTIAL_NAME },
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expectNodeParameter(persisted, 'HTTP Request', 'url', 'https://example.com/api');
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'HTTP Request',
|
|
'httpHeaderAuth',
|
|
PARAMETER_CREDENTIAL_NAME,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('should create and apply a mocked testable Slack credential from setup', async ({
|
|
n8n,
|
|
}) => {
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createSlackWorkflow(SLACK_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(`Set up the workflow named "${SLACK_WORKFLOW_NAME}".`);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
|
|
await n8n.instanceAi.workflowSetup.getSetupCredentialButton().click();
|
|
await n8n.instanceAi.credentialModal.waitForModal();
|
|
await expect(n8n.instanceAi.credentialModal.getFieldInput('accessToken')).toBeVisible();
|
|
|
|
const credentialTestResponsePromise = n8n.page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/rest/credentials/test') &&
|
|
response.request().method() === 'POST',
|
|
);
|
|
|
|
await n8n.instanceAi.credentialModal.addCredential(
|
|
{
|
|
accessToken: 'xoxb-fake-token-for-testing',
|
|
},
|
|
{ name: SLACK_CREDENTIAL_NAME },
|
|
);
|
|
|
|
const credentialTestResponse = await credentialTestResponsePromise;
|
|
const credentialTestRequest = credentialTestResponse.request().postDataJSON() as {
|
|
credentials?: {
|
|
type?: string;
|
|
};
|
|
};
|
|
const credentialTestBody = (await credentialTestResponse.json()) as {
|
|
data?: { status?: string };
|
|
};
|
|
|
|
expect(credentialTestRequest.credentials?.type).toBe('slackApi');
|
|
expect(credentialTestBody.data?.status).toBe('OK');
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expectAssignedCredentialName(persisted, 'Slack', 'slackApi', SLACK_CREDENTIAL_NAME);
|
|
});
|
|
|
|
test('should persist a manually selected existing credential from the dropdown', async ({
|
|
n8n,
|
|
}) => {
|
|
// creds are sorted by name, in the dropdown
|
|
const firstCrdentialInList = await n8n.api.credentials.createCredential({
|
|
name: SELECT_EXISTING_TARGET_CREDENTIAL_NAME,
|
|
type: 'slackApi',
|
|
data: {
|
|
accessToken: 'xoxb-target-token-for-testing',
|
|
},
|
|
});
|
|
const secondCrdentialInList = await n8n.api.credentials.createCredential({
|
|
name: SELECT_EXISTING_INITIAL_CREDENTIAL_NAME,
|
|
type: 'slackApi',
|
|
data: {
|
|
accessToken: 'xoxb-initial-token-for-testing',
|
|
},
|
|
});
|
|
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createSlackTriggerWorkflow(SELECT_EXISTING_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(
|
|
`Set up the workflow named "${SELECT_EXISTING_WORKFLOW_NAME}".`,
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect
|
|
.poll(async () => await n8n.instanceAi.workflowSetup.getSelectedCredentialLabel())
|
|
.toBe(firstCrdentialInList.name);
|
|
|
|
await n8n.instanceAi.workflowSetup.selectCredentialById(secondCrdentialInList.id);
|
|
await expect
|
|
.poll(async () => await n8n.instanceAi.workflowSetup.getSelectedCredentialLabel())
|
|
.toBe(secondCrdentialInList.name);
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'Slack Trigger',
|
|
'slackApi',
|
|
secondCrdentialInList.name,
|
|
);
|
|
});
|
|
|
|
test('should render AI agent subnodes as one group step with separate sections', async ({
|
|
n8n,
|
|
}) => {
|
|
await n8n.api.credentials.createCredential({
|
|
name: GROUPING_OPENAI_CREDENTIAL_NAME,
|
|
type: 'openAiApi',
|
|
data: { apiKey: 'sk-grouping-test' },
|
|
});
|
|
await n8n.api.credentials.createCredential({
|
|
name: GROUPING_LINEAR_CREDENTIAL_NAME,
|
|
type: 'linearApi',
|
|
data: { apiKey: 'lin-grouping-test' },
|
|
});
|
|
await n8n.api.credentials.createCredential({
|
|
name: GROUPING_TELEGRAM_CREDENTIAL_NAME,
|
|
type: 'telegramApi',
|
|
data: { accessToken: 'tg-grouping-test' },
|
|
});
|
|
|
|
const workflow = await n8n.api.workflows.createWorkflow(
|
|
createAgentWithSubnodesWorkflow(GROUPING_WORKFLOW_NAME),
|
|
);
|
|
|
|
await n8n.navigate.toInstanceAi();
|
|
await n8n.instanceAi.sendMessage(`Set up the workflow named "${GROUPING_WORKFLOW_NAME}".`);
|
|
|
|
// === Step 1: AI Agent group step ===
|
|
await expect(n8n.instanceAi.workflowSetup.getGroupCard()).toBeVisible({ timeout: 120_000 });
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('1 of 2')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getGroupCard()).toContainText('AI Agent');
|
|
|
|
// All three subnode sections render separately because the Linear tools
|
|
// each have parameter issues, so they are not merged into a single
|
|
// credential-only section.
|
|
await expect(n8n.instanceAi.workflowSetup.getSection('OpenAI Chat Model')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getSection('Get an issue in Linear')).toBeVisible();
|
|
await expect(
|
|
n8n.instanceAi.workflowSetup.getSection('Update an issue in Linear'),
|
|
).toBeVisible();
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getGroupCheck()).toBeHidden();
|
|
|
|
// Fill each section independently — the per-section helpers expand the
|
|
// section if it isn't already open.
|
|
await n8n.instanceAi.workflowSetup.selectSectionCredential(
|
|
'OpenAI Chat Model',
|
|
GROUPING_OPENAI_CREDENTIAL_NAME,
|
|
);
|
|
await n8n.instanceAi.workflowSetup.selectSectionCredential(
|
|
'Get an issue in Linear',
|
|
GROUPING_LINEAR_CREDENTIAL_NAME,
|
|
);
|
|
await n8n.instanceAi.workflowSetup.fillSectionParameter(
|
|
'Get an issue in Linear',
|
|
'issueId',
|
|
'IS-123',
|
|
);
|
|
await n8n.instanceAi.workflowSetup.selectSectionCredential(
|
|
'Update an issue in Linear',
|
|
GROUPING_LINEAR_CREDENTIAL_NAME,
|
|
);
|
|
await n8n.instanceAi.workflowSetup.fillSectionParameter(
|
|
'Update an issue in Linear',
|
|
'issueId',
|
|
'IS-456',
|
|
);
|
|
|
|
await expect(n8n.instanceAi.workflowSetup.getGroupCheck()).toBeVisible();
|
|
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
|
|
// === Step 2: Telegram (single, non-grouped card) ===
|
|
await expect(n8n.instanceAi.workflowSetup.getStepText('2 of 2')).toBeVisible();
|
|
await expect(n8n.instanceAi.workflowSetup.getGroupCard()).toBeHidden();
|
|
await n8n.instanceAi.workflowSetup.selectCredential(GROUPING_TELEGRAM_CREDENTIAL_NAME);
|
|
await expect(n8n.instanceAi.workflowSetup.getCardCheck()).toBeVisible();
|
|
await n8n.instanceAi.workflowSetup.getApplyButton().click();
|
|
await n8n.instanceAi.waitForResponseComplete();
|
|
|
|
const persisted = await n8n.api.workflows.getWorkflow(workflow.id);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'OpenAI Chat Model',
|
|
'openAiApi',
|
|
GROUPING_OPENAI_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'Get an issue in Linear',
|
|
'linearApi',
|
|
GROUPING_LINEAR_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'Update an issue in Linear',
|
|
'linearApi',
|
|
GROUPING_LINEAR_CREDENTIAL_NAME,
|
|
);
|
|
expectAssignedCredentialName(
|
|
persisted,
|
|
'Send a text message',
|
|
'telegramApi',
|
|
GROUPING_TELEGRAM_CREDENTIAL_NAME,
|
|
);
|
|
expectNodeParameter(persisted, 'Get an issue in Linear', 'issueId', 'IS-123');
|
|
expectNodeParameter(persisted, 'Update an issue in Linear', 'issueId', 'IS-456');
|
|
});
|
|
},
|
|
);
|