From e248a508164889142b34775bb3ed6900656a0b9c Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Mon, 29 Sep 2025 15:27:22 +0100 Subject: [PATCH] test: Migrate remaining folders tests from Cypress to Playwright (#20128) --- cypress/e2e/group4/49-folders-advanced.cy.ts | 163 ---------- .../e2e/group4/49-folders-operations.cy.ts | 247 --------------- .../composables/CredentialsComposer.ts | 2 +- .../composables/TestEntryComposer.ts | 22 +- packages/testing/playwright/fixtures/base.ts | 2 + .../playwright/helpers/NavigationHelper.ts | 12 +- packages/testing/playwright/pages/BasePage.ts | 7 +- .../testing/playwright/pages/CanvasPage.ts | 4 + .../playwright/pages/WorkflowSettingsModal.ts | 3 + .../testing/playwright/pages/WorkflowsPage.ts | 47 ++- .../pages/components/Breadcrumbs.ts | 16 +- .../pages/components/ResourceCards.ts | 12 +- .../testing/playwright/services/api-helper.ts | 12 +- .../services/workflow-api-helper.ts | 42 +++ .../playwright/tests/ui/1-workflows.spec.ts | 8 +- .../playwright/tests/ui/2-credentials.spec.ts | 20 +- .../playwright/tests/ui/23-variables.spec.ts | 2 +- .../playwright/tests/ui/39-projects.spec.ts | 16 +- .../ui/45-workflow-selector-parameter.spec.ts | 2 +- .../tests/ui/49-folders-advanced.spec.ts | 144 +++++++++ .../tests/ui/49-folders-basic.spec.ts | 12 +- .../tests/ui/49-folders-operations.spec.ts | 290 ++++++++++++++++++ .../ui/building-blocks/04-credentials.spec.ts | 2 +- .../ui/credential-api-operations.spec.ts | 48 +-- .../playwright/tests/ui/evaluations.spec.ts | 2 +- .../tests/ui/webhook-external-trigger.spec.ts | 8 +- .../tests/ui/webhook-origin-isolation.spec.ts | 2 +- 27 files changed, 644 insertions(+), 503 deletions(-) delete mode 100644 cypress/e2e/group4/49-folders-advanced.cy.ts delete mode 100644 cypress/e2e/group4/49-folders-operations.cy.ts create mode 100644 packages/testing/playwright/tests/ui/49-folders-advanced.spec.ts create mode 100644 packages/testing/playwright/tests/ui/49-folders-operations.spec.ts diff --git a/cypress/e2e/group4/49-folders-advanced.cy.ts b/cypress/e2e/group4/49-folders-advanced.cy.ts deleted file mode 100644 index 9fc598b0bbe..00000000000 --- a/cypress/e2e/group4/49-folders-advanced.cy.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - createFolderFromProjectHeader, - createFolderInsideFolder, - createNewProject, - createWorkflowFromProjectHeader, - dragAndDropToFolder, - dragAndDropToProjectRoot, - duplicateWorkflowFromCardActions, - duplicateWorkflowFromWorkflowPage, - getFolderCard, - getFolderCards, - getOverviewMenuItem, - getProjectMenuItem, - getWorkflowCard, - getWorkflowCards, - goToPersonalProject, -} from '../../composables/folders'; -import { expandSidebar } from '../../composables/sidebar'; -import { visitWorkflowsPage } from '../../composables/workflowsPage'; -import { successToast } from '../../pages/notifications'; - -describe('Folders - Advanced Interactions', () => { - before(() => { - cy.resetDatabase(); - cy.enableFeature('sharing'); - cy.enableFeature('folders'); - cy.enableFeature('advancedPermissions'); - cy.enableFeature('projectRole:admin'); - cy.enableFeature('projectRole:editor'); - cy.changeQuota('maxTeamProjects', -1); - }); - - beforeEach(() => { - visitWorkflowsPage(); - }); - - describe('Duplicate workflows', () => { - beforeEach(() => { - // Prevent the duplicated workflow from opening in a new tab - cy.window().then((win) => { - cy.stub(win, 'open').as('open'); - }); - }); - - it('should duplicate workflow within root folder from personal projects', () => { - goToPersonalProject(); - createWorkflowFromProjectHeader(undefined, 'Duplicate Me From Root'); - goToPersonalProject(); - duplicateWorkflowFromCardActions('Duplicate Me From Root', 'Duplicate Me From Root (Copy)'); - getWorkflowCard('Duplicate Me From Root (Copy)').should('exist'); - }); - - it('should duplicate workflow within a folder from personal projects', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Parent folder for duplication'); - getFolderCard('Parent folder for duplication').click(); - createWorkflowFromProjectHeader( - 'Parent folder for duplication', - 'Duplicate Me From Personal', - ); - goToPersonalProject(); - getFolderCard('Parent folder for duplication').click(); - duplicateWorkflowFromCardActions( - 'Duplicate Me From Personal', - 'Duplicate Me From Personal (Copy)', - ); - getWorkflowCard('Duplicate Me From Personal (Copy)').should('exist'); - }); - - it('should duplicate workflow within a folder from overview', () => { - expandSidebar(); - goToPersonalProject(); - getFolderCard('Parent folder for duplication').click(); - createWorkflowFromProjectHeader( - 'Parent folder for duplication', - 'Duplicate Me From Overview', - ); - getOverviewMenuItem().click(); - duplicateWorkflowFromCardActions( - 'Duplicate Me From Overview', - 'Duplicate Me From Overview (Copy)', - ); - getWorkflowCard('Duplicate Me From Overview (Copy)').should('exist'); - goToPersonalProject(); - getFolderCard('Parent folder for duplication').click(); - getWorkflowCard('Duplicate Me From Overview (Copy)').should('exist'); - }); - - it('should duplicate workflow within a folder from workflow', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Parent folder for duplication'); - getFolderCard('Parent folder for duplication').click(); - createWorkflowFromProjectHeader( - 'Parent folder for duplication', - 'Duplicate Me From Workflow', - ); - duplicateWorkflowFromWorkflowPage('Duplicate Me From Workflow (Copy)'); - goToPersonalProject(); - getFolderCard('Parent folder for duplication').click(); - getWorkflowCard('Duplicate Me From Workflow (Copy)').should('exist'); - }); - }); - - describe('Drag and drop', () => { - it('should drag and drop folders into folders', () => { - const PROJECT_NAME = 'Drag and Drop Test'; - const TARGET_NAME = 'Drag me'; - const DESTINATION_NAME = 'Folder Destination'; - - expandSidebar(); - createNewProject(PROJECT_NAME, { openAfterCreate: true }); - createFolderFromProjectHeader(TARGET_NAME); - createFolderFromProjectHeader(DESTINATION_NAME); - - dragAndDropToFolder(TARGET_NAME, DESTINATION_NAME); - successToast().should('contain.text', `${TARGET_NAME} has been moved to ${DESTINATION_NAME}`); - // Only one folder card should remain - getFolderCards().should('have.length', 1); - // Check folder in the destination - getFolderCard(DESTINATION_NAME).click(); - getFolderCard(TARGET_NAME).should('exist'); - }); - - it('should drag and drop folders into project root breadcrumb', () => { - const PROJECT_NAME = 'Drag to root test'; - const TARGET_NAME = 'To Project root'; - const PARENT_NAME = 'Parent Folder'; - - expandSidebar(); - createNewProject(PROJECT_NAME, { openAfterCreate: true }); - createFolderFromProjectHeader(PARENT_NAME); - createFolderInsideFolder(TARGET_NAME, PARENT_NAME); - - dragAndDropToProjectRoot(TARGET_NAME); - - // No folder cards should be shown in the parent folder - getFolderCards().should('not.exist'); - successToast().should('contain.text', `${TARGET_NAME} has been moved to ${PROJECT_NAME}`); - // Check folder in the project root - getProjectMenuItem(PROJECT_NAME).click(); - getFolderCard(TARGET_NAME).should('exist'); - }); - - it('should drag and drop workflows into folders', () => { - const PROJECT_NAME = 'Drag and Drop WF Test'; - const TARGET_NAME = 'Drag me - WF'; - const DESTINATION_NAME = 'Workflow Destination'; - - expandSidebar(); - createNewProject(PROJECT_NAME, { openAfterCreate: true }); - createFolderFromProjectHeader(DESTINATION_NAME); - createWorkflowFromProjectHeader(undefined, TARGET_NAME); - getProjectMenuItem(PROJECT_NAME).click(); - dragAndDropToFolder(TARGET_NAME, DESTINATION_NAME); - // No workflow cards should be shown in the project root - getWorkflowCards().should('not.exist'); - successToast().should('contain.text', `${TARGET_NAME} has been moved to ${DESTINATION_NAME}`); - // Check workflow in the destination - getFolderCard(DESTINATION_NAME).click(); - getWorkflowCard(TARGET_NAME).should('exist'); - }); - }); -}); diff --git a/cypress/e2e/group4/49-folders-operations.cy.ts b/cypress/e2e/group4/49-folders-operations.cy.ts deleted file mode 100644 index 1c33d4b6ddd..00000000000 --- a/cypress/e2e/group4/49-folders-operations.cy.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { - createFolderFromProjectHeader, - createFolderInsideFolder, - createWorkflowFromProjectHeader, - deleteAndTransferFolderContentsFromCardDropdown, - deleteAndTransferFolderContentsFromListDropdown, - deleteEmptyFolderFromCardDropdown, - deleteEmptyFolderFromListDropdown, - deleteFolderWithContentsFromCardDropdown, - deleteFolderWithContentsFromListDropdown, - getCurrentBreadcrumbText, - getFolderCard, - getFolderCards, - getFolderEmptyState, - getHomeProjectBreadcrumb, - getListBreadcrumbItem, - getListBreadcrumbs, - getPersonalProjectMenuItem, - getWorkflowCard, - getWorkflowCards, - goToPersonalProject, - moveFolderFromFolderCardActions, - moveFolderFromListActions, - moveWorkflowToFolder, - renameFolderFromCardActions, - renameFolderFromListActions, -} from '../../composables/folders'; -import { visitWorkflowsPage } from '../../composables/workflowsPage'; -import { successToast } from '../../pages/notifications'; - -describe('Folders - Operations', () => { - before(() => { - cy.resetDatabase(); - cy.enableFeature('sharing'); - cy.enableFeature('folders'); - cy.enableFeature('advancedPermissions'); - cy.enableFeature('projectRole:admin'); - cy.enableFeature('projectRole:editor'); - cy.changeQuota('maxTeamProjects', -1); - }); - - beforeEach(() => { - visitWorkflowsPage(); - }); - - describe('Rename and delete folders', () => { - it('should rename folder from main dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Rename Me'); - getFolderCard('Rename Me').should('exist'); - renameFolderFromListActions('Rename Me', 'Renamed'); - getCurrentBreadcrumbText().should('equal', 'Renamed'); - }); - - it('should rename folder from card dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Rename Me 2'); - renameFolderFromCardActions('Rename Me 2', 'Renamed 2'); - getFolderCard('Renamed 2').should('exist'); - }); - - it('should delete empty folder from card dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Delete Me'); - getFolderCard('Delete Me').should('exist'); - deleteEmptyFolderFromCardDropdown('Delete Me'); - }); - - it('should delete empty folder from main dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Delete Me 2'); - getFolderCard('Delete Me 2').should('exist'); - deleteEmptyFolderFromListDropdown('Delete Me 2'); - // Since we deleted the current folder, we should be back in the home project - getListBreadcrumbs().should('not.exist'); - getPersonalProjectMenuItem().find('li').should('have.class', 'is-active'); - }); - - it('should warn before deleting non-empty folder from list dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('I have children'); - createFolderInsideFolder('Child 1', 'I have children'); - deleteFolderWithContentsFromListDropdown('I have children'); - // Since we deleted the current folder, we should be back in the home project - getListBreadcrumbs().should('not.exist'); - getPersonalProjectMenuItem().find('li').should('have.class', 'is-active'); - }); - - it('should warn before deleting non-empty folder from card dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('I also have family'); - createFolderInsideFolder('Child 1', 'I also have family'); - // Back to home - getHomeProjectBreadcrumb().click(); - getFolderCard('I also have family').should('exist'); - deleteFolderWithContentsFromCardDropdown('I also have family'); - }); - - it('should transfer contents when deleting non-empty folder - from card dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Move my contents'); - createFolderFromProjectHeader('Destination'); - createFolderInsideFolder('Child 1', 'Move my contents'); - getHomeProjectBreadcrumb().click(); - getFolderCard('Move my contents').should('exist'); - deleteAndTransferFolderContentsFromCardDropdown('Move my contents', 'Destination'); - getFolderCard('Destination').click(); - // Should show the contents of the moved folder - getFolderCard('Child 1').should('exist'); - }); - - it('should transfer contents when deleting non-empty folder - from list breadcrumbs', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Move me too'); - createFolderFromProjectHeader('Destination 2'); - createFolderInsideFolder('Child 1', 'Move me too'); - deleteAndTransferFolderContentsFromListDropdown('Destination 2'); - getFolderCard('Destination').click(); - // Should show the contents of the moved folder - getFolderCard('Child 1').should('exist'); - }); - }); - - describe('Move folders and workflows', () => { - it('should move empty folder to another folder - from folder card action', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Move me - I am empty'); - createFolderFromProjectHeader('Destination 3'); - moveFolderFromFolderCardActions('Move me - I am empty', 'Destination 3'); - getFolderCard('Destination 3').click(); - getFolderCard('Move me - I am empty').should('exist'); - getFolderCard('Move me - I am empty').click(); - getFolderEmptyState().should('exist'); - successToast().should('contain.text', 'Move me - I am empty has been moved to Destination 3'); - // Breadcrumbs should show the destination folder - getListBreadcrumbItem('Destination 3').should('exist'); - }); - - it('should move folder with contents to another folder - from folder card action', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Move me - I have family'); - createFolderFromProjectHeader('Destination 4'); - // Create a workflow and a folder inside the folder - createFolderInsideFolder('Child 1', 'Move me - I have family'); - createWorkflowFromProjectHeader('Move me - I have family'); - goToPersonalProject(); - // Move the folder - moveFolderFromFolderCardActions('Move me - I have family', 'Destination 4'); - successToast().should( - 'contain.text', - 'Move me - I have family has been moved to Destination 4', - ); - // Go to destination folder and check if contents are there - getFolderCard('Destination 4').click(); - // Moved folder should be there - getFolderCard('Move me - I have family').should('exist').click(); - // Both the workflow and the folder should be there - getFolderCards().should('have.length', 1); - getWorkflowCards().should('have.length', 1); - // Breadcrumbs should show the destination folder - getListBreadcrumbItem('Destination 4').should('exist'); - }); - - it('should move empty folder to another folder - from list breadcrumbs', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Move me too - I am empty'); - createFolderFromProjectHeader('Destination 5'); - moveFolderFromListActions('Move me too - I am empty', 'Destination 5'); - // Since we moved the current folder, we should be in the destination folder - getCurrentBreadcrumbText().should('equal', 'Destination 5'); - }); - - it('should move folder with contents to another folder - from list dropdown', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Move me - I have family 2'); - createFolderFromProjectHeader('Destination 6'); - // Create a workflow and a folder inside the folder - createFolderInsideFolder('Child 1', 'Move me - I have family 2'); - createWorkflowFromProjectHeader('Move me - I have family 2'); - // Navigate back to folder - goToPersonalProject(); - getFolderCard('Move me - I have family 2').should('exist'); - // Move the folder - moveFolderFromListActions('Move me - I have family 2', 'Destination 6'); - // Since we moved the current folder, we should be in the destination folder - getCurrentBreadcrumbText().should('equal', 'Destination 6'); - // Moved folder should be there - getFolderCard('Move me - I have family 2').should('exist').click(); - // After navigating to the moved folder, both the workflow and the folder should be there - getFolderCards().should('have.length', 1); - getWorkflowCards().should('have.length', 1); - // Breadcrumbs should show the destination folder - getListBreadcrumbItem('Destination 6').should('exist'); - }); - - it('should move folder to project root - from folder card action', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Test parent'); - createFolderInsideFolder('Move me to root', 'Test parent'); - moveFolderFromFolderCardActions('Move me to root', 'No folder (project root)'); - // Parent folder should be empty - getFolderEmptyState().should('exist'); - // Child folder should be in the root - goToPersonalProject(); - getFolderCard('Move me to root').should('exist'); - // Navigate to the moved folder and check breadcrumbs - getFolderCard('Move me to root').click(); - getHomeProjectBreadcrumb().should('contain.text', 'Personal'); - getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist'); - getCurrentBreadcrumbText().should('equal', 'Move me to root'); - }); - - it('should move workflow from project root to folder', () => { - goToPersonalProject(); - createWorkflowFromProjectHeader(undefined, 'Move me'); - goToPersonalProject(); - createFolderFromProjectHeader('Workflow destination'); - moveWorkflowToFolder('Move me', 'Workflow destination'); - successToast().should('contain.text', 'Move me has been moved to Workflow destination'); - // Navigate to the destination folder - getFolderCard('Workflow destination').click(); - // Moved workflow should be there - getWorkflowCards().should('have.length', 1); - getWorkflowCard('Move me').should('exist'); - }); - - it('should move workflow to another folder', () => { - goToPersonalProject(); - createFolderFromProjectHeader('Moving workflow from here'); - createFolderFromProjectHeader('Moving workflow to here'); - getFolderCard('Moving workflow from here').click(); - createWorkflowFromProjectHeader(undefined, 'Move me'); - goToPersonalProject(); - getFolderCard('Moving workflow from here').click(); - getWorkflowCard('Move me').should('exist'); - moveWorkflowToFolder('Move me', 'Moving workflow to here'); - // Now folder should be empty - getFolderEmptyState().should('exist'); - // Navigate to the destination folder - getHomeProjectBreadcrumb().click(); - getFolderCard('Moving workflow to here').click(); - // Moved workflow should be there - getWorkflowCards().should('have.length', 1); - getWorkflowCard('Move me').should('exist'); - }); - }); -}); diff --git a/packages/testing/playwright/composables/CredentialsComposer.ts b/packages/testing/playwright/composables/CredentialsComposer.ts index 85c0ffb897b..a555766f191 100644 --- a/packages/testing/playwright/composables/CredentialsComposer.ts +++ b/packages/testing/playwright/composables/CredentialsComposer.ts @@ -47,6 +47,6 @@ export class CredentialsComposer { * Create a credential directly via API. Returns created credential object. */ async createFromApi(payload: CreateCredentialDto & { projectId?: string }) { - return await this.n8n.api.credentialApi.createCredential(payload); + return await this.n8n.api.credentials.createCredential(payload); } } diff --git a/packages/testing/playwright/composables/TestEntryComposer.ts b/packages/testing/playwright/composables/TestEntryComposer.ts index 1926d213c1e..35b54a85c7e 100644 --- a/packages/testing/playwright/composables/TestEntryComposer.ts +++ b/packages/testing/playwright/composables/TestEntryComposer.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test'; + import type { n8nPage } from '../pages/n8nPage'; /** @@ -44,10 +46,7 @@ export class TestEntryComposer { } async fromNewProject() { - await this.withProjectFeatures(); - // Create a project using the API const response = await this.n8n.api.projects.createProject(); - const projectId = response.id; await this.n8n.navigate.toProject(projectId); return projectId; @@ -58,11 +57,26 @@ export class TestEntryComposer { * Returns the workflow import result for use in the test */ async fromImportedWorkflow(workflowFile: string) { - const workflowImportResult = await this.n8n.api.workflowApi.importWorkflow(workflowFile); + const workflowImportResult = await this.n8n.api.workflows.importWorkflow(workflowFile); await this.n8n.page.goto(`workflow/${workflowImportResult.workflowId}`); return workflowImportResult; } + /** + * Start UI test on a new page created by an action + * @param action - The action that will create a new page + * @returns n8nPage instance for the new page + */ + async fromNewPage(action: () => Promise): Promise { + const newPagePromise = this.n8n.page.waitForEvent('popup'); + await action(); + const newPage = await newPagePromise; + await newPage.waitForLoadState('domcontentloaded'); + // Use the constructor from the current instance to avoid circular dependency + const n8nPageConstructor = this.n8n.constructor as new (page: Page) => n8nPage; + return new n8nPageConstructor(newPage); + } + /** * Enable project feature set * Allow project creation, sharing, and folder creation diff --git a/packages/testing/playwright/fixtures/base.ts b/packages/testing/playwright/fixtures/base.ts index 3c918f384b9..b82dd8b082e 100644 --- a/packages/testing/playwright/fixtures/base.ts +++ b/packages/testing/playwright/fixtures/base.ts @@ -138,6 +138,8 @@ export const test = base.extend({ const page = await context.newPage(); const n8nInstance = new n8nPage(page); await n8nInstance.api.setupFromTags(testInfo.tags); + // Enable project features for the tests, this is used in several tests, but is never disabled in tests, so we can have it on by default + await n8nInstance.start.withProjectFeatures(); await use(n8nInstance); }, diff --git a/packages/testing/playwright/helpers/NavigationHelper.ts b/packages/testing/playwright/helpers/NavigationHelper.ts index 37ec69e0353..4fdecd4d8d5 100644 --- a/packages/testing/playwright/helpers/NavigationHelper.ts +++ b/packages/testing/playwright/helpers/NavigationHelper.ts @@ -112,16 +112,14 @@ export class NavigationHelper { * - 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}`; + async toWorkflow(workflowId: string = 'new'): Promise { + const url = `/workflow/${workflowId}`; await this.page.goto(url); } /** * Navigate to a specific folder - * URL: /projects/{projectId}/folders/{folderId} + * URL: /projects/{projectId}/folders/{folderId}/workflows or /home/folders/{folderId}/workflows */ async toFolder(folderId: string, projectId?: string): Promise { const url = projectId @@ -133,8 +131,8 @@ export class NavigationHelper { /** * Navigate to workflow canvas (alias for toWorkflow) */ - async toCanvas(workflowId: string = 'new', projectId?: string): Promise { - await this.toWorkflow(workflowId, projectId); + async toCanvas(workflowId: string = 'new'): Promise { + await this.toWorkflow(workflowId); } /** diff --git a/packages/testing/playwright/pages/BasePage.ts b/packages/testing/playwright/pages/BasePage.ts index a334b1029d9..a7a24fb3ba5 100644 --- a/packages/testing/playwright/pages/BasePage.ts +++ b/packages/testing/playwright/pages/BasePage.ts @@ -3,8 +3,11 @@ import type { Page } from '@playwright/test'; import { BaseModal } from './components/BaseModal'; export abstract class BasePage { - protected readonly baseModal = new BaseModal(this.page); - constructor(protected readonly page: Page) {} + protected readonly baseModal: BaseModal; + + constructor(protected readonly page: Page) { + this.baseModal = new BaseModal(this.page); + } protected async clickByTestId(testId: string) { await this.page.getByTestId(testId).click(); diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index 7111604820f..4c693a21d15 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -752,4 +752,8 @@ export class CanvasPage extends BasePage { getCanvasPlusButton(): Locator { return this.page.getByTestId('canvas-plus-button'); } + + getWorkflowName(): Locator { + return this.page.getByTestId('workflow-name-input'); + } } diff --git a/packages/testing/playwright/pages/WorkflowSettingsModal.ts b/packages/testing/playwright/pages/WorkflowSettingsModal.ts index 6485b9d03aa..4aa5f6b3524 100644 --- a/packages/testing/playwright/pages/WorkflowSettingsModal.ts +++ b/packages/testing/playwright/pages/WorkflowSettingsModal.ts @@ -18,6 +18,9 @@ export class WorkflowSettingsModal extends BasePage { getErrorWorkflowField(): Locator { return this.page.getByTestId('workflow-settings-error-workflow'); } + getDuplicateMenuItem(): Locator { + return this.page.getByTestId('workflow-menu-item-duplicate'); + } getSaveButton(): Locator { return this.page.getByRole('button', { name: 'Save' }); diff --git a/packages/testing/playwright/pages/WorkflowsPage.ts b/packages/testing/playwright/pages/WorkflowsPage.ts index b05f60059a8..cc4b1b30f9b 100644 --- a/packages/testing/playwright/pages/WorkflowsPage.ts +++ b/packages/testing/playwright/pages/WorkflowsPage.ts @@ -154,7 +154,6 @@ export class WorkflowsPage extends BasePage { async filterByTag(tag: string) { await this.filterByTags([tag]); } - getFolderBreadcrumbsActions() { return this.page.getByTestId('folder-breadcrumbs-actions'); } @@ -187,9 +186,51 @@ export class WorkflowsPage extends BasePage { /** * Fill the folder modal * @param folderName - The name of the folder + * @param buttonText - The text of the button to click (default: 'Create') */ - async fillFolderModal(folderName: string) { + async fillFolderModal(folderName: string, buttonText: string = 'Create') { await this.baseModal.fillInput(folderName); - await this.baseModal.clickButton('Create'); + await this.baseModal.clickButton(buttonText); + } + + deleteFolderModal() { + return this.page.getByTestId('deleteFolder-modal'); + } + + deleteModalTransferRadioButton() { + return this.deleteFolderModal().getByTestId('transfer-content-radio'); + } + + deleteModalDeleteRadioButton() { + return this.deleteFolderModal().getByTestId('delete-content-radio'); + } + + deleteModalConfirmButton() { + return this.deleteFolderModal().getByTestId('confirm-delete-folder-button'); + } + + transferFolderDropdown() { + return this.deleteFolderModal().getByRole('combobox', { name: 'Select a folder' }); + } + + transferFolderOption(folderName: string) { + return this.page.getByTestId('move-to-folder-option').filter({ hasText: folderName }); + } + + // Move folder modal methods + moveFolderModal() { + return this.page.getByTestId('moveFolder-modal'); + } + + moveFolderDropdown() { + return this.moveFolderModal().getByTestId('move-to-folder-dropdown').getByRole('combobox'); + } + + moveFolderOption(folderName: string) { + return this.page.getByTestId('move-to-folder-option').filter({ hasText: folderName }); + } + + moveFolderConfirmButton() { + return this.moveFolderModal().getByTestId('confirm-move-folder-button'); } } diff --git a/packages/testing/playwright/pages/components/Breadcrumbs.ts b/packages/testing/playwright/pages/components/Breadcrumbs.ts index f374bcb224e..d6e6f372415 100644 --- a/packages/testing/playwright/pages/components/Breadcrumbs.ts +++ b/packages/testing/playwright/pages/components/Breadcrumbs.ts @@ -29,4 +29,18 @@ export class Breadcrumbs { getActionToggleDropdown(resourceName: string) { return this.page.getByTestId('action-toggle-dropdown').getByTestId(`action-${resourceName}`); } -} + + getFolderBreadcrumbsActionToggle() { + return this.page.getByTestId('folder-breadcrumbs-actions'); + } + + /** + * Rename the current breadcrumb by activating inline edit mode + * @param newName - The new name for the breadcrumb item + */ + async renameCurrentBreadcrumb(newName: string) { + await this.getCurrentBreadcrumb().getByTestId('inline-edit-preview').click(); + await this.getCurrentBreadcrumb().getByTestId('inline-edit-input').fill(newName); + await this.page.keyboard.press('Enter'); + } +} \ No newline at end of file diff --git a/packages/testing/playwright/pages/components/ResourceCards.ts b/packages/testing/playwright/pages/components/ResourceCards.ts index 2b26d3244ab..993bf9027ce 100644 --- a/packages/testing/playwright/pages/components/ResourceCards.ts +++ b/packages/testing/playwright/pages/components/ResourceCards.ts @@ -46,11 +46,14 @@ export class ResourceCards { } getCardActionToggle(card: Locator): Locator { - return card.getByTestId('card-append'); + return card + .getByTestId('card-append') + .locator('[class*="action-toggle"]') + .filter({ visible: true }); } getCardAction(actionName: string): Locator { - return this.page.getByTestId(`action-${actionName}`); + return this.page.getByTestId(`action-${actionName}`).filter({ visible: true }); } async openCardActions(card: Locator): Promise { @@ -66,4 +69,9 @@ export class ResourceCards { const folderCard = this.getFolder(folderName); await this.clickCardAction(folderCard, 'open'); } + + async deleteFolder(folderName: string): Promise { + const folderCard = this.getFolder(folderName); + await this.clickCardAction(folderCard, 'delete'); + } } diff --git a/packages/testing/playwright/services/api-helper.ts b/packages/testing/playwright/services/api-helper.ts index 7fe7e561316..390f583f2f8 100644 --- a/packages/testing/playwright/services/api-helper.ts +++ b/packages/testing/playwright/services/api-helper.ts @@ -35,17 +35,17 @@ const DB_TAGS = { export class ApiHelpers { request: APIRequestContext; - workflowApi: WorkflowApiHelper; + workflows: WorkflowApiHelper; projects: ProjectApiHelper; - credentialApi: CredentialApiHelper; - variablesApi: VariablesApiHelper; + credentials: CredentialApiHelper; + variables: VariablesApiHelper; constructor(requestContext: APIRequestContext) { this.request = requestContext; - this.workflowApi = new WorkflowApiHelper(this); + this.workflows = new WorkflowApiHelper(this); this.projects = new ProjectApiHelper(this); - this.credentialApi = new CredentialApiHelper(this); - this.variablesApi = new VariablesApiHelper(this); + this.credentials = new CredentialApiHelper(this); + this.variables = new VariablesApiHelper(this); } // ===== MAIN SETUP METHODS ===== diff --git a/packages/testing/playwright/services/workflow-api-helper.ts b/packages/testing/playwright/services/workflow-api-helper.ts index 5cc2654d5f5..8b03c7595a4 100644 --- a/packages/testing/playwright/services/workflow-api-helper.ts +++ b/packages/testing/playwright/services/workflow-api-helper.ts @@ -35,6 +35,48 @@ export class WorkflowApiHelper { return result.data ?? result; } + /** + * Creates a workflow in a project with optional folder placement (Uses Internal API not public API) + * @param project - Required project ID where the workflow will be created + * @param options - Optional configuration for workflow creation + * @param options.folder - Optional folder ID to place the workflow in + * @param options.name - Optional workflow name. If not provided, generates a unique name using nanoid + * @returns Object containing the name and ID of the created workflow + */ + async createInProject( + project: string, + options?: { + folder?: string; + name?: string; + }, + ): Promise<{ name: string; id: string }> { + const workflowName = options?.name ?? `Test Workflow ${nanoid(8)}`; + + const workflow = { + name: workflowName, + nodes: [], + connections: {}, + settings: {}, + active: false, + projectId: project, + ...(options?.folder && { parentFolderId: options.folder }), + }; + + const response = await this.api.request.post('/rest/workflows', { data: workflow }); + + if (!response.ok()) { + throw new TestError(`Failed to create workflow: ${await response.text()}`); + } + + const result = await response.json(); + const workflowData = result.data ?? result; + + return { + name: workflowName, + id: workflowData.id, + }; + } + async setActive(workflowId: string, active: boolean) { const response = await this.api.request.patch(`/rest/workflows/${workflowId}?forceSave=true`, { data: { active }, diff --git a/packages/testing/playwright/tests/ui/1-workflows.spec.ts b/packages/testing/playwright/tests/ui/1-workflows.spec.ts index 5f779bc98f8..7bc4d88ccc3 100644 --- a/packages/testing/playwright/tests/ui/1-workflows.spec.ts +++ b/packages/testing/playwright/tests/ui/1-workflows.spec.ts @@ -11,12 +11,6 @@ const NOTIFICATIONS = { test.describe('Workflows', () => { test.beforeEach(async ({ n8n }) => { - await n8n.api.enableFeature('sharing'); - await n8n.api.enableFeature('folders'); - await n8n.api.enableFeature('advancedPermissions'); - await n8n.api.enableFeature('projectRole:admin'); - await n8n.api.enableFeature('projectRole:editor'); - await n8n.api.setMaxTeamProjectsQuota(-1); await n8n.goHome(); }); @@ -35,7 +29,7 @@ test.describe('Workflows', () => { const uniqueIdForCreate = nanoid(8); const workflowName = `Test Workflow ${uniqueIdForCreate}`; await n8n.canvas.setWorkflowName(workflowName); - await n8n.canvas.clickSaveWorkflowButton(); + await n8n.canvas.saveWorkflow(); await expect(n8n.notifications.getNotificationByTitle(NOTIFICATIONS.CREATED)).toBeVisible(); }); diff --git a/packages/testing/playwright/tests/ui/2-credentials.spec.ts b/packages/testing/playwright/tests/ui/2-credentials.spec.ts index c84d9d25ce7..b92eb69fba9 100644 --- a/packages/testing/playwright/tests/ui/2-credentials.spec.ts +++ b/packages/testing/playwright/tests/ui/2-credentials.spec.ts @@ -26,14 +26,14 @@ test.describe('Credentials', () => { const credentialA = `A Credential ${nanoid()}`; const credentialZ = `Z Credential ${nanoid()}`; - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: credentialA, type: 'notionApi', data: { apiKey: '1234567890' }, projectId, }); - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: credentialZ, type: 'trelloApi', data: { apiKey: 'test_api_key', apiToken: 'test_api_token' }, @@ -94,14 +94,14 @@ test.describe('Credentials', () => { const serviceAccountCredentialName2 = `OAuth2 Credential ${nanoid()}`; const serviceAccountCredentialName = `Service Account Credential ${nanoid()}`; - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: serviceAccountCredentialName2, type: 'googleApi', data: { email: 'test@service.com', privateKey: 'test_key' }, projectId, }); - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: serviceAccountCredentialName, type: 'googleApi', data: { email: 'test@service.com', privateKey: 'test_key' }, @@ -234,7 +234,7 @@ test.describe('Credentials', () => { const projectId = await n8n.start.fromNewProjectBlankCanvas(); const credentialName = `My awesome Notion account ${nanoid()}`; - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: credentialName, type: 'notionApi', data: { apiKey: '1234567890' }, @@ -245,16 +245,16 @@ test.describe('Credentials', () => { await n8n.canvas.addNode('Notion', { action: 'Append a block' }); await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); - const credentials = await n8n.api.credentialApi.getCredentials(); + const credentials = await n8n.api.credentials.getCredentials(); const credential = credentials.find((c) => c.name === credentialName); - await n8n.api.credentialApi.deleteCredential(credential!.id); + await n8n.api.credentials.deleteCredential(credential!.id); }); test('should set a default credential when editing a node', async ({ n8n }) => { const projectId = await n8n.start.fromNewProjectBlankCanvas(); const credentialName = `My awesome Notion account ${nanoid()}`; - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: credentialName, type: 'notionApi', data: { apiKey: '1234567890' }, @@ -268,9 +268,9 @@ test.describe('Credentials', () => { await n8n.ndv.selectOptionInParameterDropdown('nodeCredentialType', 'Notion API'); await expect(n8n.ndv.getCredentialSelect()).toHaveValue(credentialName); - const credentials = await n8n.api.credentialApi.getCredentials(); + const credentials = await n8n.api.credentials.getCredentials(); const credential = credentials.find((c) => c.name === credentialName); - await n8n.api.credentialApi.deleteCredential(credential!.id); + await n8n.api.credentials.deleteCredential(credential!.id); }); test('should setup generic authentication for HTTP node', async ({ n8n }) => { diff --git a/packages/testing/playwright/tests/ui/23-variables.spec.ts b/packages/testing/playwright/tests/ui/23-variables.spec.ts index 06489cb3077..8a14dfa03cd 100644 --- a/packages/testing/playwright/tests/ui/23-variables.spec.ts +++ b/packages/testing/playwright/tests/ui/23-variables.spec.ts @@ -22,7 +22,7 @@ test.describe('Variables', () => { test.describe('licensed', () => { test.beforeEach(async ({ n8n }) => { await n8n.api.enableFeature('variables'); - await n8n.api.variablesApi.deleteAllVariables(); + await n8n.api.variables.deleteAllVariables(); await n8n.navigate.toVariables(); }); diff --git a/packages/testing/playwright/tests/ui/39-projects.spec.ts b/packages/testing/playwright/tests/ui/39-projects.spec.ts index 083512e2c0e..1280af84297 100644 --- a/packages/testing/playwright/tests/ui/39-projects.spec.ts +++ b/packages/testing/playwright/tests/ui/39-projects.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../fixtures/base'; -import { n8nPage } from '../../pages/n8nPage'; +import type { n8nPage } from '../../pages/n8nPage'; const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger'; const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Sub-workflow'; @@ -18,12 +18,6 @@ async function getCredentialsForProject(n8n: n8nPage, projectId?: string) { test.describe('Projects', () => { test.beforeEach(async ({ n8n }) => { - await n8n.api.enableFeature('sharing'); - await n8n.api.enableFeature('folders'); - await n8n.api.enableFeature('advancedPermissions'); - await n8n.api.enableFeature('projectRole:admin'); - await n8n.api.enableFeature('projectRole:editor'); - await n8n.api.setMaxTeamProjectsQuota(-1); await n8n.goHome(); }); @@ -64,11 +58,9 @@ test.describe('Projects', () => { await n8n.canvas.addNode(EXECUTE_WORKFLOW_NODE_NAME, { action: 'Execute A Sub Workflow' }); - const subWorkflowPagePromise = n8n.page.waitForEvent('popup'); - - await n8n.ndv.selectWorkflowResource(`Create a Sub-Workflow in '${projectName}'`); - - const subn8n = new n8nPage(await subWorkflowPagePromise); + const subn8n = await n8n.start.fromNewPage(() => + n8n.ndv.selectWorkflowResource(`Create a Sub-Workflow in '${projectName}'`), + ); await subn8n.ndv.clickBackToCanvasButton(); diff --git a/packages/testing/playwright/tests/ui/45-workflow-selector-parameter.spec.ts b/packages/testing/playwright/tests/ui/45-workflow-selector-parameter.spec.ts index ebfb38356fd..1ba298a131f 100644 --- a/packages/testing/playwright/tests/ui/45-workflow-selector-parameter.spec.ts +++ b/packages/testing/playwright/tests/ui/45-workflow-selector-parameter.spec.ts @@ -14,7 +14,7 @@ test.describe('Workflow Selector Parameter @db:reset', () => { ]; for (const { file } of subWorkflows) { - await n8n.api.workflowApi.importWorkflow(file); + await n8n.api.workflows.importWorkflow(file); } await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); diff --git a/packages/testing/playwright/tests/ui/49-folders-advanced.spec.ts b/packages/testing/playwright/tests/ui/49-folders-advanced.spec.ts new file mode 100644 index 00000000000..fc69aa919c9 --- /dev/null +++ b/packages/testing/playwright/tests/ui/49-folders-advanced.spec.ts @@ -0,0 +1,144 @@ +import { test, expect } from '../../fixtures/base'; + +test.describe('Folders - Advanced Operations', () => { + test.describe('Duplicate workflows', () => { + test('should duplicate workflow within root folder from personal projects', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const { name: workflowName } = await n8n.api.workflows.createInProject(projectId); + await n8n.navigate.toProject(projectId); + const workflowCard = n8n.workflows.cards.getWorkflow(workflowName); + await n8n.workflows.cards.openCardActions(workflowCard); + await n8n.workflows.cards.getCardAction('duplicate').click(); + const duplicatePage = await n8n.start.fromNewPage(async () => { + await n8n.modal.clickButton('Duplicate'); + }); + + const duplicatedName = `${workflowName} copy`; + await duplicatePage.navigate.toProject(projectId); + await expect(duplicatePage.workflows.cards.getWorkflow(duplicatedName)).toBeVisible(); + }); + + test('should duplicate workflow within a folder from personal projects', async ({ n8n }) => { + const projectId = await n8n.start.fromNewProject(); + const folder = await n8n.api.projects.createFolder(projectId); + const { name: workflowName } = await n8n.api.workflows.createInProject(projectId, { + folder: folder.id, + }); + await n8n.navigate.toFolder(folder.id, projectId); + + const workflowCard = n8n.workflows.cards.getWorkflow(workflowName); + await n8n.workflows.cards.openCardActions(workflowCard); + await n8n.workflows.cards.getCardAction('duplicate').click(); + + const duplicatePage = await n8n.start.fromNewPage(async () => { + await n8n.modal.clickButton('Duplicate'); + }); + + await duplicatePage.navigate.toFolder(folder.id); + const duplicatedName = `${workflowName} copy`; + await expect(duplicatePage.workflows.cards.getWorkflow(duplicatedName)).toBeVisible(); + }); + + test('should duplicate workflow within a folder from workflow page', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const folder = await n8n.api.projects.createFolder(projectId); + const { name: workflowName, id: workflowId } = await n8n.api.workflows.createInProject( + projectId, + { + folder: folder.id, + }, + ); + await n8n.navigate.toCanvas(workflowId); + + await n8n.workflowSettingsModal.getWorkflowMenu().click(); + await n8n.workflowSettingsModal.getDuplicateMenuItem().click(); + const duplicatePage = await n8n.start.fromNewPage(async () => { + await n8n.modal.clickButton('Duplicate'); + }); + + const duplicatedName = `${workflowName} copy`; + await duplicatePage.navigate.toFolder(folder.id, projectId); + await expect(duplicatePage.workflows.cards.getWorkflow(duplicatedName)).toBeVisible(); + }); + }); + + test.describe('Drag and drop', () => { + test('should drag and drop folders into folders', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject('Drag and Drop Test'); + await n8n.navigate.toProject(projectId); + const targetFolder = await n8n.api.projects.createFolder(projectId, 'Drag me'); + const destinationFolder = await n8n.api.projects.createFolder( + projectId, + 'Folder Destination', + ); + + const sourceFolderCard = n8n.workflows.cards.getFolder(targetFolder.name); + const destinationFolderCard = n8n.workflows.cards.getFolder(destinationFolder.name); + + await n8n.interactions.precisionDragToTarget(sourceFolderCard, destinationFolderCard); + + await expect( + n8n.notifications.getNotificationByTitleOrContent( + `${targetFolder.name} has been moved to ${destinationFolder.name}`, + ), + ).toBeVisible(); + + await expect(n8n.workflows.cards.getFolders()).toHaveCount(1); + + await n8n.workflows.cards.openFolder(destinationFolder.name); + await expect(n8n.workflows.cards.getFolder(targetFolder.name)).toBeVisible(); + }); + + test('should drag and drop folders into project root breadcrumb', async ({ n8n }) => { + const project = await n8n.api.projects.createProject('Drag to root test'); + await n8n.navigate.toProject(project.id); + const parentFolder = await n8n.api.projects.createFolder(project.id, 'Parent Folder'); + const targetFolder = await n8n.api.projects.createFolder( + project.id, + 'To Project root', + parentFolder.id, + ); + + await n8n.navigate.toFolder(parentFolder.id, project.id); + + const sourceFolderCard = n8n.workflows.cards.getFolder(targetFolder.name); + const projectBreadcrumb = n8n.breadcrumbs.getHomeProjectBreadcrumb(); + + await n8n.interactions.precisionDragToTarget(sourceFolderCard, projectBreadcrumb); + + await expect( + n8n.notifications.getNotificationByTitleOrContent( + `${targetFolder.name} has been moved to ${project.name}`, + ), + ).toBeVisible(); + + await expect(n8n.workflows.cards.getFolders()).toHaveCount(0); + + await n8n.navigate.toProject(project.id); + await expect(n8n.workflows.cards.getFolder(targetFolder.name)).toBeVisible(); + }); + + test('should drag and drop workflows into folders', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject('Drag and Drop WF Test'); + const { name: workflowName } = await n8n.api.workflows.createInProject(projectId, {}); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + await n8n.navigate.toProject(projectId); + + const sourceWorkflowCard = n8n.workflows.cards.getWorkflow(workflowName); + const destinationFolderCard = n8n.workflows.cards.getFolder(destinationFolder.name); + + await n8n.interactions.precisionDragToTarget(sourceWorkflowCard, destinationFolderCard); + + await expect( + n8n.notifications.getNotificationByTitleOrContent( + `${workflowName} has been moved to ${destinationFolder.name}`, + ), + ).toBeVisible(); + + await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0); + + await n8n.workflows.cards.openFolder(destinationFolder.name); + await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeVisible(); + }); + }); +}); diff --git a/packages/testing/playwright/tests/ui/49-folders-basic.spec.ts b/packages/testing/playwright/tests/ui/49-folders-basic.spec.ts index d086a2d0e38..d40c4d5560e 100644 --- a/packages/testing/playwright/tests/ui/49-folders-basic.spec.ts +++ b/packages/testing/playwright/tests/ui/49-folders-basic.spec.ts @@ -125,13 +125,15 @@ test.describe('Folders - Basic Operations', () => { }); test('should create workflow in a folder', async ({ n8n }) => { - const projectId = await n8n.start.fromNewProject(); + const { name: projectName, id: projectId } = await n8n.api.projects.createProject(); const folder = await n8n.api.projects.createFolder(projectId); - const folderName = folder.name; - await n8n.workflows.cards.openFolder(folderName); + await n8n.navigate.toFolder(folder.id, projectId); await n8n.workflows.addResource.workflow(); - - await expect(n8n.breadcrumbs.getBreadcrumb(folderName)).toBeVisible(); + await n8n.canvas.saveWorkflow(); + const successMessage = `Workflow successfully created in "${projectName}", within "${folder.name}"`; + await expect(n8n.notifications.getNotificationByTitleOrContent(successMessage)).toBeVisible(); + await n8n.navigate.toFolder(folder.id, projectId); + await expect(n8n.workflows.cards.getWorkflows()).toBeVisible(); }); test('should not create folders with invalid names in the UI', async ({ n8n }) => { diff --git a/packages/testing/playwright/tests/ui/49-folders-operations.spec.ts b/packages/testing/playwright/tests/ui/49-folders-operations.spec.ts new file mode 100644 index 00000000000..84b2f4501c0 --- /dev/null +++ b/packages/testing/playwright/tests/ui/49-folders-operations.spec.ts @@ -0,0 +1,290 @@ +import { test, expect } from '../../fixtures/base'; + +test.describe('Folders - Operations', () => { + test.describe('Rename and delete folders', () => { + test('should rename folder from breadcrumb dropdown', async ({ n8n }) => { + await n8n.start.fromNewProject(); + const folderName = await n8n.workflows.addFolder(); + const folderCard = n8n.workflows.cards.getFolder(folderName); + await n8n.workflows.cards.openCardActions(folderCard); + await n8n.workflows.cards.getCardAction('open').click(); + await n8n.breadcrumbs.renameCurrentBreadcrumb('Renamed'); + await n8n.breadcrumbs.getHomeProjectBreadcrumb().click(); + await expect(n8n.workflows.cards.getFolder('Renamed')).toBeVisible(); + }); + + test('should rename folder from card dropdown', async ({ n8n }) => { + await n8n.start.fromNewProject(); + const folderName = await n8n.workflows.addFolder(); + const folderCard = n8n.workflows.cards.getFolder(folderName); + await n8n.workflows.cards.openCardActions(folderCard); + await n8n.workflows.cards.getCardAction('rename').click(); + await n8n.workflows.fillFolderModal('Renamed', 'Rename'); + await expect(n8n.workflows.cards.getFolder('Renamed')).toBeVisible(); + }); + + test('should delete empty folder from card dropdown', async ({ n8n }) => { + await n8n.start.fromNewProject(); + const folderName = await n8n.workflows.addFolder(); + await n8n.workflows.cards.deleteFolder(folderName); + await expect(n8n.workflows.cards.getFolder(folderName)).toBeHidden(); + }); + + test('should delete empty folder from breadcrumb dropdown', async ({ n8n }) => { + await n8n.start.fromNewProject(); + const folderName = await n8n.workflows.addFolder(); + await n8n.workflows.cards.openFolder(folderName); + await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click(); + await n8n.breadcrumbs.getActionToggleDropdown('delete').click(); + await expect(n8n.workflows.cards.getFolder(folderName)).toBeHidden(); + }); + + test('should warn before deleting non-empty folder from breadcrumb dropdown', async ({ + n8n, + }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const folder = await n8n.api.projects.createFolder(projectId); + await n8n.api.workflows.createInProject(projectId, { + folder: folder.id, + }); + await n8n.navigate.toFolder(folder.id, projectId); + await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click(); + await n8n.breadcrumbs.getActionToggleDropdown('delete').click(); + await expect(n8n.workflows.deleteFolderModal()).toBeVisible(); + await expect(n8n.workflows.deleteModalConfirmButton()).toBeDisabled(); + }); + + test('should warn before deleting non-empty folder from card dropdown', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const folder = await n8n.api.projects.createFolder(projectId); + await n8n.api.workflows.createInProject(projectId, { + folder: folder.id, + }); + await n8n.navigate.toProject(projectId); + const folderCard = n8n.workflows.cards.getFolder(folder.name); + await n8n.workflows.cards.openCardActions(folderCard); + await n8n.workflows.cards.getCardAction('delete').click(); + await expect(n8n.workflows.deleteFolderModal()).toBeVisible(); + await expect(n8n.workflows.deleteModalConfirmButton()).toBeDisabled(); + }); + + test('should transfer contents when deleting non-empty folder - from card dropdown', async ({ + n8n, + }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const folderToDelete = await n8n.api.projects.createFolder(projectId); + await n8n.api.workflows.createInProject(projectId, { + folder: folderToDelete.id, + }); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + await n8n.navigate.toProject(projectId); + + const folderCard = n8n.workflows.cards.getFolder(folderToDelete.name); + await n8n.workflows.cards.openCardActions(folderCard); + await n8n.workflows.cards.getCardAction('delete').click(); + await n8n.workflows.deleteModalTransferRadioButton().click(); + await n8n.workflows.transferFolderDropdown().click(); + await n8n.workflows.transferFolderOption(destinationFolder.name).click(); + await n8n.workflows.deleteModalConfirmButton().click(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Folder deleted'), + ).toBeVisible(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getWorkflows()).toBeVisible(); + }); + }); + + test.describe('Move folders and workflows', () => { + test('should move empty folder to another folder - from folder card action', async ({ + n8n, + }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const sourceFolder = await n8n.api.projects.createFolder(projectId); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + await n8n.navigate.toProject(projectId); + + const sourceFolderCard = n8n.workflows.cards.getFolder(sourceFolder.name); + await n8n.workflows.cards.openCardActions(sourceFolderCard); + await n8n.workflows.cards.getCardAction('move').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + await n8n.workflows.moveFolderOption(destinationFolder.name).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Successfully moved folder'), + ).toBeVisible(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible(); + }); + + test('should move folder with contents to another folder - from folder card action', async ({ + n8n, + }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const sourceFolder = await n8n.api.projects.createFolder(projectId); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + await n8n.api.workflows.createInProject(projectId, { + folder: sourceFolder.id, + }); + + await n8n.navigate.toProject(projectId); + + const sourceFolderCard = n8n.workflows.cards.getFolder(sourceFolder.name); + await n8n.workflows.cards.openCardActions(sourceFolderCard); + await n8n.workflows.cards.getCardAction('move').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + await n8n.workflows.moveFolderOption(destinationFolder.name).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Successfully moved folder'), + ).toBeVisible(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible(); + await n8n.workflows.cards.openFolder(sourceFolder.name); + await expect(n8n.workflows.cards.getWorkflows()).toBeVisible(); + }); + + test('should move empty folder to another folder - from list breadcrumbs', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const sourceFolder = await n8n.api.projects.createFolder(projectId); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + await n8n.navigate.toFolder(sourceFolder.id, projectId); + await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click(); + await n8n.breadcrumbs.getActionToggleDropdown('move').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + await n8n.workflows.moveFolderOption(destinationFolder.name).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible(); + }); + + test('should move folder with contents to another folder - from list dropdown', async ({ + n8n, + }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const sourceFolder = await n8n.api.projects.createFolder(projectId); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + await n8n.api.workflows.createInProject(projectId, { + folder: sourceFolder.id, + }); + + await n8n.navigate.toFolder(sourceFolder.id, projectId); + await n8n.breadcrumbs.getFolderBreadcrumbsActionToggle().click(); + await n8n.breadcrumbs.getActionToggleDropdown('move').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + await n8n.workflows.moveFolderOption(destinationFolder.name).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getFolder(sourceFolder.name)).toBeVisible(); + await n8n.workflows.cards.openFolder(sourceFolder.name); + await expect(n8n.workflows.cards.getWorkflows()).toBeVisible(); + }); + + test('should move folder to project root - from folder card action', async ({ n8n }) => { + const project = await n8n.api.projects.createProject(); + const parentFolder = await n8n.api.projects.createFolder(project.id); + const childFolderName = 'Child Folder'; + const childFolder = await n8n.api.projects.createFolder( + project.id, + childFolderName, + parentFolder.id, + ); + + await n8n.navigate.toFolder(parentFolder.id, project.id); + + const childFolderCard = n8n.workflows.cards.getFolder(childFolder.name); + await n8n.workflows.cards.openCardActions(childFolderCard); + await n8n.workflows.cards.getCardAction('move').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + + const rootOption = 'No folder (project root)'; + await n8n.workflows.moveFolderOption(rootOption).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Successfully moved folder'), + ).toBeVisible(); + + await n8n.navigate.toProject(project.id); + await expect(n8n.workflows.cards.getFolder(childFolder.name)).toBeVisible(); + }); + + test('should move workflow from project root to folder', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + await n8n.api.workflows.createInProject(projectId); + + await n8n.navigate.toProject(projectId); + + const workflowCard = n8n.workflows.cards.getWorkflows().first(); + await n8n.workflows.cards.openCardActions(workflowCard); + await n8n.workflows.cards.getCardAction('moveToFolder').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + await n8n.workflows.moveFolderOption(destinationFolder.name).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Successfully moved workflow'), + ).toBeVisible(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getWorkflows()).toBeVisible(); + }); + + test('should move workflow to another folder', async ({ n8n }) => { + const { id: projectId } = await n8n.api.projects.createProject(); + const sourceFolder = await n8n.api.projects.createFolder(projectId); + const destinationFolder = await n8n.api.projects.createFolder(projectId); + + const { name: workflowName } = await n8n.api.workflows.createInProject(projectId, { + folder: sourceFolder.id, + }); + + await n8n.navigate.toFolder(sourceFolder.id, projectId); + + const workflowCard = n8n.workflows.cards.getWorkflow(workflowName); + await n8n.workflows.cards.openCardActions(workflowCard); + await n8n.workflows.cards.getCardAction('moveToFolder').click(); + + await expect(n8n.workflows.moveFolderModal()).toBeVisible(); + await n8n.workflows.moveFolderDropdown().click(); + await n8n.workflows.moveFolderOption(destinationFolder.name).click(); + await n8n.workflows.moveFolderConfirmButton().click(); + + await expect( + n8n.notifications.getNotificationByTitleOrContent('Successfully moved workflow'), + ).toBeVisible(); + + await n8n.navigate.toFolder(destinationFolder.id, projectId); + await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeVisible(); + + await n8n.navigate.toFolder(sourceFolder.id, projectId); + await expect(n8n.workflows.cards.getWorkflow(workflowName)).toBeHidden(); + }); + }); +}); 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 index 8d266d371c3..3c84edbf752 100644 --- a/packages/testing/playwright/tests/ui/building-blocks/04-credentials.spec.ts +++ b/packages/testing/playwright/tests/ui/building-blocks/04-credentials.spec.ts @@ -77,7 +77,7 @@ test.describe('04 - Credentials', () => { const uniqueCredentialName = `credential-${nanoid()}`; const projectId = await n8n.start.fromNewProjectBlankCanvas(); - await n8n.api.credentialApi.createCredential({ + await n8n.api.credentials.createCredential({ name: uniqueCredentialName, type: 'notionApi', data: { diff --git a/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts b/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts index dae741e4bd6..79eaaa3a0d1 100644 --- a/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts +++ b/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts @@ -15,25 +15,25 @@ test.describe('Credential API Operations', () => { }; const { credentialId, createdCredential } = - await api.credentialApi.createCredentialFromDefinition(credentialData); + await api.credentials.createCredentialFromDefinition(credentialData); expect(credentialId).toBeTruthy(); expect(createdCredential.type).toBe('httpBasicAuth'); expect(createdCredential.name).toContain('Test HTTP Basic Auth (Test'); - const retrievedCredential = await api.credentialApi.getCredential(credentialId); + const retrievedCredential = await api.credentials.getCredential(credentialId); expect(retrievedCredential.id).toBe(credentialId); expect(retrievedCredential.type).toBe('httpBasicAuth'); expect(retrievedCredential.name).toBe(createdCredential.name); - const credentialWithData = await api.credentialApi.getCredential(credentialId, { + const credentialWithData = await api.credentials.getCredential(credentialId, { includeData: true, }); expect(credentialWithData.data).toBeDefined(); expect(credentialWithData.data?.user).toBe('test_user'); const updatedName = 'Updated HTTP Basic Auth'; - const updatedCredential = await api.credentialApi.updateCredential(credentialId, { + const updatedCredential = await api.credentials.updateCredential(credentialId, { name: updatedName, data: { user: 'updated_user', @@ -42,47 +42,47 @@ test.describe('Credential API Operations', () => { }); expect(updatedCredential.name).toBe(updatedName); - const verifyUpdated = await api.credentialApi.getCredential(credentialId, { + const verifyUpdated = await api.credentials.getCredential(credentialId, { includeData: true, }); expect(verifyUpdated.name).toBe(updatedName); expect(verifyUpdated.data?.user).toBe('updated_user'); - const deleteResult = await api.credentialApi.deleteCredential(credentialId); + const deleteResult = await api.credentials.deleteCredential(credentialId); expect(deleteResult).toBe(true); - await expect(api.credentialApi.getCredential(credentialId)).rejects.toThrow(); + await expect(api.credentials.getCredential(credentialId)).rejects.toThrow(); }); }); test.describe('Credential Listing', () => { test('should list credentials with different query options', async ({ api }) => { - const credential1 = await api.credentialApi.createCredentialFromDefinition({ + const credential1 = await api.credentials.createCredentialFromDefinition({ name: 'First Test Credential', type: 'httpBasicAuth', data: { user: 'user1', password: 'pass1' }, }); - const credential2 = await api.credentialApi.createCredentialFromDefinition({ + const credential2 = await api.credentials.createCredentialFromDefinition({ name: 'Second Test Credential', type: 'httpHeaderAuth', data: { name: 'Authorization', value: 'Bearer token' }, }); - const allCredentials = await api.credentialApi.getCredentials(); + const allCredentials = await api.credentials.getCredentials(); expect(allCredentials.length).toBeGreaterThanOrEqual(2); const createdIds = [credential1.credentialId, credential2.credentialId]; const foundCredentials = allCredentials.filter((c) => createdIds.includes(c.id)); expect(foundCredentials).toHaveLength(2); - const credentialsWithScopes = await api.credentialApi.getCredentials({ + const credentialsWithScopes = await api.credentials.getCredentials({ includeScopes: true, }); expect(credentialsWithScopes[0].scopes).toBeDefined(); expect(Array.isArray(credentialsWithScopes[0].scopes)).toBe(true); - const credentialsWithData = await api.credentialApi.getCredentials({ + const credentialsWithData = await api.credentials.getCredentials({ includeData: true, }); const foundWithData = credentialsWithData.filter((c) => createdIds.includes(c.id)); @@ -98,14 +98,14 @@ test.describe('Credential API Operations', () => { const project = await api.projects.createProject('Test Project for Credentials'); - const credential = await api.credentialApi.createCredentialFromDefinition({ + const credential = await api.credentials.createCredentialFromDefinition({ name: 'Project Credential', type: 'httpBasicAuth', data: { user: 'user', password: 'pass' }, projectId: project.id, }); - const projectCredentials = await api.credentialApi.getCredentialsForWorkflow({ + const projectCredentials = await api.credentials.getCredentialsForWorkflow({ projectId: project.id, }); @@ -124,22 +124,22 @@ test.describe('Credential API Operations', () => { const sourceProject = await api.projects.createProject('Source Project'); const destinationProject = await api.projects.createProject('Destination Project'); - const credential = await api.credentialApi.createCredentialFromDefinition({ + const credential = await api.credentials.createCredentialFromDefinition({ name: 'Transfer Test Credential', type: 'httpBasicAuth', data: { user: 'user', password: 'pass' }, projectId: sourceProject.id, }); - const sourceCredentials = await api.credentialApi.getCredentialsForWorkflow({ + const sourceCredentials = await api.credentials.getCredentialsForWorkflow({ projectId: sourceProject.id, }); const foundInSource = sourceCredentials.find((c) => c.id === credential.credentialId); expect(foundInSource).toBeDefined(); - await api.credentialApi.transferCredential(credential.credentialId, destinationProject.id); + await api.credentials.transferCredential(credential.credentialId, destinationProject.id); - const destinationCredentials = await api.credentialApi.getCredentialsForWorkflow({ + const destinationCredentials = await api.credentials.getCredentialsForWorkflow({ projectId: destinationProject.id, }); const foundInDestination = destinationCredentials.find( @@ -147,7 +147,7 @@ test.describe('Credential API Operations', () => { ); expect(foundInDestination).toBeDefined(); - const sourceCredentialsAfter = await api.credentialApi.getCredentialsForWorkflow({ + const sourceCredentialsAfter = await api.credentials.getCredentialsForWorkflow({ projectId: sourceProject.id, }); const stillInSource = sourceCredentialsAfter.find((c) => c.id === credential.credentialId); @@ -166,27 +166,27 @@ test.describe('Credential API Operations', () => { }, }; - const { credentialId } = await api.credentialApi.createCredentialFromDefinition(originalData); + const { credentialId } = await api.credentials.createCredentialFromDefinition(originalData); - const afterCreate = await api.credentialApi.getCredential(credentialId, { + const afterCreate = await api.credentials.getCredential(credentialId, { includeData: true, }); expect(afterCreate.data?.user).toBe('persistent_user'); - await api.credentialApi.updateCredential(credentialId, { + await api.credentials.updateCredential(credentialId, { data: { user: 'updated_persistent_user', password: 'updated_persistent_password', }, }); - const afterUpdate = await api.credentialApi.getCredential(credentialId, { + const afterUpdate = await api.credentials.getCredential(credentialId, { includeData: true, }); expect(afterUpdate.data?.user).toBe('updated_persistent_user'); expect(afterUpdate.data?.password).toBeDefined(); - const allCredentials = await api.credentialApi.getCredentials(); + const allCredentials = await api.credentials.getCredentials(); const foundCredential = allCredentials.find((c) => c.id === credentialId); expect(foundCredential).toBeDefined(); expect(foundCredential!.type).toBe('httpBasicAuth'); diff --git a/packages/testing/playwright/tests/ui/evaluations.spec.ts b/packages/testing/playwright/tests/ui/evaluations.spec.ts index 06e06ed608d..e1bfde0ce54 100644 --- a/packages/testing/playwright/tests/ui/evaluations.spec.ts +++ b/packages/testing/playwright/tests/ui/evaluations.spec.ts @@ -11,7 +11,7 @@ test.describe('Evaluations @capability:proxy', () => { test('should load evaluations workflow and execute twice', async ({ n8n, proxyServer }) => { await proxyServer.loadExpectations('evaluations'); - await n8n.api.credentialApi.createCredentialFromDefinition({ + await n8n.api.credentials.createCredentialFromDefinition({ name: 'Test Google Sheets', type: 'googleApi', data: { diff --git a/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts b/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts index 12f2f3e2f08..64cd8bea7dc 100644 --- a/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts +++ b/packages/testing/playwright/tests/ui/webhook-external-trigger.spec.ts @@ -4,7 +4,7 @@ test.describe('External Webhook Triggering', () => { test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({ api, }) => { - const { webhookPath, workflowId } = await api.workflowApi.importWorkflow( + const { webhookPath, workflowId } = await api.workflows.importWorkflow( 'simple-webhook-test.json', ); @@ -16,15 +16,15 @@ test.describe('External Webhook Triggering', () => { expect(webhookResponse.ok()).toBe(true); - const execution = await api.workflowApi.waitForExecution(workflowId, 5000); + const execution = await api.workflows.waitForExecution(workflowId, 5000); expect(execution.status).toBe('success'); - const executionDetails = await api.workflowApi.getExecution(execution.id); + const executionDetails = await api.workflows.getExecution(execution.id); expect(executionDetails.data).toContain('Hello from Playwright test'); }); test('should surface workflow configuration errors to the caller', async ({ api }) => { - const { webhookPath } = await api.workflowApi.importWorkflow( + const { webhookPath } = await api.workflows.importWorkflow( 'webhook-misconfiguration-test.json', ); diff --git a/packages/testing/playwright/tests/ui/webhook-origin-isolation.spec.ts b/packages/testing/playwright/tests/ui/webhook-origin-isolation.spec.ts index 17f316e1108..420037c0c3c 100644 --- a/packages/testing/playwright/tests/ui/webhook-origin-isolation.spec.ts +++ b/packages/testing/playwright/tests/ui/webhook-origin-isolation.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '../../fixtures/base'; test.describe('Webhook Origin Isolation', () => { test.beforeAll(async ({ api }) => { - await api.workflowApi.importWorkflow('webhook-origin-isolation.json', { makeUnique: false }); + await api.workflows.importWorkflow('webhook-origin-isolation.json', { makeUnique: false }); }); const webhookPaths = [