From f7d225e871858468f8b154691d05f60fd16aa60e Mon Sep 17 00:00:00 2001 From: shortstacked Date: Mon, 8 Sep 2025 12:46:08 +0100 Subject: [PATCH] test: Migrate 3 specs from Cypress - Playwright (#19269) --- cypress/e2e/21-community-nodes.cy.ts | 216 ------------------ cypress/e2e/23-variables.cy.ts | 124 ---------- cypress/e2e/25-stickies.cy.ts | 64 ------ cypress/e2e/8-http-request-node.cy.ts | 59 ----- .../composables/TestEntryComposer.ts | 18 +- .../playwright/helpers/NavigationHelper.ts | 216 ++++++++++++++++++ .../testing/playwright/pages/CanvasPage.ts | 4 + .../playwright/pages/CommunityNodesPage.ts | 121 ++++++++++ .../playwright/pages/NodeDetailsViewPage.ts | 3 + .../testing/playwright/pages/VariablesPage.ts | 96 ++++++++ packages/testing/playwright/pages/n8nPage.ts | 13 ++ .../testing/playwright/services/api-helper.ts | 3 + .../services/variables-api-helper.ts | 123 ++++++++++ .../tests/performance/perf-examples.spec.ts | 2 +- .../tests/ui/21-community-nodes.spec.ts | 169 ++++++++++++++ .../playwright/tests/ui/23-variables.spec.ts | 163 +++++++++++++ .../playwright/tests/ui/43-oauth-flow.spec.ts | 2 +- .../playwright/tests/ui/50-logs.spec.ts | 4 +- .../tests/ui/8-http-request-node.spec.ts | 36 +++ .../01-workflow-entry-points.spec.ts | 2 +- .../workflows/Custom_credential.json | 21 ++ .../playwright/workflows/Custom_node.json | 51 +++++ .../Custom_node_custom_credential.json | 57 +++++ .../workflows/Custom_node_n8n_credential.json | 57 +++++ 24 files changed, 1154 insertions(+), 470 deletions(-) delete mode 100644 cypress/e2e/21-community-nodes.cy.ts delete mode 100644 cypress/e2e/23-variables.cy.ts delete mode 100644 cypress/e2e/25-stickies.cy.ts delete mode 100644 cypress/e2e/8-http-request-node.cy.ts create mode 100644 packages/testing/playwright/helpers/NavigationHelper.ts create mode 100644 packages/testing/playwright/pages/CommunityNodesPage.ts create mode 100644 packages/testing/playwright/pages/VariablesPage.ts create mode 100644 packages/testing/playwright/services/variables-api-helper.ts create mode 100644 packages/testing/playwright/tests/ui/21-community-nodes.spec.ts create mode 100644 packages/testing/playwright/tests/ui/23-variables.spec.ts create mode 100644 packages/testing/playwright/tests/ui/8-http-request-node.spec.ts create mode 100644 packages/testing/playwright/workflows/Custom_credential.json create mode 100644 packages/testing/playwright/workflows/Custom_node.json create mode 100644 packages/testing/playwright/workflows/Custom_node_custom_credential.json create mode 100644 packages/testing/playwright/workflows/Custom_node_n8n_credential.json diff --git a/cypress/e2e/21-community-nodes.cy.ts b/cypress/e2e/21-community-nodes.cy.ts deleted file mode 100644 index df534b5bf4..0000000000 --- a/cypress/e2e/21-community-nodes.cy.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { ICredentialType } from 'n8n-workflow'; - -import CustomCredential from '../fixtures/Custom_credential.json'; -import CustomNodeFixture from '../fixtures/Custom_node.json'; -import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json'; -import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json'; -import { CredentialsModal, WorkflowPage } from '../pages'; -import { NodeCreator } from '../pages/features/node-creator'; -import { - confirmCommunityNodeUninstall, - confirmCommunityNodeUpdate, - getCommunityCards, - installFirstCommunityNode, - visitCommunityNodesSettings, -} from '../pages/settings-community-nodes'; -import { getVisibleSelect } from '../utils'; - -const credentialsModal = new CredentialsModal(); -const nodeCreatorFeature = new NodeCreator(); -const workflowPage = new WorkflowPage(); -const ADD_TO_WORKFLOW_BUTTON = 'Add to workflow'; - -const addCommunityNodeToCanvas = (name: string) => { - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type(name); - - nodeCreatorFeature.getters.getCreatorItem(name).find('.el-tooltip__trigger').should('exist'); - nodeCreatorFeature.actions.selectNode(name); - - cy.contains('span', name).should('be.visible'); - cy.contains(ADD_TO_WORKFLOW_BUTTON).should('be.visible').click(); -}; - -// We separate-out the custom nodes because they require injecting nodes and credentials -// so the /nodes and /credentials endpoints are intercepted and non-cached. -// We want to keep the other tests as fast as possible so we don't want to break the cache in those. -describe('Community and custom nodes in canvas', () => { - beforeEach(() => { - cy.intercept('/types/nodes.json', { middleware: true }, (req) => { - req.headers['cache-control'] = 'no-cache, no-store'; - - req.on('response', (res) => { - const nodes = res.body || []; - - nodes.push( - CustomNodeFixture, - CustomNodeWithN8nCredentialFixture, - CustomNodeWithCustomCredentialFixture, - ); - }); - }); - - 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 || []; - - credentials.push(CustomCredential as ICredentialType); - }); - }); - - // next intercepts are not strictly needed, but they make the tests faster - // - intercept request to vetted community types, returning empty list - // - intercept request to vetted community type details, return null - // - intercept request npm registry, return 404 - // -------------------------------------------------------------------------- - cy.intercept('/community-node-types', (req) => { - req.reply({ - statusCode: 200, - body: { - data: [], - }, - }); - }); - - cy.intercept('GET', '/community-node-types/*', { - statusCode: 200, - body: null, - }); - - cy.intercept('GET', 'https://registry.npmjs.org/*', { - statusCode: 404, - body: {}, - }); - // -------------------------------------------------------------------------- - - workflowPage.actions.visit(); - }); - - it('should render and select community node', () => { - addCommunityNodeToCanvas('E2E Node'); - - const nodeParameters = () => cy.getByTestId('node-parameters'); - const firstParameter = () => nodeParameters().find('.parameter-item').eq(0); - const secondParameter = () => nodeParameters().find('.parameter-item').eq(1); - - // Check correct fields are rendered - nodeParameters().should('exist'); - // Test property text input - firstParameter().contains('Test property').should('exist'); - firstParameter().find('input.el-input__inner').should('have.value', 'Some default'); - // Resource select input - secondParameter().find('label').contains('Resource').should('exist'); - secondParameter().find('input.el-input__inner').should('have.value', 'option2'); - secondParameter().find('.el-select').click(); - // Check if all options are rendered and select the fourth one - getVisibleSelect().find('li').should('have.length', 4); - getVisibleSelect().find('li').eq(3).contains('option4').should('exist').click(); - secondParameter().find('input.el-input__inner').should('have.value', 'option4'); - }); - - it('should render custom node with n8n credential', () => { - workflowPage.actions.addNodeToCanvas('Manual'); - addCommunityNodeToCanvas('E2E Node with native n8n credential'); - workflowPage.getters.nodeCredentialsLabel().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.editCredentialModal().should('be.visible'); - credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API'); - }); - - it('should render custom node with custom credential', () => { - workflowPage.actions.addNodeToCanvas('Manual'); - addCommunityNodeToCanvas('E2E Node with custom credential'); - workflowPage.getters.nodeCredentialsLabel().click(); - workflowPage.getters.nodeCredentialsCreateOption().click(); - credentialsModal.getters.editCredentialModal().should('be.visible'); - credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential'); - }); -}); - -describe('Community nodes', () => { - const mockPackage = { - createdAt: '2024-07-22T19:08:06.505Z', - updatedAt: '2024-07-22T19:08:06.505Z', - packageName: 'n8n-nodes-chatwork', - installedVersion: '1.0.0', - authorName: null, - authorEmail: null, - installedNodes: [ - { - name: 'Chatwork', - type: 'n8n-nodes-chatwork.chatwork', - latestVersion: 1, - }, - ], - updateAvailable: '1.1.2', - }; - - it('can install, update and uninstall community nodes', () => { - cy.intercept( - { - hostname: 'api.npms.io', - pathname: '/v2/search', - query: { q: 'keywords:n8n-community-node-package' }, - }, - { body: {} }, - ); - cy.intercept( - { method: 'GET', pathname: '/rest/community-packages', times: 1 }, - { - body: { data: [] }, - }, - ).as('getEmptyPackages'); - visitCommunityNodesSettings(); - cy.wait('@getEmptyPackages'); - - // install a package - cy.intercept( - { method: 'POST', pathname: '/rest/community-packages', times: 1 }, - { - body: { data: mockPackage }, - }, - ).as('installPackage'); - cy.intercept( - { method: 'GET', pathname: '/rest/community-packages', times: 1 }, - { - body: { data: [mockPackage] }, - }, - ).as('getPackages'); - installFirstCommunityNode('n8n-nodes-chatwork@1.0.0'); - cy.wait('@installPackage'); - cy.wait('@getPackages'); - getCommunityCards().should('have.length', 1); - getCommunityCards().eq(0).should('include.text', 'v1.0.0'); - - // update the package - cy.intercept( - { method: 'PATCH', pathname: '/rest/community-packages' }, - { - body: { data: { ...mockPackage, installedVersion: '1.2.0', updateAvailable: undefined } }, - }, - ).as('updatePackage'); - getCommunityCards().eq(0).find('button').click(); - confirmCommunityNodeUpdate(); - cy.wait('@updatePackage'); - getCommunityCards().should('have.length', 1); - getCommunityCards().eq(0).should('not.include.text', 'v1.0.0'); - - // uninstall the package - cy.intercept( - { - method: 'DELETE', - pathname: '/rest/community-packages', - query: { name: 'n8n-nodes-chatwork' }, - }, - { statusCode: 204 }, - ).as('uninstallPackage'); - getCommunityCards().getByTestId('action-toggle').click(); - cy.getByTestId('action-uninstall').click(); - confirmCommunityNodeUninstall(); - cy.wait('@uninstallPackage'); - - cy.getByTestId('action-box').should('exist'); - }); -}); diff --git a/cypress/e2e/23-variables.cy.ts b/cypress/e2e/23-variables.cy.ts deleted file mode 100644 index e3d67ab987..0000000000 --- a/cypress/e2e/23-variables.cy.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { VariablesPage } from '../pages/variables'; - -const variablesPage = new VariablesPage(); - -describe('Variables', () => { - it('should show the unlicensed action box when the feature is disabled', () => { - cy.disableFeature('variables'); - cy.visit(variablesPage.url); - - variablesPage.getters.unavailableResourcesList().should('be.visible'); - variablesPage.getters.resourcesList().should('not.exist'); - }); - - describe('licensed', () => { - before(() => { - cy.enableFeature('variables'); - }); - - beforeEach(() => { - cy.intercept('GET', '/rest/variables').as('loadVariables'); - cy.intercept('GET', '/rest/login').as('login'); - - cy.visit(variablesPage.url); - cy.wait(['@loadVariables', '@loadSettings', '@login']); - }); - - it('should show the licensed action box when the feature is enabled', () => { - variablesPage.getters.emptyResourcesList().should('be.visible'); - variablesPage.getters.emptyResourcesListNewVariableButton().should('be.visible'); - }); - - it('should create a new variable using empty state row', () => { - const key = 'ENV_VAR'; - const value = 'value'; - - variablesPage.actions.createVariableFromEmptyState(key, value); - variablesPage.getters.variableRow(key).should('contain', value).should('be.visible'); - variablesPage.getters.variablesRows().should('have.length', 1); - }); - - it('should create a new variable using pre-existing state', () => { - const key = 'ENV_VAR_NEW'; - const value = 'value2'; - - variablesPage.actions.createVariable(key, value); - variablesPage.getters.variableRow(key).should('contain', value).should('be.visible'); - variablesPage.getters.variablesRows().should('have.length', 2); - - const otherKey = 'ENV_EXAMPLE'; - const otherValue = 'value3'; - - variablesPage.actions.createVariable(otherKey, otherValue); - variablesPage.getters - .variableRow(otherKey) - .should('contain', otherValue) - .should('be.visible'); - variablesPage.getters.variablesRows().should('have.length', 3); - }); - - it('should get validation errors and cancel variable creation', () => { - const key = 'ENV_VAR_NEW$'; - const value = 'value3'; - - variablesPage.getters.createVariableButton().click(); - const editingRow = variablesPage.getters.variablesEditableRows().eq(0); - variablesPage.actions.setRowValue(editingRow, 'key', key); - variablesPage.actions.setRowValue(editingRow, 'value', value); - variablesPage.actions.saveRowEditing(editingRow); - variablesPage.getters - .variablesEditableRows() - .eq(0) - .should('contain', 'This field may contain only letters'); - variablesPage.actions.cancelRowEditing(editingRow); - - variablesPage.getters.variablesRows().should('have.length', 3); - }); - - it('should edit a variable', () => { - const key = 'ENV_VAR_NEW'; - const newValue = 'value4'; - - variablesPage.actions.editRow(key); - const editingRow = variablesPage.getters.variablesEditableRows().eq(0); - variablesPage.actions.setRowValue(editingRow, 'value', newValue); - variablesPage.actions.saveRowEditing(editingRow); - - variablesPage.getters.variableRow(key).should('contain', newValue).should('be.visible'); - variablesPage.getters.variablesRows().should('have.length', 3); - }); - - it('should delete a variable', () => { - const key = 'TO_DELETE'; - const value = 'xxx'; - - variablesPage.actions.createVariable(key, value); - variablesPage.actions.deleteVariable(key); - }); - - it('should search for a variable', () => { - // One Result - variablesPage.getters.searchBar().type('NEW'); - variablesPage.getters.variablesRows().should('have.length', 1); - variablesPage.getters.variableRow('NEW').should('contain.text', 'ENV_VAR_NEW'); - cy.url().should('include', 'search=NEW'); - - // Multiple Results - variablesPage.getters.searchBar().clear().type('ENV_VAR'); - variablesPage.getters.variablesRows().should('have.length', 2); - cy.url().should('include', 'search=ENV_VAR'); - - // All Results - variablesPage.getters.searchBar().clear().type('ENV'); - variablesPage.getters.variablesRows().should('have.length', 3); - cy.url().should('include', 'search=ENV'); - - // No Results - variablesPage.getters.searchBar().clear().type('Some non-existent variable'); - variablesPage.getters.variablesRows().should('not.exist'); - cy.url().should('include', 'search=Some+non-existent+variable'); - - cy.contains('No variables found').should('be.visible'); - }); - }); -}); diff --git a/cypress/e2e/25-stickies.cy.ts b/cypress/e2e/25-stickies.cy.ts deleted file mode 100644 index 4e0e49ae2b..0000000000 --- a/cypress/e2e/25-stickies.cy.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { META_KEY } from '../constants'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; - -const workflowPage = new WorkflowPageClass(); - -describe('Canvas Actions', () => { - beforeEach(() => { - workflowPage.actions.visit(); - cy.get('#collapse-change-button').should('be.visible').click(); - cy.get('#side-menu[class*=collapsed i]').should('be.visible'); - workflowPage.actions.zoomToFit(); - }); - - it('adds sticky to canvas with default text and position', () => { - workflowPage.getters.addStickyButton().should('be.visible'); - - addDefaultSticky(); - workflowPage.actions.deselectAll(); - workflowPage.actions.addStickyFromContextMenu(); - workflowPage.actions.hitAddSticky(); - - workflowPage.getters.stickies().should('have.length', 3); - - // Should not add a sticky for ctrl+shift+s - cy.get('body').type(`{${META_KEY}+shift+s}`); - - workflowPage.getters.stickies().should('have.length', 3); - workflowPage.getters - .stickies() - .eq(0) - .should('have.text', 'I’m a note\nDouble click to edit me. Guide\n') - .find('a') - .contains('Guide') - .should('have.attr', 'href'); - }); -}); - -function shouldHaveOneSticky() { - workflowPage.getters.stickies().should('have.length', 1); -} - -function shouldBeInDefaultLocation() { - workflowPage.getters - .stickies() - .eq(0) - .should(($el) => { - expect($el).to.have.css('height', '160px'); - expect($el).to.have.css('width', '240px'); - }); -} - -function shouldHaveDefaultSize() { - workflowPage.getters.stickies().should(($el) => { - expect($el).to.have.css('height', '160px'); - expect($el).to.have.css('width', '240px'); - }); -} - -function addDefaultSticky() { - workflowPage.actions.addSticky(); - shouldHaveOneSticky(); - shouldHaveDefaultSize(); - shouldBeInDefaultLocation(); -} diff --git a/cypress/e2e/8-http-request-node.cy.ts b/cypress/e2e/8-http-request-node.cy.ts deleted file mode 100644 index 1567815ddf..0000000000 --- a/cypress/e2e/8-http-request-node.cy.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { WorkflowPage, NDV } from '../pages'; -import { NodeCreator } from '../pages/features/node-creator'; - -const workflowPage = new WorkflowPage(); -const nodeCreatorFeature = new NodeCreator(); -const ndv = new NDV(); - -describe('HTTP Request node', () => { - beforeEach(() => { - workflowPage.actions.visit(); - }); - - it('should make a request with a URL and receive a response', () => { - workflowPage.actions.addInitialNodeToCanvas('Manual'); - workflowPage.actions.addNodeToCanvas('HTTP Request'); - workflowPage.actions.openNode('HTTP Request'); - ndv.actions.typeIntoParameterInput('url', 'https://catfact.ninja/fact'); - - ndv.actions.execute(); - - ndv.getters.outputPanel().contains('fact'); - }); - - describe('Credential-only HTTP Request Node variants', () => { - it('should render a modified HTTP Request Node', () => { - workflowPage.actions.addInitialNodeToCanvas('Manual'); - - workflowPage.getters.nodeCreatorPlusButton().click(); - workflowPage.getters.nodeCreatorSearchBar().type('VirusTotal'); - - expect(nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'VirusTotal')); - expect( - nodeCreatorFeature.getters - .nodeItemDescription() - .first() - .should('have.text', 'HTTP request'), - ); - - nodeCreatorFeature.actions.selectNode('VirusTotal'); - expect(ndv.getters.nodeNameContainer().should('contain.text', 'VirusTotal HTTP Request')); - expect( - ndv.getters - .parameterInput('url') - .find('input') - .should('contain.value', 'https://www.virustotal.com/api/v3/'), - ); - - // These parameters exist for normal HTTP Request Node, but are hidden for credential-only variants - expect(ndv.getters.parameterInput('authentication').should('not.exist')); - expect(ndv.getters.parameterInput('nodeCredentialType').should('not.exist')); - - expect( - workflowPage.getters - .nodeCredentialsLabel() - .should('contain.text', 'Credential for VirusTotal'), - ); - }); - }); -}); diff --git a/packages/testing/playwright/composables/TestEntryComposer.ts b/packages/testing/playwright/composables/TestEntryComposer.ts index 27eea670c0..f5ae83bf2e 100644 --- a/packages/testing/playwright/composables/TestEntryComposer.ts +++ b/packages/testing/playwright/composables/TestEntryComposer.ts @@ -26,9 +26,9 @@ export class TestEntryComposer { } /** - * Start UI test from a workflow in a new project + * Start UI test from a workflow in a new project on a new canvas */ - async fromNewProject() { + async fromNewProjectBlankCanvas() { // Enable features to allow us to create a new project await this.n8n.api.enableFeature('projectRole:admin'); await this.n8n.api.enableFeature('projectRole:editor'); @@ -43,6 +43,20 @@ export class TestEntryComposer { return projectId; } + async fromNewProject() { + // Enable features to allow us to create a new project + await this.n8n.api.enableFeature('projectRole:admin'); + await this.n8n.api.enableFeature('projectRole:editor'); + await this.n8n.api.setMaxTeamProjectsQuota(-1); + + // Create a project using the API + const response = await this.n8n.api.projectApi.createProject(); + + const projectId = response.id; + await this.n8n.navigate.toProject(projectId); + return projectId; + } + /** * Start UI test from the canvas of an imported workflow * Returns the workflow import result for use in the test diff --git a/packages/testing/playwright/helpers/NavigationHelper.ts b/packages/testing/playwright/helpers/NavigationHelper.ts new file mode 100644 index 0000000000..2269783026 --- /dev/null +++ b/packages/testing/playwright/helpers/NavigationHelper.ts @@ -0,0 +1,216 @@ +import type { Page } from '@playwright/test'; + +/** + * NavigationHelper provides centralized navigation methods for all n8n routes. + * Handles both project-specific and global routes with proper URL construction. + * + * URLs are documented to help users understand where they're navigating: + * - Home workflows: /home/workflows + * - Project workflows: /projects/{projectId}/workflows + * - Variables: /variables (global only, no project scope) + * - Settings: /settings (global only) + * - Credentials: /home/credentials or /projects/{projectId}/credentials + * - Executions: /home/executions or /projects/{projectId}/executions + */ +export class NavigationHelper { + constructor(private page: Page) {} + + /** + * Navigate to the home dashboard + * URL: /home + */ + async toHome(): Promise { + await this.page.goto('/home'); + } + + /** + * Navigate to workflows page + * URLs: + * - Home workflows: /home/workflows + * - Project workflows: /projects/{projectId}/workflows + */ + async toWorkflows(projectId?: string): Promise { + const url = projectId ? `/projects/${projectId}/workflows` : '/home/workflows'; + await this.page.goto(url); + } + + /** + * Navigate to credentials page + * URLs: + * - Home credentials: /home/credentials + * - Project credentials: /projects/{projectId}/credentials + */ + async toCredentials(projectId?: string): Promise { + const url = projectId ? `/projects/${projectId}/credentials` : '/home/credentials'; + await this.page.goto(url); + } + + /** + * Navigate to executions page + * URLs: + * - Home executions: /home/executions + * - Project executions: /projects/{projectId}/executions + */ + async toExecutions(projectId?: string): Promise { + const url = projectId ? `/projects/${projectId}/executions` : '/home/executions'; + await this.page.goto(url); + } + + /** + * Navigate to variables page (global only) + * URL: /variables + * Note: Variables are global and don't have project-specific scoping + */ + async toVariables(): Promise { + await this.page.goto('/variables'); + } + + /** + * Navigate to settings page (global only) + * URL: /settings + */ + async toSettings(): Promise { + await this.page.goto('/settings'); + } + + /** + * Navigate to personal settings + * URL: /settings/personal + */ + async toPersonalSettings(): Promise { + await this.page.goto('/settings/personal'); + } + + /** + * Navigate to projects page + * URL: /projects + */ + async toProjects(): Promise { + await this.page.goto('/projects'); + } + + /** + * Navigate to a specific project's dashboard + * URL: /projects/{projectId} + */ + async toProject(projectId: string): Promise { + await this.page.goto(`/projects/${projectId}`); + } + + /** + * Navigate to project settings + * URL: /projects/{projectId}/settings + */ + async toProjectSettings(projectId: string): Promise { + await this.page.goto(`/projects/${projectId}/settings`); + } + + /** + * Navigate to a specific workflow + * URLs: + * - New workflow: /workflow/new + * - Existing workflow: /workflow/{workflowId} + * - Project workflow: /projects/{projectId}/workflow/{workflowId} + */ + async toWorkflow(workflowId: string = 'new', projectId?: string): Promise { + const url = projectId + ? `/projects/${projectId}/workflow/${workflowId}` + : `/workflow/${workflowId}`; + await this.page.goto(url); + } + + /** + * Navigate to workflow canvas (alias for toWorkflow) + */ + async toCanvas(workflowId: string = 'new', projectId?: string): Promise { + await this.toWorkflow(workflowId, projectId); + } + + /** + * Navigate to templates page + * URL: /templates + */ + async toTemplates(): Promise { + await this.page.goto('/templates'); + } + + /** + * Navigate to a specific template + * URL: /templates/{templateId} + */ + async toTemplate(templateId: string): Promise { + await this.page.goto(`/templates/${templateId}`); + } + + /** + * Navigate to community nodes + * URL: /settings/community-nodes + */ + async toCommunityNodes(): Promise { + await this.page.goto('/settings/community-nodes'); + } + + /** + * Navigate to log streaming settings + * URL: /settings/log-streaming + */ + async toLogStreaming(): Promise { + await this.page.goto('/settings/log-streaming'); + } + + /** + * Navigate to worker view + * URL: /settings/workers + */ + async toWorkerView(): Promise { + await this.page.goto('/settings/workers'); + } + + /** + * Navigate to users management + * URL: /settings/users + */ + async toUsers(): Promise { + await this.page.goto('/settings/users'); + } + + /** + * Navigate to API settings + * URL: /settings/api + */ + async toApiSettings(): Promise { + await this.page.goto('/settings/api'); + } + + /** + * Navigate to LDAP settings + * URL: /settings/ldap + */ + async toLdapSettings(): Promise { + await this.page.goto('/settings/ldap'); + } + + /** + * Navigate to SSO settings + * URL: /settings/sso + */ + async toSsoSettings(): Promise { + await this.page.goto('/settings/sso'); + } + + /** + * Navigate to source control settings + * URL: /settings/source-control + */ + async toSourceControl(): Promise { + await this.page.goto('/settings/source-control'); + } + + /** + * Navigate to external secrets settings + * URL: /settings/external-secrets + */ + async toExternalSecrets(): Promise { + await this.page.goto('/settings/external-secrets'); + } +} diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index be6685f8a2..6a48b8cb13 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -76,6 +76,10 @@ export class CanvasPage extends BasePage { await this.nodeCreatorItemByName(text).click(); } + async clickAddToWorkflowButton(): Promise { + await this.page.getByText('Add to workflow').click(); + } + /** * Add a node to the canvas with flexible options * @param nodeName - The name of the node to search for and add diff --git a/packages/testing/playwright/pages/CommunityNodesPage.ts b/packages/testing/playwright/pages/CommunityNodesPage.ts new file mode 100644 index 0000000000..4ad5442ec5 --- /dev/null +++ b/packages/testing/playwright/pages/CommunityNodesPage.ts @@ -0,0 +1,121 @@ +import type { Locator } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +export class CommunityNodesPage extends BasePage { + // Element getters + getCommunityCards(): Locator { + return this.page.getByTestId('community-package-card'); + } + + getActionBox(): Locator { + return this.page.getByTestId('action-box'); + } + + getInstallButton(): Locator { + // Try action box first (empty state), fallback to header install button + const actionBoxButton = this.getActionBox().locator('button'); + const headerInstallButton = this.page.getByRole('button', { name: 'Install' }); + + return actionBoxButton.or(headerInstallButton); + } + + getInstallModal(): Locator { + return this.page.getByTestId('communityPackageInstall-modal'); + } + + getConfirmModal(): Locator { + return this.page.getByTestId('communityPackageManageConfirm-modal'); + } + + getPackageNameInput(): Locator { + return this.getInstallModal().locator('input').first(); + } + + getUserAgreementCheckbox(): Locator { + return this.page.getByTestId('user-agreement-checkbox'); + } + + getInstallPackageButton(): Locator { + return this.page.getByTestId('install-community-package-button'); + } + + getActionToggle(): Locator { + return this.page.getByTestId('action-toggle'); + } + + getUninstallAction(): Locator { + return this.page.getByTestId('action-uninstall'); + } + + getUpdateButton(): Locator { + return this.getCommunityCards().first().locator('button'); + } + + getConfirmUpdateButton(): Locator { + return this.getConfirmModal().getByRole('button', { name: 'Confirm update' }); + } + + getConfirmUninstallButton(): Locator { + return this.getConfirmModal().getByRole('button', { name: 'Confirm uninstall' }); + } + + // Simple actions + async clickInstallButton(): Promise { + await this.getInstallButton().click(); + } + + async fillPackageName(packageName: string): Promise { + await this.getPackageNameInput().fill(packageName); + } + + async clickUserAgreementCheckbox(): Promise { + await this.getUserAgreementCheckbox().click(); + } + + async clickInstallPackageButton(): Promise { + await this.getInstallPackageButton().click(); + } + + async clickActionToggle(): Promise { + await this.getActionToggle().click(); + } + + async clickUninstallAction(): Promise { + await this.getUninstallAction().click(); + } + + async clickUpdateButton(): Promise { + await this.getUpdateButton().click(); + } + + async clickConfirmUpdate(): Promise { + await this.getConfirmUpdateButton().click(); + } + + async clickConfirmUninstall(): Promise { + await this.getConfirmUninstallButton().click(); + } + + // Helper methods for common workflows + async installPackage(packageName: string): Promise { + await this.clickInstallButton(); + await this.fillPackageName(packageName); + await this.clickUserAgreementCheckbox(); + await this.clickInstallPackageButton(); + + // Wait for install modal to close + await this.getInstallModal().waitFor({ state: 'hidden' }); + } + + async updatePackage(): Promise { + await this.clickUpdateButton(); + await this.clickConfirmUpdate(); + } + + async uninstallPackage(): Promise { + await this.clickActionToggle(); + await this.clickUninstallAction(); + await this.clickConfirmUninstall(); + } +} diff --git a/packages/testing/playwright/pages/NodeDetailsViewPage.ts b/packages/testing/playwright/pages/NodeDetailsViewPage.ts index 72154a4be1..19f88f4924 100644 --- a/packages/testing/playwright/pages/NodeDetailsViewPage.ts +++ b/packages/testing/playwright/pages/NodeDetailsViewPage.ts @@ -725,4 +725,7 @@ export class NodeDetailsViewPage extends BasePage { getInputSelect() { return this.page.getByTestId('ndv-input-select').locator('input'); } + getCredentialLabel(credentialType: string) { + return this.page.getByText(credentialType); + } } diff --git a/packages/testing/playwright/pages/VariablesPage.ts b/packages/testing/playwright/pages/VariablesPage.ts new file mode 100644 index 0000000000..df44aeb774 --- /dev/null +++ b/packages/testing/playwright/pages/VariablesPage.ts @@ -0,0 +1,96 @@ +import { expect, type Locator } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +export class VariablesPage extends BasePage { + getUnavailableResourcesList() { + return this.page.getByTestId('unavailable-resources-list'); + } + + getResourcesList() { + return this.page.getByTestId('resources-list'); + } + + getEmptyResourcesList() { + return this.page.getByTestId('empty-resources-list'); + } + + getEmptyResourcesListNewVariableButton() { + return this.getEmptyResourcesList().locator('button'); + } + + getSearchBar() { + return this.page.getByTestId('resources-list-search'); + } + + getCreateVariableButton() { + return this.page.getByTestId('resources-list-add'); + } + + getVariablesRows() { + return this.page.getByTestId('variables-row'); + } + + getVariablesEditableRows() { + return this.page.getByTestId('variables-row').filter({ has: this.page.locator('input') }); + } + + getVariableRow(key: string) { + return this.getVariablesRows().filter({ hasText: key }); + } + + getEditableRowCancelButton(row: Locator) { + return row.getByTestId('variable-row-cancel-button'); + } + + getEditableRowSaveButton(row: Locator) { + return row.getByTestId('variable-row-save-button'); + } + + async createVariable(key: string, value: string) { + await this.getCreateVariableButton().click(); + + const editingRow = this.getVariablesEditableRows().first(); + await this.setRowValue(editingRow, 'key', key); + await this.setRowValue(editingRow, 'value', value); + await this.saveRowEditing(editingRow); + } + + async createVariableFromEmptyState(key: string, value: string) { + await this.getEmptyResourcesListNewVariableButton().click(); + + const editingRow = this.getVariablesEditableRows().first(); + await this.setRowValue(editingRow, 'key', key); + await this.setRowValue(editingRow, 'value', value); + await this.saveRowEditing(editingRow); + } + + async deleteVariable(key: string) { + const row = this.getVariableRow(key); + await row.getByTestId('variable-row-delete-button').click(); + + // Use a more specific selector to avoid strict mode violation with other dialogs + const modal = this.page.getByRole('dialog').filter({ hasText: 'Delete variable' }); + await expect(modal).toBeVisible(); + await modal.locator('.btn--confirm').click(); + } + + async editRow(key: string) { + const row = this.getVariableRow(key); + await row.getByTestId('variable-row-edit-button').click(); + } + + async setRowValue(row: Locator, field: 'key' | 'value', value: string) { + const input = row.getByTestId(`variable-row-${field}-input`).locator('input, textarea'); + await input.selectText(); + await input.fill(value); + } + + async saveRowEditing(row: Locator) { + await this.getEditableRowSaveButton(row).click(); + } + + async cancelRowEditing(row: Locator) { + await this.getEditableRowCancelButton(row).click(); + } +} diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index bf2732f336..5f4aa3c5ac 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -3,6 +3,7 @@ import type { Page } from '@playwright/test'; import { AIAssistantPage } from './AIAssistantPage'; import { BecomeCreatorCTAPage } from './BecomeCreatorCTAPage'; import { CanvasPage } from './CanvasPage'; +import { CommunityNodesPage } from './CommunityNodesPage'; import { CredentialsPage } from './CredentialsPage'; import { DemoPage } from './DemoPage'; import { ExecutionsPage } from './ExecutionsPage'; @@ -14,6 +15,7 @@ import { NpsSurveyPage } from './NpsSurveyPage'; import { ProjectSettingsPage } from './ProjectSettingsPage'; import { SettingsPage } from './SettingsPage'; import { SidebarPage } from './SidebarPage'; +import { VariablesPage } from './VariablesPage'; import { VersionsPage } from './VersionsPage'; import { WorkerViewPage } from './WorkerViewPage'; import { WorkflowActivationModal } from './WorkflowActivationModal'; @@ -24,6 +26,7 @@ import { CanvasComposer } from '../composables/CanvasComposer'; import { ProjectComposer } from '../composables/ProjectComposer'; import { TestEntryComposer } from '../composables/TestEntryComposer'; import { WorkflowComposer } from '../composables/WorkflowComposer'; +import { NavigationHelper } from '../helpers/NavigationHelper'; import type { ApiHelpers } from '../services/api-helper'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -35,6 +38,7 @@ export class n8nPage { readonly aiAssistant: AIAssistantPage; readonly becomeCreatorCTA: BecomeCreatorCTAPage; readonly canvas: CanvasPage; + readonly communityNodes: CommunityNodesPage; readonly demo: DemoPage; readonly iframe: IframePage; readonly interactions: InteractionsPage; @@ -42,6 +46,7 @@ export class n8nPage { readonly npsSurvey: NpsSurveyPage; readonly projectSettings: ProjectSettingsPage; readonly settings: SettingsPage; + readonly variables: VariablesPage; readonly versions: VersionsPage; readonly workerView: WorkerViewPage; readonly workflows: WorkflowsPage; @@ -61,6 +66,9 @@ export class n8nPage { readonly canvasComposer: CanvasComposer; readonly start: TestEntryComposer; + // Helpers + readonly navigate: NavigationHelper; + constructor(page: Page, api: ApiHelpers) { this.page = page; this.api = api; @@ -69,6 +77,7 @@ export class n8nPage { this.aiAssistant = new AIAssistantPage(page); this.becomeCreatorCTA = new BecomeCreatorCTAPage(page); this.canvas = new CanvasPage(page); + this.communityNodes = new CommunityNodesPage(page); this.demo = new DemoPage(page); this.iframe = new IframePage(page); this.interactions = new InteractionsPage(page); @@ -76,6 +85,7 @@ export class n8nPage { this.npsSurvey = new NpsSurveyPage(page); this.projectSettings = new ProjectSettingsPage(page); this.settings = new SettingsPage(page); + this.variables = new VariablesPage(page); this.versions = new VersionsPage(page); this.workerView = new WorkerViewPage(page); this.workflows = new WorkflowsPage(page); @@ -94,6 +104,9 @@ export class n8nPage { this.projectComposer = new ProjectComposer(this); this.canvasComposer = new CanvasComposer(this); this.start = new TestEntryComposer(this); + + // Helpers + this.navigate = new NavigationHelper(page); } async goHome() { diff --git a/packages/testing/playwright/services/api-helper.ts b/packages/testing/playwright/services/api-helper.ts index c66ccc4f29..392164eeb1 100644 --- a/packages/testing/playwright/services/api-helper.ts +++ b/packages/testing/playwright/services/api-helper.ts @@ -11,6 +11,7 @@ import { import { TestError } from '../Types'; import { CredentialApiHelper } from './credential-api-helper'; import { ProjectApiHelper } from './project-api-helper'; +import { VariablesApiHelper } from './variables-api-helper'; import { WorkflowApiHelper } from './workflow-api-helper'; export interface LoginResponseData { @@ -37,12 +38,14 @@ export class ApiHelpers { workflowApi: WorkflowApiHelper; projectApi: ProjectApiHelper; credentialApi: CredentialApiHelper; + variablesApi: VariablesApiHelper; constructor(requestContext: APIRequestContext) { this.request = requestContext; this.workflowApi = new WorkflowApiHelper(this); this.projectApi = new ProjectApiHelper(this); this.credentialApi = new CredentialApiHelper(this); + this.variablesApi = new VariablesApiHelper(this); } // ===== MAIN SETUP METHODS ===== diff --git a/packages/testing/playwright/services/variables-api-helper.ts b/packages/testing/playwright/services/variables-api-helper.ts new file mode 100644 index 0000000000..b18e1a8a4a --- /dev/null +++ b/packages/testing/playwright/services/variables-api-helper.ts @@ -0,0 +1,123 @@ +import type { ApiHelpers } from './api-helper'; +import { TestError } from '../Types'; + +interface VariableResponse { + id: string; + key: string; + value: string; +} + +interface CreateVariableDto { + key: string; + value: string; +} + +interface UpdateVariableDto { + key?: string; + value?: string; +} + +export class VariablesApiHelper { + constructor(private api: ApiHelpers) {} + + /** + * Create a new variable + */ + async createVariable(variable: CreateVariableDto): Promise { + const response = await this.api.request.post('/rest/variables', { data: variable }); + + if (!response.ok()) { + throw new TestError(`Failed to create variable: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Get all variables + */ + async getAllVariables(): Promise { + const response = await this.api.request.get('/rest/variables'); + + if (!response.ok()) { + throw new TestError(`Failed to get variables: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Get a variable by ID + */ + async getVariable(id: string): Promise { + const response = await this.api.request.get(`/rest/variables/${id}`); + + if (!response.ok()) { + throw new TestError(`Failed to get variable: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Update a variable by ID + */ + async updateVariable(id: string, updates: UpdateVariableDto): Promise { + const response = await this.api.request.patch(`/rest/variables/${id}`, { data: updates }); + + if (!response.ok()) { + throw new TestError(`Failed to update variable: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Delete a variable by ID + */ + async deleteVariable(id: string): Promise { + const response = await this.api.request.delete(`/rest/variables/${id}`); + + if (!response.ok()) { + throw new TestError(`Failed to delete variable: ${await response.text()}`); + } + } + + /** + * Delete all variables (useful for test cleanup) + */ + async deleteAllVariables(): Promise { + const variables = await this.getAllVariables(); + + // Delete variables in parallel for better performance + await Promise.all(variables.map((variable) => this.deleteVariable(variable.id))); + } + + /** + * Create a test variable with a unique key + */ + async createTestVariable( + keyPrefix: string = 'TEST_VAR', + value: string = 'test_value', + ): Promise { + const key = `${keyPrefix}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + return await this.createVariable({ key, value }); + } + + /** + * Clean up variables by key pattern (useful for test cleanup) + */ + async cleanupTestVariables(keyPattern?: string): Promise { + const variables = await this.getAllVariables(); + + const variablesToDelete = keyPattern + ? variables.filter((variable) => variable.key.includes(keyPattern)) + : variables.filter((variable) => variable.key.startsWith('TEST_')); + + await Promise.all(variablesToDelete.map((variable) => this.deleteVariable(variable.id))); + } +} diff --git a/packages/testing/playwright/tests/performance/perf-examples.spec.ts b/packages/testing/playwright/tests/performance/perf-examples.spec.ts index 9c51e8a5f4..f13e7e205b 100644 --- a/packages/testing/playwright/tests/performance/perf-examples.spec.ts +++ b/packages/testing/playwright/tests/performance/perf-examples.spec.ts @@ -7,7 +7,7 @@ import { } from '../../utils/performance-helper'; async function setupPerformanceTest(n8n: n8nPage, size: number) { - await n8n.start.fromNewProject(); + await n8n.start.fromNewProjectBlankCanvas(); await n8n.canvas.importWorkflow('large.json', 'Large Workflow'); await n8n.notifications.closeNotificationByText('Successful'); diff --git a/packages/testing/playwright/tests/ui/21-community-nodes.spec.ts b/packages/testing/playwright/tests/ui/21-community-nodes.spec.ts new file mode 100644 index 0000000000..e0be5fb6a0 --- /dev/null +++ b/packages/testing/playwright/tests/ui/21-community-nodes.spec.ts @@ -0,0 +1,169 @@ +import { MANUAL_TRIGGER_NODE_NAME } from '../../config/constants'; +import { test, expect } from '../../fixtures/base'; +import customCredential from '../../workflows/Custom_credential.json'; +import customNodeFixture from '../../workflows/Custom_node.json'; +import customNodeWithCustomCredentialFixture from '../../workflows/Custom_node_custom_credential.json'; +import customNodeWithN8nCredentialFixture from '../../workflows/Custom_node_n8n_credential.json'; + +const CUSTOM_NODE_NAME = 'E2E Node'; +const CUSTOM_NODE_WITH_N8N_CREDENTIAL = 'E2E Node with native n8n credential'; +const CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL = 'E2E Node with custom credential'; +const MOCK_PACKAGE = { + createdAt: '2024-07-22T19:08:06.505Z', + updatedAt: '2024-07-22T19:08:06.505Z', + packageName: 'n8n-nodes-chatwork', + installedVersion: '1.0.0', + authorName: null, + authorEmail: null, + installedNodes: [ + { + name: 'Chatwork', + type: 'n8n-nodes-chatwork.chatwork', + latestVersion: 1, + }, + ], + updateAvailable: '1.1.2', +}; + +test.describe('Community and custom nodes in canvas', () => { + test.beforeEach(async ({ page }) => { + await page.route('/types/nodes.json', async (route) => { + const response = await route.fetch(); + const nodes = await response.json(); + nodes.push( + customNodeFixture, + customNodeWithN8nCredentialFixture, + customNodeWithCustomCredentialFixture, + ); + await route.fulfill({ + response, + json: nodes, + headers: { 'cache-control': 'no-cache, no-store' }, + }); + }); + + await page.route('/types/credentials.json', async (route) => { + const response = await route.fetch(); + const credentials = await response.json(); + credentials.push(customCredential); + await route.fulfill({ + response, + json: credentials, + headers: { 'cache-control': 'no-cache, no-store' }, + }); + }); + + await page.route('/community-node-types', async (route) => { + await route.fulfill({ status: 200, json: { data: [] } }); + }); + + await page.route('**/community-node-types/*', async (route) => { + await route.fulfill({ status: 200, json: null }); + }); + + await page.route('https://registry.npmjs.org/*', async (route) => { + await route.fulfill({ status: 404, json: {} }); + }); + }); + + test('should render and select community node', async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + + await n8n.canvas.clickCanvasPlusButton(); + await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_NAME); + await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_NAME); + await n8n.canvas.clickAddToWorkflowButton(); + + await expect(n8n.ndv.getNodeParameters()).toBeVisible(); + + await expect(n8n.ndv.getParameterInputField('testProp')).toHaveValue('Some default'); + await expect(n8n.ndv.getParameterInputField('resource')).toHaveValue('option2'); + + await n8n.ndv.selectOptionInParameterDropdown('resource', 'option4'); + await expect(n8n.ndv.getParameterInputField('resource')).toHaveValue('option4'); + }); + + test('should render custom node with n8n credential', async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + + await n8n.canvas.clickNodeCreatorPlusButton(); + await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_WITH_N8N_CREDENTIAL); + await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_WITH_N8N_CREDENTIAL); + await n8n.canvas.clickAddToWorkflowButton(); + + await n8n.page.getByTestId('credentials-label').click(); + await n8n.page.getByTestId('node-credentials-select-item-new').click(); + + await expect(n8n.page.getByTestId('editCredential-modal')).toContainText('Notion API'); + }); + + test('should render custom node with custom credential', async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + + await n8n.canvas.clickNodeCreatorPlusButton(); + await n8n.canvas.fillNodeCreatorSearchBar(CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL); + await n8n.canvas.clickNodeCreatorItemName(CUSTOM_NODE_WITH_CUSTOM_CREDENTIAL); + await n8n.canvas.clickAddToWorkflowButton(); + + await n8n.page.getByTestId('credentials-label').click(); + await n8n.page.getByTestId('node-credentials-select-item-new').click(); + + await expect(n8n.page.getByTestId('editCredential-modal')).toContainText( + 'Custom E2E Credential', + ); + }); +}); + +test.describe('Community nodes management', () => { + test('can install, update and uninstall community nodes', async ({ n8n, page }) => { + await page.route('**/api.npms.io/v2/search*', async (route) => { + await route.fulfill({ status: 200, json: {} }); + }); + + await page.route('/rest/community-packages', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: { data: [] } }); + } + }); + + await n8n.navigate.toCommunityNodes(); + + await page.route('/rest/community-packages', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ status: 200, json: { data: MOCK_PACKAGE } }); + } else if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: { data: [MOCK_PACKAGE] } }); + } + }); + + await n8n.communityNodes.installPackage('n8n-nodes-chatwork@1.0.0'); + await expect(n8n.communityNodes.getCommunityCards()).toHaveCount(1); + await expect(n8n.communityNodes.getCommunityCards().first()).toContainText('v1.0.0'); + + const updatedPackage = { + ...MOCK_PACKAGE, + installedVersion: '1.2.0', + updateAvailable: undefined, + }; + await page.route('/rest/community-packages', async (route) => { + if (route.request().method() === 'PATCH') { + await route.fulfill({ status: 200, json: { data: updatedPackage } }); + } + }); + + await n8n.communityNodes.updatePackage(); + await expect(n8n.communityNodes.getCommunityCards()).toHaveCount(1); + await expect(n8n.communityNodes.getCommunityCards().first()).not.toContainText('v1.0.0'); + + await page.route('/rest/community-packages*', async (route) => { + if (route.request().method() === 'DELETE') { + await route.fulfill({ status: 204 }); + } + }); + + await n8n.communityNodes.uninstallPackage(); + await expect(n8n.communityNodes.getActionBox()).toBeVisible(); + }); +}); diff --git a/packages/testing/playwright/tests/ui/23-variables.spec.ts b/packages/testing/playwright/tests/ui/23-variables.spec.ts new file mode 100644 index 0000000000..c763863be4 --- /dev/null +++ b/packages/testing/playwright/tests/ui/23-variables.spec.ts @@ -0,0 +1,163 @@ +import { customAlphabet } from 'nanoid'; + +import { test, expect } from '../../fixtures/base'; + +const generateValidId = customAlphabet( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_', + 8, +); + +test.describe('Variables', () => { + // These tests are serial since it's at an instance level and they interact with the same variables + test.describe.configure({ mode: 'serial' }); + test.describe('unlicensed', () => { + test('should show the unlicensed action box when the feature is disabled', async ({ + n8n, + api, + }) => { + await api.disableFeature('variables'); + await n8n.navigate.toVariables(); + await expect(n8n.variables.getUnavailableResourcesList()).toBeVisible(); + await expect(n8n.variables.getResourcesList()).toBeHidden(); + }); + }); + + test.describe('licensed', () => { + test.beforeEach(async ({ n8n, api }) => { + await api.enableFeature('variables'); + await api.variablesApi.deleteAllVariables(); + await n8n.navigate.toVariables(); + }); + + test('should create a new variable using empty state', async ({ n8n }) => { + const key = `ENV_VAR_${generateValidId()}`; + const value = 'test_value'; + + await n8n.variables.createVariableFromEmptyState(key, value); + + const variableRow = n8n.variables.getVariableRow(key); + await expect(variableRow).toContainText(value); + await expect(variableRow).toBeVisible(); + await expect(n8n.variables.getVariablesRows()).toHaveCount(1); + }); + + test('should create multiple variables', async ({ n8n }) => { + const key1 = `ENV_VAR_NEW_${generateValidId()}`; + const value1 = 'test_value_1'; + await n8n.variables.createVariableFromEmptyState(key1, value1); + await expect(n8n.variables.getVariablesRows()).toHaveCount(1); + + const key2 = `ENV_EXAMPLE_${generateValidId()}`; + const value2 = 'test_value_2'; + await n8n.variables.createVariable(key2, value2); + + await expect(n8n.variables.getVariablesRows()).toHaveCount(2); + + const variableRow1 = n8n.variables.getVariableRow(key1); + await expect(variableRow1).toContainText(value1); + await expect(variableRow1).toBeVisible(); + + const variableRow2 = n8n.variables.getVariableRow(key2); + await expect(variableRow2).toContainText(value2); + await expect(variableRow2).toBeVisible(); + }); + + test('should get validation errors and cancel variable creation', async ({ n8n }) => { + await n8n.variables.createVariableFromEmptyState( + `ENV_BASE_${generateValidId()}`, + 'base_value', + ); + const initialCount = await n8n.variables.getVariablesRows().count(); + + const key = `ENV_VAR_INVALID_${generateValidId()}$`; // Invalid key with special character + const value = 'test_value'; + + await n8n.variables.getCreateVariableButton().click(); + const editingRow = n8n.variables.getVariablesEditableRows().first(); + await n8n.variables.setRowValue(editingRow, 'key', key); + await n8n.variables.setRowValue(editingRow, 'value', value); + await n8n.variables.saveRowEditing(editingRow); + + await expect(editingRow).toContainText( + 'This field may contain only letters, numbers, and underscores', + ); + + await n8n.variables.cancelRowEditing(editingRow); + await expect(n8n.variables.getVariablesRows()).toHaveCount(initialCount); + }); + + test('should edit a variable', async ({ n8n }) => { + const key = `ENV_VAR_EDIT_${generateValidId()}`; + const initialValue = 'initial_value'; + await n8n.variables.createVariableFromEmptyState(key, initialValue); + + const newValue = 'updated_value'; + + await n8n.variables.editRow(key); + const editingRow = n8n.variables.getVariablesEditableRows().first(); + await n8n.variables.setRowValue(editingRow, 'value', newValue); + await n8n.variables.saveRowEditing(editingRow); + + const variableRow = n8n.variables.getVariableRow(key); + await expect(variableRow).toContainText(newValue); + await expect(variableRow).toBeVisible(); + }); + + test('should delete a variable', async ({ n8n }) => { + const key = `TO_DELETE_${generateValidId()}`; + const value = 'delete_test_value'; + + await n8n.variables.createVariableFromEmptyState(key, value); + const initialCount = await n8n.variables.getVariablesRows().count(); + + await n8n.variables.deleteVariable(key); + + await expect(n8n.variables.getVariablesRows()).toHaveCount(initialCount - 1); + + await expect(n8n.variables.getVariableRow(key)).toBeHidden(); + }); + + test('should search for a variable', async ({ n8n, page }) => { + const uniqueId = generateValidId(); + + const key1 = `SEARCH_VAR_${uniqueId}`; + const key2 = `SEARCH_VAR_NEW_${uniqueId}`; + const key3 = `SEARCH_EXAMPLE_${uniqueId}`; + + await n8n.variables.createVariableFromEmptyState(key1, 'search_value_1'); + await n8n.variables.createVariable(key2, 'search_value_2'); + await n8n.variables.createVariable(key3, 'search_value_3'); + + await n8n.variables.getSearchBar().fill('NEW_'); + await n8n.variables.getSearchBar().press('Enter'); + await expect(n8n.variables.getVariablesRows()).toHaveCount(1); + await expect(n8n.variables.getVariableRow(key2)).toBeVisible(); + await expect(page).toHaveURL(new RegExp('search=NEW_')); + + await n8n.variables.getSearchBar().clear(); + await n8n.variables.getSearchBar().fill('SEARCH_VAR_'); + await n8n.variables.getSearchBar().press('Enter'); + await expect(n8n.variables.getVariablesRows()).toHaveCount(2); + await expect(n8n.variables.getVariableRow(key1)).toBeVisible(); + await expect(n8n.variables.getVariableRow(key2)).toBeVisible(); + await expect(page).toHaveURL(new RegExp('search=SEARCH_VAR_')); + + await n8n.variables.getSearchBar().clear(); + await n8n.variables.getSearchBar().fill('SEARCH_'); + await n8n.variables.getSearchBar().press('Enter'); + await expect(n8n.variables.getVariablesRows()).toHaveCount(3); + await expect(n8n.variables.getVariableRow(key1)).toBeVisible(); + await expect(n8n.variables.getVariableRow(key2)).toBeVisible(); + await expect(n8n.variables.getVariableRow(key3)).toBeVisible(); + await expect(page).toHaveURL(new RegExp('search=SEARCH_')); + + await n8n.variables.getSearchBar().clear(); + await n8n.variables.getSearchBar().fill(`NonExistent_${generateValidId()}`); + await n8n.variables.getSearchBar().press('Enter'); + await expect(n8n.variables.getVariablesRows()).toBeHidden(); + await expect(page).toHaveURL(/search=NonExistent_/); + + await expect(page.getByText('No variables found')).toBeVisible(); + }); + }); +}); 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 e10348be97..1d0c8207ed 100644 --- a/packages/testing/playwright/tests/ui/43-oauth-flow.spec.ts +++ b/packages/testing/playwright/tests/ui/43-oauth-flow.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '../../fixtures/base'; test.describe('OAuth Credentials', () => { test('should create and connect with Google OAuth2', async ({ n8n, page }) => { - const projectId = await n8n.start.fromNewProject(); + const projectId = await n8n.start.fromNewProjectBlankCanvas(); await page.goto(`projects/${projectId}/credentials`); await n8n.credentials.emptyListCreateCredentialButton.click(); await n8n.credentials.openNewCredentialDialogFromCredentialList('Google OAuth2 API'); diff --git a/packages/testing/playwright/tests/ui/50-logs.spec.ts b/packages/testing/playwright/tests/ui/50-logs.spec.ts index 3197653009..dff44c8fd5 100644 --- a/packages/testing/playwright/tests/ui/50-logs.spec.ts +++ b/packages/testing/playwright/tests/ui/50-logs.spec.ts @@ -259,8 +259,8 @@ test.describe('Logs', () => { const response = await n8n.page.request.get(webhookUrl!); expect(response.status()).toBe(200); - await expect(n8n.canvas.getNodesWithSpinner()).not.toBeVisible(); - await expect(n8n.canvas.getWaitingNodes()).not.toBeVisible(); + await expect(n8n.canvas.getNodesWithSpinner()).toBeHidden(); + await expect(n8n.canvas.getWaitingNodes()).toBeHidden(); await expect( n8n.canvas.logsPanel.getOverviewStatus().filter({ hasText: /Success in [\d.]+m?s/ }), ).toBeVisible(); diff --git a/packages/testing/playwright/tests/ui/8-http-request-node.spec.ts b/packages/testing/playwright/tests/ui/8-http-request-node.spec.ts new file mode 100644 index 0000000000..87b339d9ce --- /dev/null +++ b/packages/testing/playwright/tests/ui/8-http-request-node.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../../fixtures/base'; + +test.describe('HTTP Request node', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should make a request with a URL and receive a response', async ({ n8n }) => { + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('HTTP Request', { closeNDV: false }); + + await n8n.ndv.setupHelper.httpRequest({ + url: 'https://catfact.ninja/fact', + }); + await n8n.ndv.execute(); + + await expect(n8n.ndv.outputPanel.get()).toContainText('fact'); + }); + + test.describe('Credential-only HTTP Request Node variants', () => { + test('should render a modified HTTP Request Node', async ({ n8n }) => { + await n8n.canvas.addNode('Manual Trigger'); + await n8n.canvas.addNode('VirusTotal'); + + await expect(n8n.ndv.getNodeNameContainer()).toContainText('VirusTotal HTTP Request'); + await expect(n8n.ndv.getParameterInputField('url')).toHaveValue( + 'https://www.virustotal.com/api/v3/', + ); + + await expect(n8n.ndv.getParameterInput('authentication')).toBeHidden(); + await expect(n8n.ndv.getParameterInput('nodeCredentialType')).toBeHidden(); + + await expect(n8n.ndv.getCredentialLabel('Credential for VirusTotal')).toBeVisible(); + }); + }); +}); diff --git a/packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts b/packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts index 87d69338f0..4692af37ad 100644 --- a/packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts +++ b/packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts @@ -17,7 +17,7 @@ test.describe('01 - UI Test Entry Points', () => { test.describe('Entry Point: Basic Workflow Creation', () => { test('should create a new project and workflow', async ({ n8n }) => { - await n8n.start.fromNewProject(); + await n8n.start.fromNewProjectBlankCanvas(); await expect(n8n.canvas.canvasPane()).toBeVisible(); }); }); diff --git a/packages/testing/playwright/workflows/Custom_credential.json b/packages/testing/playwright/workflows/Custom_credential.json new file mode 100644 index 0000000000..7b42758fe2 --- /dev/null +++ b/packages/testing/playwright/workflows/Custom_credential.json @@ -0,0 +1,21 @@ +{ + "name": "customE2eCredential", + "displayName": "Custom E2E Credential", + "properties": [ + { + "displayName": "API Key", + "name": "apiKey", + "type": "string", + "default": "", + "required": false + } + ], + "authenticate": { + "type": "generic", + "properties": { + "qs": { + "auth": "={{$credentials.apiKey}}" + } + } + } +} diff --git a/packages/testing/playwright/workflows/Custom_node.json b/packages/testing/playwright/workflows/Custom_node.json new file mode 100644 index 0000000000..bd898ec89d --- /dev/null +++ b/packages/testing/playwright/workflows/Custom_node.json @@ -0,0 +1,51 @@ +{ + "properties": [ + { + "displayName": "Test property", + "name": "testProp", + "type": "string", + "required": true, + "noDataExpression": false, + "default": "Some default" + }, + { + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [ + { + "name": "option1", + "value": "option1" + }, + { + "name": "option2", + "value": "option2" + }, + { + "name": "option3", + "value": "option3" + }, + { + "name": "option4", + "value": "option4" + } + ], + "default": "option2" + } + ], + "displayName": "E2E Node", + "name": "@e2e/n8n-nodes-e2e", + "group": ["transform"], + "codex": { + "categories": ["Custom Category"] + }, + "version": 1, + "description": "Demonstrate rendering of node", + "defaults": { + "name": "E2E Node " + }, + "inputs": ["main"], + "outputs": ["main"], + "icon": "fa:network-wired" +} diff --git a/packages/testing/playwright/workflows/Custom_node_custom_credential.json b/packages/testing/playwright/workflows/Custom_node_custom_credential.json new file mode 100644 index 0000000000..642309f99d --- /dev/null +++ b/packages/testing/playwright/workflows/Custom_node_custom_credential.json @@ -0,0 +1,57 @@ +{ + "properties": [ + { + "displayName": "Test property", + "name": "testProp", + "type": "string", + "required": true, + "noDataExpression": false, + "default": "Some default" + }, + { + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [ + { + "name": "option1", + "value": "option1" + }, + { + "name": "option2", + "value": "option2" + }, + { + "name": "option3", + "value": "option3" + }, + { + "name": "option4", + "value": "option4" + } + ], + "default": "option2" + } + ], + "displayName": "E2E Node with custom credential", + "name": "@e2e/n8n-nodes-e2e-custom-credential", + "group": ["transform"], + "codex": { + "categories": ["Custom Category"] + }, + "version": 1, + "description": "Demonstrate rendering of node with custom credential", + "defaults": { + "name": "E2E Node with custom credential" + }, + "inputs": ["main"], + "outputs": ["main"], + "icon": "fa:network-wired", + "credentials": [ + { + "name": "customE2eCredential", + "required": true + } + ] +} diff --git a/packages/testing/playwright/workflows/Custom_node_n8n_credential.json b/packages/testing/playwright/workflows/Custom_node_n8n_credential.json new file mode 100644 index 0000000000..9b8a557507 --- /dev/null +++ b/packages/testing/playwright/workflows/Custom_node_n8n_credential.json @@ -0,0 +1,57 @@ +{ + "properties": [ + { + "displayName": "Test property", + "name": "testProp", + "type": "string", + "required": true, + "noDataExpression": false, + "default": "Some default" + }, + { + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [ + { + "name": "option1", + "value": "option1" + }, + { + "name": "option2", + "value": "option2" + }, + { + "name": "option3", + "value": "option3" + }, + { + "name": "option4", + "value": "option4" + } + ], + "default": "option2" + } + ], + "displayName": "E2E Node with native n8n credential", + "name": "@e2e/n8n-nodes-e2e-credential", + "group": ["transform"], + "codex": { + "categories": ["Custom Category"] + }, + "version": 1, + "description": "Demonstrate rendering of node with native credential", + "defaults": { + "name": "E2E Node with native n8n credential" + }, + "inputs": ["main"], + "outputs": ["main"], + "icon": "fa:network-wired", + "credentials": [ + { + "name": "notionApi", + "required": true + } + ] +}