n8n/packages/testing/playwright/tests/e2e/instance-ai/instance-ai-workflow-setup.spec.ts
oleg d7d2cc1442
feat(core): Add native agent substrate (no-changelog) (#30015)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
2026-05-19 13:48:45 +00:00

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');
});
},
);