From 1a1c07d6eb50cd292ad83ec176f5468ec7a27c47 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Fri, 12 Sep 2025 12:32:04 +0100 Subject: [PATCH] test: Credential test migration part 1 (#19420) --- cypress/e2e/2-credentials.cy.ts | 351 ----------------- .../composables/CredentialsComposer.ts | 53 +++ .../playwright/composables/ProjectComposer.ts | 8 +- .../testing/playwright/pages/CanvasPage.ts | 2 + .../playwright/pages/CredentialsEditModal.ts | 67 ---- .../playwright/pages/CredentialsPage.ts | 107 +++--- .../playwright/pages/NodeDetailsViewPage.ts | 22 +- .../pages/components/CredentialModal.ts | 120 ++++++ packages/testing/playwright/pages/n8nPage.ts | 6 +- .../services/credential-api-helper.ts | 4 + .../playwright/tests/ui/2-credentials.spec.ts | 362 ++++++++++++++++++ .../playwright/tests/ui/30-langchain.spec.ts | 6 +- .../playwright/tests/ui/39-projects.spec.ts | 5 +- .../playwright/tests/ui/43-oauth-flow.spec.ts | 19 +- .../testing/playwright/tests/ui/5-ndv.spec.ts | 8 +- .../playwright/tests/ui/50-logs.spec.ts | 2 +- ...tly-set-up-agent-model-shows-error.spec.ts | 4 +- .../ui/building-blocks/04-credentials.spec.ts | 93 +++++ 18 files changed, 740 insertions(+), 499 deletions(-) delete mode 100644 cypress/e2e/2-credentials.cy.ts create mode 100644 packages/testing/playwright/composables/CredentialsComposer.ts delete mode 100644 packages/testing/playwright/pages/CredentialsEditModal.ts create mode 100644 packages/testing/playwright/pages/components/CredentialModal.ts create mode 100644 packages/testing/playwright/tests/ui/2-credentials.spec.ts create mode 100644 packages/testing/playwright/tests/ui/building-blocks/04-credentials.spec.ts diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts deleted file mode 100644 index 03eb0b4e27..0000000000 --- a/cypress/e2e/2-credentials.cy.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { type ICredentialType } from 'n8n-workflow'; - -import * as credentialsComposables from '../composables/credentialsComposables'; -import { getCredentialSaveButton, saveCredential } from '../composables/modals/credential-modal'; -import { - AGENT_NODE_NAME, - AI_TOOL_HTTP_NODE_NAME, - GMAIL_NODE_NAME, - HTTP_REQUEST_NODE_NAME, - NEW_GOOGLE_ACCOUNT_NAME, - NEW_NOTION_ACCOUNT_NAME, - NEW_QUERY_AUTH_ACCOUNT_NAME, - NEW_TRELLO_ACCOUNT_NAME, - NOTION_NODE_NAME, - PIPEDRIVE_NODE_NAME, - SCHEDULE_TRIGGER_NODE_NAME, - TRELLO_NODE_NAME, -} from '../constants'; -import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages'; -import { errorToast, successToast } from '../pages/notifications'; -import { getVisibleSelect } from '../utils'; - -const credentialsPage = new CredentialsPage(); -const credentialsModal = new CredentialsModal(); -const workflowPage = new WorkflowPage(); -const nodeDetailsView = new NDV(); - -const NEW_CREDENTIAL_NAME = 'Something else'; -const NEW_CREDENTIAL_NAME2 = 'Something else entirely'; - -function createNotionCredential() { - workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME); - workflowPage.actions.openNode(NOTION_NODE_NAME); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.actions.fillCredentialsForm(); - cy.get('body').type('{esc}'); - workflowPage.actions.deleteNode(NOTION_NODE_NAME); -} - -function deleteSelectedCredential() { - workflowPage.getters.nodeCredentialsEditButton().click(); - credentialsModal.getters.deleteButton().click(); - cy.get('.el-message-box').find('button').contains('Yes').click(); -} - -describe('Credentials', () => { - beforeEach(() => { - credentialsComposables.loadCredentialsPage(credentialsPage.url); - }); - - it('should create a new credential using empty state', () => { - credentialsPage.getters.emptyListCreateCredentialButton().click(); - - credentialsModal.getters.newCredentialModal().should('be.visible'); - credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); - credentialsModal.getters.newCredentialTypeOption('Notion API').click(); - - credentialsModal.getters.newCredentialTypeButton().click(); - credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); - - credentialsModal.actions.setName('My awesome Notion account'); - credentialsModal.actions.save(); - credentialsModal.actions.close(); - - credentialsPage.getters.credentialCards().should('have.length', 1); - }); - - it('should sort credentials', () => { - credentialsPage.actions.search(''); - credentialsPage.actions.sortBy('nameDesc'); - credentialsPage.getters.credentialCards().eq(0).should('contain.text', 'Notion'); - credentialsPage.actions.sortBy('nameAsc'); - }); - - it('should create credentials from NDV for node with multiple auth options', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(GMAIL_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); - credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); - credentialsModal.actions.fillCredentialsForm(); - cy.get('.el-message-box').find('button').contains('Close').click(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_GOOGLE_ACCOUNT_NAME); - }); - - it('should show multiple credential types in the same dropdown', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(GMAIL_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - workflowPage.getters.nodeCredentialsSelect().click(); - // Add oAuth credentials - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); - credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); - credentialsModal.actions.fillCredentialsForm(); - cy.get('.el-message-box').find('button').contains('Close').click(); - workflowPage.getters.nodeCredentialsSelect().click(); - // Add Service account credentials - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); - credentialsModal.getters.credentialAuthTypeRadioButtons().last().click(); - credentialsModal.actions.fillCredentialsForm(); - workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.length', 3); - }); - - it('should correctly render required and optional credentials', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(PIPEDRIVE_NODE_NAME, true, true); - cy.get('body').type('{downArrow}'); - cy.get('body').type('{enter}'); - // Select incoming authentication - nodeDetailsView.getters.parameterInput('incomingAuthentication').should('exist'); - nodeDetailsView.getters.parameterInput('incomingAuthentication').click(); - getVisibleSelect().find('li').first().click(); - // There should be two credential fields - workflowPage.getters.nodeCredentialsSelect().should('have.length', 2); - - workflowPage.getters.nodeCredentialsSelect().first().click(); - workflowPage.getters.nodeCredentialsCreateOption().first().click(); - // This one should show auth type selector - credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); - cy.get('body').type('{esc}'); - - workflowPage.getters.nodeCredentialsSelect().last().click(); - workflowPage.getters.nodeCredentialsCreateOption().last().click(); - // This one should not show auth type selector - credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist'); - }); - - it('should create credentials from NDV for node with no auth options', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(TRELLO_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist'); - credentialsModal.actions.fillCredentialsForm(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_TRELLO_ACCOUNT_NAME); - }); - - it('should delete credentials from NDV', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.actions.fillCredentialsForm(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_NOTION_ACCOUNT_NAME); - - workflowPage.getters.nodeCredentialsEditButton().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.getters.deleteButton().click(); - cy.get('.el-message-box').find('button').contains('Yes').click(); - successToast().contains('Credential deleted'); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('not.have.value', NEW_TRELLO_ACCOUNT_NAME); - }); - - it('should rename credentials from NDV', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(TRELLO_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.actions.fillCredentialsForm(); - workflowPage.getters.nodeCredentialsEditButton().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME); - saveCredential(); - credentialsModal.getters.closeButton().click(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_CREDENTIAL_NAME); - - // Reload page to make sure this also works when the credential hasn't been - // just created. - nodeDetailsView.actions.close(); - workflowPage.actions.saveWorkflowOnButtonClick(); - cy.reload(); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - workflowPage.getters.nodeCredentialsEditButton().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME2); - saveCredential(); - credentialsModal.getters.closeButton().click(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_CREDENTIAL_NAME2); - }); - - it('should edit credential for non-standard credential type', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(AGENT_NODE_NAME); - workflowPage.actions.addNodeToCanvas(AI_TOOL_HTTP_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - cy.getByTestId('parameter-input-authentication').click(); - cy.contains('Predefined Credential Type').click(); - cy.getByTestId('credential-select').click(); - cy.contains('Adalo API').click(); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.actions.fillCredentialsForm(); - workflowPage.getters.nodeCredentialsEditButton().click(); - credentialsModal.getters.credentialsEditModal().should('be.visible'); - credentialsModal.actions.renameCredential(NEW_CREDENTIAL_NAME); - saveCredential(); - credentialsModal.getters.closeButton().click(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_CREDENTIAL_NAME); - }); - - it('should set a default credential when adding nodes', () => { - workflowPage.actions.visit(); - - createNotionCredential(); - - workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_NOTION_ACCOUNT_NAME); - - deleteSelectedCredential(); - }); - - it('should set a default credential when editing a node', () => { - workflowPage.actions.visit(); - - createNotionCredential(); - - workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true); - nodeDetailsView.getters.parameterInput('authentication').click(); - getVisibleSelect().find('li').contains('Predefined').click(); - - nodeDetailsView.getters.parameterInput('nodeCredentialType').click(); - getVisibleSelect().find('li').contains('Notion API').click(); - - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_NOTION_ACCOUNT_NAME); - - deleteSelectedCredential(); - }); - - it('should setup generic authentication for HTTP node', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME); - workflowPage.getters.canvasNodes().last().click(); - cy.get('body').type('{enter}'); - nodeDetailsView.getters.parameterInput('authentication').click(); - getVisibleSelect().find('li').should('have.length', 3); - getVisibleSelect().find('li').last().click(); - nodeDetailsView.getters.parameterInput('genericAuthType').should('exist'); - nodeDetailsView.getters.parameterInput('genericAuthType').click(); - getVisibleSelect().find('li').should('have.length.greaterThan', 0); - getVisibleSelect().find('li').last().click(); - - workflowPage.getters.nodeCredentialsSelect().should('exist'); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.actions.fillCredentialsForm(); - workflowPage.getters - .nodeCredentialsSelect() - .find('input') - .should('have.value', NEW_QUERY_AUTH_ACCOUNT_NAME); - }); - - it('should not show OAuth redirect URL section when OAuth2 credentials are overridden', () => { - cy.intercept('/types/credentials.json', { middleware: true }, (req) => { - req.headers['cache-control'] = 'no-cache, no-store'; - - req.on('response', (res) => { - const credentials: ICredentialType[] = res.body || []; - - const index = credentials.findIndex((c) => c.name === 'slackOAuth2Api'); - - credentials[index] = { - ...credentials[index], - __overwrittenProperties: ['clientId', 'clientSecret'], - }; - }); - }); - - workflowPage.actions.visit(true); - workflowPage.actions.addNodeToCanvas('Manual'); - workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); - workflowPage.getters.nodeCredentialsSelect().should('exist'); - workflowPage.getters.nodeCredentialsSelect().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); - nodeDetailsView.getters.copyInput().should('not.exist'); - }); - - it('ADO-2583 should show notifications above credential modal overlay', () => { - // check error notifications because they are sticky - cy.intercept('POST', '/rest/credentials', { forceNetworkError: true }); - credentialsPage.getters.createCredentialButton().click(); - - credentialsModal.getters.newCredentialModal().should('be.visible'); - credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); - credentialsModal.getters.newCredentialTypeOption('Notion API').click(); - - credentialsModal.getters.newCredentialTypeButton().click(); - credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); - - credentialsModal.actions.setName('My awesome Notion account'); - getCredentialSaveButton().click(); - - errorToast().should('have.length', 1); - errorToast().should('be.visible'); - - errorToast().should('have.css', 'z-index', '2100'); - cy.get('.el-overlay').should('have.css', 'z-index', '2001'); - }); -}); diff --git a/packages/testing/playwright/composables/CredentialsComposer.ts b/packages/testing/playwright/composables/CredentialsComposer.ts new file mode 100644 index 0000000000..43836f277c --- /dev/null +++ b/packages/testing/playwright/composables/CredentialsComposer.ts @@ -0,0 +1,53 @@ +import type { CreateCredentialDto } from '@n8n/api-types'; + +import type { n8nPage } from '../pages/n8nPage'; + +export class CredentialsComposer { + constructor(private readonly n8n: n8nPage) {} + + /** + * Create a credential through the Credentials list UI. + * Expects the visible label of the credential type (e.g. 'Notion API'). + */ + async createFromList( + credentialType: string, + fields: Record, + options?: { name?: string; projectId?: string; closeDialog?: boolean }, + ) { + if (options?.projectId) { + await this.n8n.navigate.toCredentials(options.projectId); + } else { + await this.n8n.navigate.toCredentials(); + } + + // Open the "new credential" chooser: open add resource -> credential + await this.n8n.credentials.addResourceButton.click(); + await this.n8n.credentials.actionCredentialButton.click(); + await this.n8n.credentials.createCredentialFromCredentialPicker(credentialType, fields, { + name: options?.name, + closeDialog: options?.closeDialog, + }); + } + + /** + * Create a credential through the NDV flow. + * Type is implied by the open node's credential requirement. + */ + async createFromNdv( + fields: Record, + options?: { name?: string; closeDialog?: boolean }, + ) { + await this.n8n.ndv.clickCreateNewCredential(); + await this.n8n.canvas.credentialModal.addCredential(fields, { + name: options?.name, + closeDialog: options?.closeDialog, + }); + } + + /** + * Create a credential directly via API. Returns created credential object. + */ + async createFromApi(payload: CreateCredentialDto & { projectId?: string }) { + return await this.n8n.api.credentialApi.createCredential(payload); + } +} diff --git a/packages/testing/playwright/composables/ProjectComposer.ts b/packages/testing/playwright/composables/ProjectComposer.ts index 73570c81dd..92350449c2 100644 --- a/packages/testing/playwright/composables/ProjectComposer.ts +++ b/packages/testing/playwright/composables/ProjectComposer.ts @@ -36,11 +36,9 @@ export class ProjectComposer { credentialValue: string, ) { await this.n8n.sideBar.openNewCredentialDialogForProject(projectName); - await this.n8n.credentials.openNewCredentialDialogFromCredentialList(credentialType); - await this.n8n.credentials.fillCredentialField(credentialFieldName, credentialValue); - await this.n8n.credentials.saveCredential(); - await this.n8n.notifications.waitForNotificationAndClose('Credential successfully created'); - await this.n8n.credentials.closeCredentialDialog(); + await this.n8n.credentials.createCredentialFromCredentialPicker(credentialType, { + [credentialFieldName]: credentialValue, + }); } extractIdFromUrl(url: string, beforeWord: string, afterWord: string): string { diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index ef3299b8b8..2c14ddf668 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -4,12 +4,14 @@ import { nanoid } from 'nanoid'; import { BasePage } from './BasePage'; import { ROUTES } from '../config/constants'; import { resolveFromRoot } from '../utils/path-helper'; +import { CredentialModal } from './components/CredentialModal'; import { LogsPanel } from './components/LogsPanel'; import { StickyComponent } from './components/StickyComponent'; export class CanvasPage extends BasePage { readonly sticky = new StickyComponent(this.page); readonly logsPanel = new LogsPanel(this.page.getByTestId('logs-panel')); + readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal')); saveWorkflowButton(): Locator { return this.page.getByRole('button', { name: 'Save' }); diff --git a/packages/testing/playwright/pages/CredentialsEditModal.ts b/packages/testing/playwright/pages/CredentialsEditModal.ts deleted file mode 100644 index e0423552ac..0000000000 --- a/packages/testing/playwright/pages/CredentialsEditModal.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Locator, Page } from '@playwright/test'; -import { expect } from '@playwright/test'; - -import { BasePage } from './BasePage'; - -export class CredentialsEditModal extends BasePage { - constructor(page: Page) { - super(page); - } - - getModal(): Locator { - return this.page.getByTestId('editCredential-modal'); - } - - async waitForModal(): Promise { - await this.getModal().waitFor({ state: 'visible' }); - } - - async fillField(key: string, value: string): Promise { - const input = this.page.getByTestId(`parameter-input-${key}`).locator('input, textarea'); - await input.fill(value); - await expect(input).toHaveValue(value); - } - - async fillAllFields(values: Record): Promise { - for (const [key, val] of Object.entries(values)) { - await this.fillField(key, val); - } - } - - getSaveButton(): Locator { - return this.page.getByTestId('credential-save-button'); - } - - async save(): Promise { - const saveBtn = this.getSaveButton(); - await saveBtn.click(); - await saveBtn.waitFor({ state: 'visible' }); - - // Saved state changes the button text to "Saved" - // Defensive wait for text when UI updates - try { - await saveBtn - .getByText('Saved', { exact: true }) - .waitFor({ state: 'visible', timeout: 3000 }); - } catch { - // ignore if text assertion is flaky; modal close below will still ensure flow continues - } - } - - async close(): Promise { - const closeBtn = this.getModal().locator('.el-dialog__close').first(); - if (await closeBtn.isVisible()) { - await closeBtn.click(); - } - } - - async setValues(values: Record, save: boolean = true): Promise { - await this.waitForModal(); - await this.fillAllFields(values); - - if (save) { - await this.save(); - await this.close(); - } - } -} diff --git a/packages/testing/playwright/pages/CredentialsPage.ts b/packages/testing/playwright/pages/CredentialsPage.ts index 98042cc711..04f887620c 100644 --- a/packages/testing/playwright/pages/CredentialsPage.ts +++ b/packages/testing/playwright/pages/CredentialsPage.ts @@ -1,6 +1,9 @@ import { BasePage } from './BasePage'; +import { CredentialModal } from './components/CredentialModal'; export class CredentialsPage extends BasePage { + readonly credentialModal = new CredentialModal(this.page.getByTestId('editCredential-modal')); + get emptyListCreateCredentialButton() { return this.page.getByRole('button', { name: 'Add first credential' }); } @@ -10,14 +13,60 @@ export class CredentialsPage extends BasePage { } get credentialCards() { - return this.page.getByTestId('credential-cards'); + return this.page.getByTestId('resources-list-item'); + } + + getCredentialByName(name: string) { + return this.credentialCards.filter({ hasText: name }).first(); + } + + get addResourceButton() { + return this.page.getByTestId('add-resource'); + } + get actionCredentialButton() { + return this.page.getByTestId('action-credential'); } /** - * Create a new credential of the specified type + * Create a credential from the credentials list, fill fields, save, and close the modal. * @param credentialType - The type of credential to create (e.g. 'Notion API') + * @param fields - Key-value pairs for credential fields to fill */ - async openNewCredentialDialogFromCredentialList(credentialType: string): Promise { + async createCredentialFromCredentialPicker( + credentialType: string, + fields: Record, + options?: { closeDialog?: boolean; name?: string }, + ): Promise { + await this.page.getByRole('combobox', { name: 'Search for app...' }).fill(credentialType); + await this.page + .getByTestId('new-credential-type-select-option') + .filter({ hasText: credentialType }) + .click(); + await this.page.getByTestId('new-credential-type-button').click(); + await this.credentialModal.addCredential(fields, { + name: options?.name, + closeDialog: options?.closeDialog, + }); + } + + async clearSearch() { + await this.page.getByTestId('resources-list-search').clear(); + } + + async sortByNameDescending() { + await this.page.getByTestId('resources-list-sort').click(); + await this.page.getByText('Name (Z-A)').click(); + } + + async sortByNameAscending() { + await this.page.getByTestId('resources-list-sort').click(); + await this.page.getByText('Name (A-Z)').click(); + } + + /** + * Select credential type without auto-saving (for tests that need to handle save manually) + */ + async selectCredentialType(credentialType: string): Promise { await this.page.getByRole('combobox', { name: 'Search for app...' }).fill(credentialType); await this.page .getByTestId('new-credential-type-select-option') @@ -25,56 +74,4 @@ export class CredentialsPage extends BasePage { .click(); await this.page.getByTestId('new-credential-type-button').click(); } - - async openCredentialSelector() { - await this.page.getByRole('combobox', { name: 'Select Credential' }).click(); - } - - async createNewCredential() { - await this.clickByText('Create new credential'); - } - - async fillCredentialField(fieldName: string, value: string) { - const field = this.page - .getByTestId(`parameter-input-${fieldName}`) - .getByTestId('parameter-input-field'); - await field.click(); - await field.fill(value); - } - get saveCredentialButton() { - return this.page.getByRole('button', { name: 'Save' }); - } - - async saveCredential() { - await this.clickButtonByName('Save'); - } - - async closeCredentialDialog() { - await this.clickButtonByName('Close this dialog'); - } - - async createAndSaveNewCredential(fieldName: string, value: string) { - await this.openCredentialSelector(); - await this.createNewCredential(); - await this.filLCredentialSaveClose(fieldName, value); - } - - async filLCredentialSaveClose(fieldName: string, value: string) { - await this.fillCredentialField(fieldName, value); - await this.saveCredential(); - await this.page.getByText('Connection tested successfully').waitFor({ state: 'visible' }); - await this.closeCredentialDialog(); - } - - getOauthConnectButton() { - return this.page.getByTestId('oauth-connect-button'); - } - - getOauthConnectSuccessBanner() { - return this.page.getByTestId('oauth-connect-success-banner'); - } - - getSaveButton() { - return this.page.getByTestId('credential-save-button'); - } } diff --git a/packages/testing/playwright/pages/NodeDetailsViewPage.ts b/packages/testing/playwright/pages/NodeDetailsViewPage.ts index 1a99b97468..55b2ae44c4 100644 --- a/packages/testing/playwright/pages/NodeDetailsViewPage.ts +++ b/packages/testing/playwright/pages/NodeDetailsViewPage.ts @@ -18,6 +18,26 @@ export class NodeDetailsViewPage extends BasePage { this.editFields = new EditFieldsNode(page); } + getNodeCredentialsSelect() { + return this.page.getByTestId('node-credentials-select'); + } + + credentialDropdownCreateNewCredential() { + return this.page.getByText('Create new credential'); + } + + getCredentialOptionByText(text: string) { + return this.page.getByText(text); + } + + getCredentialDropdownOptions() { + return this.page.getByRole('option'); + } + + getCredentialSelect() { + return this.page.getByRole('combobox', { name: 'Select Credential' }); + } + async clickBackToCanvasButton() { await this.clickByTestId('back-to-canvas'); } @@ -554,7 +574,7 @@ export class NodeDetailsViewPage extends BasePage { // Credentials modal helpers async clickCreateNewCredential(eq: number = 0): Promise { await this.page.getByTestId('node-credentials-select').nth(eq).click(); - await this.page.getByTestId('node-credentials-select-item-new').click(); + await this.page.getByTestId('node-credentials-select-item-new').nth(eq).click(); } // Run selector and linking helpers diff --git a/packages/testing/playwright/pages/components/CredentialModal.ts b/packages/testing/playwright/pages/components/CredentialModal.ts new file mode 100644 index 0000000000..4e1e21700f --- /dev/null +++ b/packages/testing/playwright/pages/components/CredentialModal.ts @@ -0,0 +1,120 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Credential modal component for canvas and credentials interactions. + * Used within CanvasPage as `n8n.canvas.credentialModal.*` + * Used within CredentialsPage as `n8n.credentials.modal.*` + * + * @example + * // Access via canvas page or credentials page + * await n8n.canvas.credentialModal.addCredential(); + * await expect(n8n.canvas.credentialModal.getModal()).toBeVisible(); + */ +export class CredentialModal { + constructor(private root: Locator) {} + + getModal(): Locator { + return this.root; + } + + getCredentialName(): Locator { + return this.root.getByTestId('credential-name'); + } + + getNameInput(): Locator { + return this.getCredentialName().getByTestId('inline-edit-input'); + } + + async waitForModal(): Promise { + await this.root.waitFor({ state: 'visible' }); + } + + async fillField(key: string, value: string): Promise { + const input = this.root.getByTestId(`parameter-input-${key}`).locator('input, textarea'); + await input.fill(value); + await expect(input).toHaveValue(value); + } + + async fillAllFields(values: Record): Promise { + for (const [key, val] of Object.entries(values)) { + await this.fillField(key, val); + } + } + + getSaveButton(): Locator { + return this.root.getByTestId('credential-save-button'); + } + + async save(): Promise { + const saveBtn = this.getSaveButton(); + await saveBtn.click(); + await saveBtn.waitFor({ state: 'visible' }); + + await saveBtn.getByText('Saved', { exact: true }).waitFor({ state: 'visible', timeout: 3000 }); + } + + async close(): Promise { + const closeBtn = this.root.locator('.el-dialog__close').first(); + if (await closeBtn.isVisible()) { + await closeBtn.click(); + } + } + + /** + * Add a credential to the modal + * @param fields - The fields to fill in the modal + * @param options - The options to pass to the modal + * @param options.closeDialog - Whether to close the modal after saving + * @param options.name - The name of the credential + */ + async addCredential( + fields: Record, + options?: { closeDialog?: boolean; name?: string }, + ): Promise { + await this.fillAllFields(fields); + if (options?.name) { + await this.getCredentialName().click(); + await this.getNameInput().fill(options.name); + } + await this.save(); + const shouldClose = options?.closeDialog ?? true; + if (shouldClose) { + await this.close(); + } + } + + get oauthConnectButton() { + return this.root.getByTestId('oauth-connect-button'); + } + + get oauthConnectSuccessBanner() { + return this.root.getByTestId('oauth-connect-success-banner'); + } + + async editCredential(): Promise { + await this.root.page().getByTestId('credential-edit-button').click(); + } + + async deleteCredential(): Promise { + await this.root.page().getByTestId('credential-delete-button').click(); + } + + async confirmDelete(): Promise { + await this.root.page().getByRole('button', { name: 'Yes' }).click(); + } + + async renameCredential(newName: string): Promise { + await this.getCredentialName().click(); + await this.getNameInput().fill(newName); + await this.getNameInput().press('Enter'); + } + + getAuthMethodSelector() { + return this.root.page().getByText('Select Authentication Method'); + } + + getOAuthRedirectUrl() { + return this.root.page().getByTestId('oauth-redirect-url'); + } +} diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index 24e8737fe4..8437d8a452 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -4,7 +4,6 @@ import { AIAssistantPage } from './AIAssistantPage'; import { BecomeCreatorCTAPage } from './BecomeCreatorCTAPage'; import { CanvasPage } from './CanvasPage'; import { CommunityNodesPage } from './CommunityNodesPage'; -import { CredentialsEditModal } from './CredentialsEditModal'; import { CredentialsPage } from './CredentialsPage'; import { DemoPage } from './DemoPage'; import { ExecutionsPage } from './ExecutionsPage'; @@ -24,6 +23,7 @@ import { WorkflowSettingsModal } from './WorkflowSettingsModal'; import { WorkflowSharingModal } from './WorkflowSharingModal'; import { WorkflowsPage } from './WorkflowsPage'; import { CanvasComposer } from '../composables/CanvasComposer'; +import { CredentialsComposer } from '../composables/CredentialsComposer'; import { ProjectComposer } from '../composables/ProjectComposer'; import { TestEntryComposer } from '../composables/TestEntryComposer'; import { WorkflowComposer } from '../composables/WorkflowComposer'; @@ -60,12 +60,12 @@ export class n8nPage { readonly workflowActivationModal: WorkflowActivationModal; readonly workflowSettingsModal: WorkflowSettingsModal; readonly workflowSharingModal: WorkflowSharingModal; - readonly credentialsModal: CredentialsEditModal; // Composables readonly workflowComposer: WorkflowComposer; readonly projectComposer: ProjectComposer; readonly canvasComposer: CanvasComposer; + readonly credentialsComposer: CredentialsComposer; readonly start: TestEntryComposer; // Helpers @@ -100,12 +100,12 @@ export class n8nPage { // Modals this.workflowActivationModal = new WorkflowActivationModal(page); this.workflowSettingsModal = new WorkflowSettingsModal(page); - this.credentialsModal = new CredentialsEditModal(page); // Composables this.workflowComposer = new WorkflowComposer(this); this.projectComposer = new ProjectComposer(this); this.canvasComposer = new CanvasComposer(this); + this.credentialsComposer = new CredentialsComposer(this); this.start = new TestEntryComposer(this); // Helpers diff --git a/packages/testing/playwright/services/credential-api-helper.ts b/packages/testing/playwright/services/credential-api-helper.ts index ea7b2f277d..65809f19fa 100644 --- a/packages/testing/playwright/services/credential-api-helper.ts +++ b/packages/testing/playwright/services/credential-api-helper.ts @@ -34,6 +34,10 @@ export class CredentialApiHelper { /** * Create a new credential + * + * Notes: + * - The `type` field is the credential type ID (e.g., 'notionApi'), which differs from the UI display name (e.g., 'Notion API'). + * - You can find available credential type IDs in the codebase under `packages/nodes-base/credentials/*.credentials.ts` and by inspecting node credential references (e.g., Notion nodes use `type: 'notionApi'`). */ async createCredential(credential: CreateCredentialDto): Promise { const response = await this.api.request.post('/rest/credentials', { data: credential }); diff --git a/packages/testing/playwright/tests/ui/2-credentials.spec.ts b/packages/testing/playwright/tests/ui/2-credentials.spec.ts new file mode 100644 index 0000000000..1d65440183 --- /dev/null +++ b/packages/testing/playwright/tests/ui/2-credentials.spec.ts @@ -0,0 +1,362 @@ +import { nanoid } from 'nanoid'; + +import { test, expect } from '../../fixtures/base'; + +test.describe('Credentials', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.goHome(); + }); + + test('should create a new credential using empty state', async ({ n8n }) => { + const projectId = await n8n.start.fromNewProject(); + const credentialName = `My awesome Notion account ${nanoid()}`; + + await n8n.credentialsComposer.createFromList( + 'Notion API', + { apiKey: '1234567890' }, + { name: credentialName, projectId }, + ); + + await expect(n8n.credentials.credentialCards).toHaveCount(1); + await expect(n8n.credentials.getCredentialByName(credentialName)).toBeVisible(); + }); + + test('should sort credentials', async ({ n8n, api }) => { + const projectId = await n8n.start.fromNewProject(); + const credentialA = `A Credential ${nanoid()}`; + const credentialZ = `Z Credential ${nanoid()}`; + + await api.credentialApi.createCredential({ + name: credentialA, + type: 'notionApi', + data: { apiKey: '1234567890' }, + projectId, + }); + + await api.credentialApi.createCredential({ + name: credentialZ, + type: 'trelloApi', + data: { apiKey: 'test_api_key', apiToken: 'test_api_token' }, + projectId, + }); + + await n8n.navigate.toCredentials(projectId); + await n8n.credentials.clearSearch(); + await n8n.credentials.sortByNameDescending(); + + const firstCardDescending = n8n.credentials.credentialCards.first(); + await expect(firstCardDescending).toContainText(credentialZ); + + await n8n.credentials.sortByNameAscending(); + + const firstCardAscending = n8n.credentials.credentialCards.first(); + await expect(firstCardAscending).toContainText(credentialA); + }); + + test('should create credentials from NDV for node with multiple auth options', async ({ + n8n, + }) => { + await n8n.start.fromNewProjectBlankCanvas(); + const credentialName = `My Google OAuth2 Account ${nanoid()}`; + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Gmail', { action: 'Send a message' }); + + await n8n.ndv.clickCreateNewCredential(); + + await expect( + n8n.canvas.credentialModal + .getModal() + .getByTestId('node-auth-type-selector') + .locator('label.el-radio'), + ).toHaveCount(2); + + await n8n.canvas.credentialModal + .getModal() + .getByTestId('node-auth-type-selector') + .locator('label.el-radio') + .first() + .click(); + + await n8n.canvas.credentialModal.addCredential( + { + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + }, + { name: credentialName }, + ); + + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); + }); + + test('should show multiple credential types in the same dropdown', async ({ n8n, api }) => { + const projectId = await n8n.start.fromNewProjectBlankCanvas(); + const serviceAccountCredentialName2 = `OAuth2 Credential ${nanoid()}`; + const serviceAccountCredentialName = `Service Account Credential ${nanoid()}`; + + await api.credentialApi.createCredential({ + name: serviceAccountCredentialName2, + type: 'googleApi', + data: { email: 'test@service.com', privateKey: 'test_key' }, + projectId, + }); + + await api.credentialApi.createCredential({ + name: serviceAccountCredentialName, + type: 'googleApi', + data: { email: 'test@service.com', privateKey: 'test_key' }, + projectId, + }); + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Gmail', { action: 'Send a message' }); + + await n8n.ndv.getCredentialSelect().click(); + await expect(n8n.ndv.getCredentialOptionByText(serviceAccountCredentialName2)).toBeVisible(); + await expect(n8n.ndv.getCredentialOptionByText(serviceAccountCredentialName)).toBeVisible(); + await expect(n8n.ndv.credentialDropdownCreateNewCredential()).toBeVisible(); + await expect(n8n.ndv.getCredentialDropdownOptions()).toHaveCount(2); + }); + + test('should correctly render required and optional credentials', async ({ n8n }) => { + await n8n.start.fromNewProjectBlankCanvas(); + + await n8n.canvas.addNode('Pipedrive', { trigger: 'On new Pipedrive event' }); + await n8n.ndv.selectOptionInParameterDropdown('incomingAuthentication', 'Basic Auth'); + await expect(n8n.ndv.getNodeCredentialsSelect()).toHaveCount(2); + + await n8n.ndv.clickCreateNewCredential(0); + await expect( + n8n.canvas.credentialModal + .getModal() + .getByTestId('node-auth-type-selector') + .locator('label.el-radio'), + ).toHaveCount(2); + await n8n.canvas.credentialModal.close(); + + await n8n.ndv.clickCreateNewCredential(1); + await expect(n8n.canvas.credentialModal.getModal()).toBeVisible(); + await expect(n8n.canvas.credentialModal.getAuthMethodSelector()).toBeHidden(); + await n8n.canvas.credentialModal.close(); + }); + + test('should create credentials from NDV for node with no auth options', async ({ n8n }) => { + await n8n.start.fromNewProjectBlankCanvas(); + const credentialName = `My Trello Account ${nanoid()}`; + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Trello', { action: 'Create a card' }); + + await n8n.credentialsComposer.createFromNdv( + { + apiKey: 'test_api_key', + apiToken: 'test_api_token', + }, + { name: credentialName }, + ); + + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); + }); + + test('should delete credentials from NDV', async ({ n8n }) => { + await n8n.start.fromNewProjectBlankCanvas(); + const credentialName = `Notion Credential ${nanoid()}`; + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Notion', { action: 'Append a block' }); + + await n8n.credentialsComposer.createFromNdv({ apiKey: '1234567890' }, { name: credentialName }); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); + + await n8n.canvas.credentialModal.editCredential(); + await n8n.canvas.credentialModal.deleteCredential(); + await n8n.canvas.credentialModal.confirmDelete(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Credential deleted'), + ).toBeVisible(); + + await expect(n8n.ndv.getCredentialSelect()).not.toHaveValue(credentialName); + }); + + test('should rename credentials from NDV', async ({ n8n }) => { + await n8n.start.fromNewProjectBlankCanvas(); + const initialName = `My Trello Account ${nanoid()}`; + const renamedName = `Something else ${nanoid()}`; + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Trello', { action: 'Create a card' }); + + await n8n.credentialsComposer.createFromNdv( + { + apiKey: 'test_api_key', + apiToken: 'test_api_token', + }, + { name: initialName }, + ); + + await n8n.canvas.credentialModal.editCredential(); + await n8n.canvas.credentialModal.renameCredential(renamedName); + await n8n.canvas.credentialModal.save(); + await n8n.canvas.credentialModal.close(); + + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(renamedName); + }); + + test('should edit credential for non-standard credential type', async ({ n8n }) => { + await n8n.start.fromNewProjectBlankCanvas(); + const initialName = `Adalo Credential ${nanoid()}`; + const editedName = `Something else ${nanoid()}`; + + await n8n.canvas.addNode('AI Agent', { closeNDV: true }); + await n8n.canvas.addNode('HTTP Request Tool'); + + await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Predefined Credential Type'); + await n8n.ndv.selectOptionInParameterDropdown('nodeCredentialType', 'Adalo API'); + + await n8n.credentialsComposer.createFromNdv( + { + apiKey: 'test_adalo_key', + appId: 'test_app_id', + }, + { name: initialName }, + ); + + await n8n.canvas.credentialModal.editCredential(); + await n8n.canvas.credentialModal.renameCredential(editedName); + await n8n.canvas.credentialModal.save(); + await n8n.canvas.credentialModal.close(); + + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(editedName); + }); + + test('should set a default credential when adding nodes', async ({ n8n, api }) => { + const projectId = await n8n.start.fromNewProjectBlankCanvas(); + const credentialName = `My awesome Notion account ${nanoid()}`; + + await api.credentialApi.createCredential({ + name: credentialName, + type: 'notionApi', + data: { apiKey: '1234567890' }, + projectId, + }); + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Notion', { action: 'Append a block' }); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); + + const credentials = await api.credentialApi.getCredentials(); + const credential = credentials.find((c) => c.name === credentialName); + await api.credentialApi.deleteCredential(credential!.id); + }); + + test('should set a default credential when editing a node', async ({ n8n, api }) => { + const projectId = await n8n.start.fromNewProjectBlankCanvas(); + const credentialName = `My awesome Notion account ${nanoid()}`; + + await api.credentialApi.createCredential({ + name: credentialName, + type: 'notionApi', + data: { apiKey: '1234567890' }, + projectId, + }); + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('HTTP Request'); + + await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Predefined Credential Type'); + await n8n.ndv.selectOptionInParameterDropdown('nodeCredentialType', 'Notion API'); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); + + const credentials = await api.credentialApi.getCredentials(); + const credential = credentials.find((c) => c.name === credentialName); + await api.credentialApi.deleteCredential(credential!.id); + }); + + test('should setup generic authentication for HTTP node', async ({ n8n }) => { + await n8n.start.fromNewProjectBlankCanvas(); + const credentialName = `Query Auth Credential ${nanoid()}`; + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('HTTP Request'); + + await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Generic Credential Type'); + await n8n.ndv.selectOptionInParameterDropdown('genericAuthType', 'Query Auth'); + + await n8n.credentialsComposer.createFromNdv( + { + name: 'api_key', + value: 'test_query_value', + }, + { name: credentialName }, + ); + + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); + }); + + test('should not show OAuth redirect URL section when OAuth2 credentials are overridden', async ({ + n8n, + page, + }) => { + // Mock credential types response to simulate admin override + await page.route('**/rest/types/credentials.json', async (route) => { + const response = await route.fetch(); + const json = await response.json(); + + // Override Slack OAuth2 credential properties + if (json.slackOAuth2Api) { + json.slackOAuth2Api.__overwrittenProperties = ['clientId', 'clientSecret']; + } + + await route.fulfill({ json }); + }); + + await n8n.start.fromNewProjectBlankCanvas(); + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Slack', { action: 'Get a channel' }); + + await n8n.ndv.clickCreateNewCredential(); + + await n8n.canvas.credentialModal + .getModal() + .getByTestId('node-auth-type-selector') + .locator('label.el-radio') + .first() + .click(); + + await expect(n8n.canvas.credentialModal.getOAuthRedirectUrl()).toBeHidden(); + await expect(n8n.canvas.credentialModal.getModal()).toBeVisible(); + }); + + test('ADO-2583 should show notifications above credential modal overlay', async ({ + n8n, + page, + }) => { + await page.route('**/rest/credentials', async (route) => { + if (route.request().method() === 'POST') { + await route.abort('failed'); + } else { + await route.continue(); + } + }); + + const projectId = await n8n.start.fromNewProject(); + await n8n.navigate.toCredentials(projectId); + await n8n.credentials.addResourceButton.click(); + await n8n.credentials.actionCredentialButton.click(); + await n8n.credentials.selectCredentialType('Notion API'); + await n8n.canvas.credentialModal.fillField('apiKey', '1234567890'); + + const saveBtn = n8n.canvas.credentialModal.getSaveButton(); + await saveBtn.click(); + + const errorNotification = page.locator('.el-notification:has(.el-notification--error)'); + await expect(errorNotification).toBeVisible(); + await expect(n8n.canvas.credentialModal.getModal()).toBeVisible(); + + const modalOverlay = page.locator('.el-overlay').first(); + await expect(errorNotification).toHaveCSS('z-index', '2100'); + await expect(modalOverlay).toHaveCSS('z-index', '2001'); + }); +}); diff --git a/packages/testing/playwright/tests/ui/30-langchain.spec.ts b/packages/testing/playwright/tests/ui/30-langchain.spec.ts index 9e8adc55f5..a32d92c841 100644 --- a/packages/testing/playwright/tests/ui/30-langchain.spec.ts +++ b/packages/testing/playwright/tests/ui/30-langchain.spec.ts @@ -29,8 +29,7 @@ async function addOpenAILanguageModelWithCredentials( options, ); - await n8n.ndv.clickCreateNewCredential(); - await n8n.credentialsModal.setValues({ + await n8n.credentialsComposer.createFromNdv({ apiKey: 'abcd', }); await n8n.ndv.clickBackToCanvasButton(); @@ -351,8 +350,7 @@ test.describe('Langchain Integration @capability:proxy', () => { { closeNDV: false }, ); - await n8n.ndv.clickCreateNewCredential(); - await n8n.credentialsModal.setValues({ + await n8n.credentialsComposer.createFromNdv({ password: 'testtesttest', }); diff --git a/packages/testing/playwright/tests/ui/39-projects.spec.ts b/packages/testing/playwright/tests/ui/39-projects.spec.ts index 3de43743a4..ca8e8e9f21 100644 --- a/packages/testing/playwright/tests/ui/39-projects.spec.ts +++ b/packages/testing/playwright/tests/ui/39-projects.spec.ts @@ -75,8 +75,9 @@ test.describe('Projects', () => { await subn8n.canvas.deleteNodeByName('Replace me with your logic'); await subn8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block' }); - - await subn8n.credentials.createAndSaveNewCredential('apiKey', NOTION_API_KEY); + await subn8n.credentialsComposer.createFromNdv({ + apiKey: NOTION_API_KEY, + }); await subn8n.ndv.clickBackToCanvasButton(); await subn8n.canvas.saveWorkflow(); diff --git a/packages/testing/playwright/tests/ui/43-oauth-flow.spec.ts b/packages/testing/playwright/tests/ui/43-oauth-flow.spec.ts index 1d0c8207ed..7edcb3d24b 100644 --- a/packages/testing/playwright/tests/ui/43-oauth-flow.spec.ts +++ b/packages/testing/playwright/tests/ui/43-oauth-flow.spec.ts @@ -5,13 +5,17 @@ test.describe('OAuth Credentials', () => { const projectId = await n8n.start.fromNewProjectBlankCanvas(); await page.goto(`projects/${projectId}/credentials`); await n8n.credentials.emptyListCreateCredentialButton.click(); - await n8n.credentials.openNewCredentialDialogFromCredentialList('Google OAuth2 API'); - await n8n.credentials.fillCredentialField('clientId', 'test-key'); - await n8n.credentials.fillCredentialField('clientSecret', 'test-secret'); - await n8n.credentials.saveCredential(); + await n8n.credentials.createCredentialFromCredentialPicker( + 'Google OAuth2 API', + { + clientId: 'test-key', + clientSecret: 'test-secret', + }, + { closeDialog: false }, + ); const popupPromise = page.waitForEvent('popup'); - await n8n.credentials.getOauthConnectButton().click(); + await n8n.credentials.credentialModal.oauthConnectButton.click(); const popup = await popupPromise; const popupUrl = popup.url(); @@ -25,7 +29,8 @@ test.describe('OAuth Credentials', () => { channel.postMessage('success'); }); - await expect(n8n.credentials.getSaveButton()).toContainText('Saved'); - await expect(n8n.credentials.getOauthConnectSuccessBanner()).toContainText('Account connected'); + await expect(n8n.credentials.credentialModal.oauthConnectSuccessBanner).toContainText( + 'Account connected', + ); }); }); diff --git a/packages/testing/playwright/tests/ui/5-ndv.spec.ts b/packages/testing/playwright/tests/ui/5-ndv.spec.ts index 010998a17c..f82587d6a4 100644 --- a/packages/testing/playwright/tests/ui/5-ndv.spec.ts +++ b/packages/testing/playwright/tests/ui/5-ndv.spec.ts @@ -486,7 +486,9 @@ test.describe('NDV', () => { await n8n.canvas.addNode('Notion', { action: 'Update a database page', closeNDV: false }); await expect(n8n.ndv.getContainer()).toBeVisible(); - await n8n.credentials.createAndSaveNewCredential('apiKey', 'sk_test_123'); + await n8n.credentialsComposer.createFromNdv({ + apiKey: 'sk_test_123', + }); await n8n.ndv.addItemToFixedCollection('propertiesUi'); await expect( n8n.ndv.getParameterInputWithIssues('propertiesUi.propertyValues[0].key'), @@ -631,8 +633,10 @@ test.describe('NDV', () => { await n8n.canvas.addNode('Manual Trigger'); await n8n.canvas.addNode('Discord', { closeNDV: false, action: 'Delete a message' }); await expect(n8n.ndv.getContainer()).toBeVisible(); + await n8n.credentialsComposer.createFromNdv({ + botToken: 'sk_test_123', + }); - await n8n.credentials.createAndSaveNewCredential('botToken', 'sk_test_123'); const resourceInput = n8n.ndv.getParameterInputField('resource'); const operationInput = n8n.ndv.getParameterInputField('operation'); diff --git a/packages/testing/playwright/tests/ui/50-logs.spec.ts b/packages/testing/playwright/tests/ui/50-logs.spec.ts index 12683f27e7..de78e115f3 100644 --- a/packages/testing/playwright/tests/ui/50-logs.spec.ts +++ b/packages/testing/playwright/tests/ui/50-logs.spec.ts @@ -61,7 +61,7 @@ test.describe('Logs', () => { await n8n.canvas.logsPanel.getClearExecutionButton().click(); await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(0); - await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).not.toBeVisible(); + await expect(n8n.canvas.getNodeIssuesByName(NODES.CODE1)).toBeHidden(); }); test('should allow to trigger partial execution', async ({ n8n, setupRequirements }) => { diff --git a/packages/testing/playwright/tests/ui/716-AI-bug-correctly-set-up-agent-model-shows-error.spec.ts b/packages/testing/playwright/tests/ui/716-AI-bug-correctly-set-up-agent-model-shows-error.spec.ts index 7f8231867c..7f6189a2cd 100644 --- a/packages/testing/playwright/tests/ui/716-AI-bug-correctly-set-up-agent-model-shows-error.spec.ts +++ b/packages/testing/playwright/tests/ui/716-AI-bug-correctly-set-up-agent-model-shows-error.spec.ts @@ -11,7 +11,9 @@ test.describe('AI-716 Correctly set up agent model shows error', () => { await n8n.canvas.addNode('OpenAI Chat Model'); - await n8n.credentials.createAndSaveNewCredential('apiKey', 'sk-123'); + await n8n.credentialsComposer.createFromNdv({ + apiKey: 'sk-123', + }); await n8n.page.keyboard.press('Escape'); diff --git a/packages/testing/playwright/tests/ui/building-blocks/04-credentials.spec.ts b/packages/testing/playwright/tests/ui/building-blocks/04-credentials.spec.ts new file mode 100644 index 0000000000..b0c6dde2ab --- /dev/null +++ b/packages/testing/playwright/tests/ui/building-blocks/04-credentials.spec.ts @@ -0,0 +1,93 @@ +import { nanoid } from 'nanoid'; + +import { test, expect } from '../../../fixtures/base'; + +test.describe('04 - Credentials', () => { + test('composer: createFromList creates credential', async ({ n8n }) => { + const projectId = await n8n.start.fromNewProject(); + const credentialName = `credential-${nanoid()}`; + await n8n.navigate.toCredentials(projectId); + + await n8n.credentialsComposer.createFromList( + 'Notion API', + { apiKey: '1234567890' }, + { + name: credentialName, + closeDialog: false, + }, + ); + await expect(n8n.credentials.getCredentialByName(credentialName)).toBeVisible(); + }); + + test('composer: createFromNdv creates credential for node', async ({ n8n }) => { + const name = `credential-${nanoid()}`; + await n8n.start.fromNewProjectBlankCanvas(); + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Notion', { action: 'Append a block' }); + + await n8n.credentialsComposer.createFromNdv({ apiKey: '1234567890' }, { name }); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(name); + }); + + test('composer: createFromApi creates credential (then NDV picks it up)', async ({ n8n }) => { + const name = `credential-${nanoid()}`; + const projectId = await n8n.start.fromNewProjectBlankCanvas(); + await n8n.credentialsComposer.createFromApi({ + name, + type: 'notionApi', + data: { apiKey: '1234567890' }, + projectId, + }); + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Notion', { action: 'Append a block' }); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(name); + }); + + test('create a new credential from empty state using the credential chooser list', async ({ + n8n, + }) => { + const projectId = await n8n.start.fromNewProject(); + await n8n.navigate.toCredentials(projectId); + await n8n.credentials.emptyListCreateCredentialButton.click(); + await n8n.credentials.createCredentialFromCredentialPicker('Notion API', { + apiKey: '1234567890', + }); + await expect(n8n.credentials.credentialCards).toHaveCount(1); + }); + + test('create a new credential from the NDV', async ({ n8n }) => { + const uniqueCredentialName = `credential-${nanoid()}`; + await n8n.start.fromNewProjectBlankCanvas(); + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Notion', { action: 'Append a block' }); + + await n8n.ndv.getNodeCredentialsSelect().click(); + await n8n.ndv.credentialDropdownCreateNewCredential().click(); + await n8n.canvas.credentialModal.addCredential( + { + apiKey: '1234567890', + }, + { name: uniqueCredentialName }, + ); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(uniqueCredentialName); + }); + + test('add an existing credential from the NDV', async ({ n8n, api }) => { + const uniqueCredentialName = `credential-${nanoid()}`; + const projectId = await n8n.start.fromNewProjectBlankCanvas(); + + await api.credentialApi.createCredential({ + name: uniqueCredentialName, + type: 'notionApi', + data: { + apiKey: '1234567890', + }, + projectId, + }); + + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('Notion', { action: 'Append a block' }); + await expect(n8n.ndv.getCredentialSelect()).toHaveValue(uniqueCredentialName); + }); +});